fix: Windows reads of lock files, update paths cfg(unix), merge main again (P11c)
spec_vault swept every file under a book, lock files included; on Windows a held LockFileEx lock is mandatory and the read failed. The locks are already dedicated files (never data), so the test skips *.lock. Merged main (P13 step 6, the macOS server.log assert, P11a's AppImage identity): host.rs keeps main's five-field identity on Unix and size/last-write on Windows; the update poll and set_program_version are Unix-only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
commit
7749afc557
234 files changed
+13028
-18411
No files matched your search
@@ -323,7 +323,9 @@ fn run_local(mode: &str, args: Vec<String>) -> i32 {
|
||||
"connect" => ledger_host::commands::connect(&prog, args),
|
||||
"passphrase" => ledger_host::commands::passphrase(&prog, args),
|
||||
#[cfg(unix)]
|
||||
"update" => ledger_host::update::update(&prog, env!("CARGO_PKG_VERSION"), args),
|
||||
"update" => {
|
||||
ledger_host::update::update(&prog, ledger_host::update::program_version(), args)
|
||||
}
|
||||
#[cfg(unix)]
|
||||
"rollback" => ledger_host::update::rollback(&prog, args),
|
||||
// Updates replace a running program's file in place, which Windows
|
||||
@@ -377,6 +379,8 @@ fn is_bare_launch(words: &[String], stdin_is_terminal: bool) -> bool {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[cfg(unix)]
|
||||
ledger_host::update::set_program_version(env!("CARGO_PKG_VERSION"));
|
||||
// A file's path may be any name the system takes: the file commands
|
||||
// get the words as given. Everything else is text.
|
||||
let given: Vec<std::ffi::OsString> = env::args_os().skip(1).collect();
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Agent evals (plan P5 step 7): scripted transcripts, no model. Each is a
|
||||
//! JSON file in `agent_evals/`, played against a real books server through
|
||||
//! the wire as an agent reaches it. The rule they enforce is the point of
|
||||
//! the result envelope: **an agent that begins with `books.start` can do
|
||||
//! its work by calling only what the previous result's `next_steps` named.**
|
||||
//!
|
||||
//! A transcript is `{ "setup": [..], "settings": "<toml>", "steps": [..] }`.
|
||||
//! `setup` calls are made directly (the chart and the books an agent finds
|
||||
//! when it arrives; they are not the agent's moves). A step is one of:
|
||||
//!
|
||||
//! - `{"call": "books.start", "args": {..}}`: only the first step, and
|
||||
//! only `books.start`.
|
||||
//! - `{"follow": "books.x", "with": {..}}`: the previous result's
|
||||
//! `next_steps` must name tool `books.x`; the call is made with that
|
||||
//! step's `args` and `with` laid over them (what the agent decides: an
|
||||
//! amount, a date).
|
||||
//! - `{"follow_prompt": "onboard"}`: the previous result's `next_steps`
|
||||
//! must name that prompt; it is fetched with `prompts/get`.
|
||||
//! - `{"expect_next_steps_contains": "books.x"}` and
|
||||
//! `{"expect": {"/json/pointer": value}}` on the previous result.
|
||||
//!
|
||||
//! In `with` and `args`, `{"$upload": {"name", "mime", "text"}}` is the
|
||||
//! upload_id of that file, uploaded first as an agent does, and
|
||||
//! `{"$var": "x"}` is a value a setup call saved with `"save": {"x":
|
||||
//! "/pointer"}`.
|
||||
//!
|
||||
//! Expected behavior comes from `docs/mcp-interface.md` ("The result
|
||||
//! envelope") and plan 4.6.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{Books, Wire, answer_of};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A value with `$upload` and `$var` laid in.
|
||||
fn resolve(w: &mut Wire, vars: &HashMap<String, Value>, v: &Value) -> Value {
|
||||
match v {
|
||||
Value::Object(o) if o.len() == 1 && o.contains_key("$upload") => {
|
||||
let u = &o["$upload"];
|
||||
let text = u["text"].as_str().expect("an upload has text");
|
||||
json!(w.upload(
|
||||
text.as_bytes(),
|
||||
u["name"].as_str().unwrap_or("file.pdf"),
|
||||
u["mime"].as_str().unwrap_or("application/pdf"),
|
||||
u["purpose"].as_str().unwrap_or("receipt"),
|
||||
))
|
||||
}
|
||||
Value::Object(o) if o.len() == 1 && o.contains_key("$var") => vars
|
||||
.get(o["$var"].as_str().unwrap())
|
||||
.unwrap_or_else(|| panic!("no saved value {}", o["$var"]))
|
||||
.clone(),
|
||||
Value::Object(o) => Value::Object(
|
||||
o.iter()
|
||||
.map(|(k, x)| (k.clone(), resolve(w, vars, x)))
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(a) => Value::Array(a.iter().map(|x| resolve(w, vars, x)).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Play one transcript; returns the tools it called after `books.start`.
|
||||
fn play(name: &str, script: &Value) -> Vec<String> {
|
||||
let books = Books::with(&format!("eval-{name}"), "USD", script["settings"].as_str());
|
||||
let mut w = books.session("agent-evals");
|
||||
let mut vars: HashMap<String, Value> = HashMap::new();
|
||||
for s in script["setup"].as_array().into_iter().flatten() {
|
||||
let args = resolve(&mut w, &vars, &s["args"]);
|
||||
let tool = s["call"].as_str().unwrap();
|
||||
let out =
|
||||
answer_of(&w.call(tool, args)).unwrap_or_else(|e| panic!("{name}: setup {tool}: {e}"));
|
||||
for (var, pointer) in s["save"].as_object().into_iter().flatten() {
|
||||
vars.insert(
|
||||
var.clone(),
|
||||
out.pointer(pointer.as_str().unwrap())
|
||||
.unwrap_or_else(|| panic!("{name}: setup {tool} has no {pointer}: {out}"))
|
||||
.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut last: Option<Value> = None;
|
||||
let mut called = Vec::new();
|
||||
for (i, step) in script["steps"]
|
||||
.as_array()
|
||||
.expect("steps")
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let here = format!("{name} step {i}");
|
||||
if let Some(tool) = step["call"].as_str() {
|
||||
assert_eq!(
|
||||
i, 0,
|
||||
"{here}: only the first step may call without following"
|
||||
);
|
||||
assert_eq!(tool, "books.start", "{here}: the first call is books.start");
|
||||
let args = resolve(&mut w, &vars, &step["args"]);
|
||||
last =
|
||||
Some(answer_of(&w.call_raw(tool, args)).unwrap_or_else(|e| panic!("{here}: {e}")));
|
||||
} else if let Some(want) = step["follow"].as_str() {
|
||||
let prev = last
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("{here}: follow before start"));
|
||||
let offered = prev["next_steps"].as_array().cloned().unwrap_or_default();
|
||||
let named = offered
|
||||
.iter()
|
||||
.find(|s| s["tool"] == json!(want))
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"{here}: {want} is not in the previous result's next_steps: {:?}",
|
||||
offered
|
||||
.iter()
|
||||
.map(|s| s["tool"].clone())
|
||||
.collect::<Vec<_>>()
|
||||
)
|
||||
});
|
||||
let mut args: Map<String, Value> =
|
||||
named["args"].as_object().cloned().unwrap_or_default();
|
||||
for (k, v) in step["with"].as_object().into_iter().flatten() {
|
||||
args.insert(k.clone(), resolve(&mut w, &vars, v));
|
||||
}
|
||||
let out = answer_of(&w.call_raw(want, Value::Object(args)))
|
||||
.unwrap_or_else(|e| panic!("{here}: {want} was refused: {e}"));
|
||||
called.push(want.to_string());
|
||||
last = Some(out);
|
||||
} else if let Some(want) = step["follow_prompt"].as_str() {
|
||||
let prev = last
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("{here}: follow before start"));
|
||||
assert!(
|
||||
prev["next_steps"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|s| s["prompt"] == json!(want)),
|
||||
"{here}: prompt {want} is not in next_steps"
|
||||
);
|
||||
let got = w.rpc("prompts/get", json!({"name": want, "arguments": {}}));
|
||||
let text = got["result"]["messages"][0]["content"]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{here}: the prompt did not render: {got}"));
|
||||
assert!(text.contains("books."), "{here}: {text}");
|
||||
} else if let Some(tool) = step["expect_next_steps_contains"].as_str() {
|
||||
let prev = last.as_ref().unwrap();
|
||||
assert!(
|
||||
prev["next_steps"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|s| s["tool"] == json!(tool)),
|
||||
"{here}: next_steps lack {tool}: {}",
|
||||
prev["next_steps"]
|
||||
);
|
||||
} else if let Some(expect) = step["expect"].as_object() {
|
||||
let prev = last.as_ref().unwrap();
|
||||
for (pointer, want) in expect {
|
||||
assert_eq!(
|
||||
prev.pointer(pointer),
|
||||
Some(want),
|
||||
"{here}: {pointer} in {prev}"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
panic!("{here}: not a step: {step}");
|
||||
}
|
||||
}
|
||||
called
|
||||
}
|
||||
|
||||
/// Every transcript follows only what it was told, and ends where it says.
|
||||
#[test]
|
||||
fn agent_evals_follow_only_next_steps() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/agent_evals");
|
||||
let mut files: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "json"))
|
||||
.collect();
|
||||
files.sort();
|
||||
let names: Vec<String> = files
|
||||
.iter()
|
||||
.map(|p| p.file_stem().unwrap().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"draft_awaiting_a_receipt",
|
||||
"month_end_close",
|
||||
"onboarding_to_first_post",
|
||||
"receipt_backed_post",
|
||||
"reconciliation",
|
||||
"reversal"
|
||||
],
|
||||
"the six evals the plan names"
|
||||
);
|
||||
for (name, path) in names.iter().zip(&files) {
|
||||
let script: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap())
|
||||
.unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let called = play(name, &script);
|
||||
assert!(!called.is_empty(), "{name} calls nothing");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"description": "A draft that waits for its receipt, and posts when it arrives.",
|
||||
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
"setup": [
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "6000",
|
||||
"name": "Rent",
|
||||
"account_type": "expense"
|
||||
}
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.draft_entry",
|
||||
"with": {
|
||||
"date": "2026-01-10",
|
||||
"description": "Landlord, January",
|
||||
"no_party_reason": "test"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "6000",
|
||||
"side": "debit",
|
||||
"amount": "40.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "1000",
|
||||
"side": "credit",
|
||||
"amount": "40.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.set_draft_state",
|
||||
"with": {
|
||||
"state": "awaiting_evidence",
|
||||
"note": "the landlord's receipt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/state": "awaiting_evidence"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.attach_receipt",
|
||||
"with": {
|
||||
"upload_id": {
|
||||
"$upload": {
|
||||
"name": "receipt.pdf",
|
||||
"mime": "application/pdf",
|
||||
"text": "%PDF-1.4 a receipt for rent",
|
||||
"purpose": "receipt"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "posted"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"description": "A period that ended is closed: the open period is found, previewed, closed, and the books verified.",
|
||||
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
"setup": [
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "6000",
|
||||
"name": "Rent",
|
||||
"account_type": "expense"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.create_period",
|
||||
"args": {
|
||||
"name": "2025-12",
|
||||
"start": "2025-12-01",
|
||||
"end": "2025-12-31"
|
||||
}
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {
|
||||
"focus": "close"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.list_periods"
|
||||
},
|
||||
{
|
||||
"follow": "books.list_periods",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.close_period",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "preview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.close_period",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "closed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.verify"
|
||||
},
|
||||
{
|
||||
"follow": "books.verify",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "ok"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"description": "A new book: onboarding to the first posted entry, following only next_steps.",
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {
|
||||
"agent": {
|
||||
"model": "scripted",
|
||||
"reasoning_effort": "none",
|
||||
"harness": "agent_evals"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/state": "needs_onboarding"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.set_setting"
|
||||
},
|
||||
{
|
||||
"follow_prompt": "onboard"
|
||||
},
|
||||
{
|
||||
"follow": "books.set_setting",
|
||||
"with": {
|
||||
"changes": [
|
||||
{
|
||||
"key": "company.name",
|
||||
"value": "Eval Co"
|
||||
}
|
||||
],
|
||||
"decided_by": "Avery Quill"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.add_account"
|
||||
},
|
||||
{
|
||||
"follow": "books.add_account",
|
||||
"with": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_account",
|
||||
"with": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.draft_entry",
|
||||
"with": {
|
||||
"date": "2026-01-05",
|
||||
"description": "Opening capital",
|
||||
"no_party_reason": "opening balance"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "1000",
|
||||
"side": "debit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "3000",
|
||||
"side": "credit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "preview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "posted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.show"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"description": "A post the receipt policy covers: the preview asks for the receipt, the receipt is attached, the entry posts.",
|
||||
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
"setup": [
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "6000",
|
||||
"name": "Rent",
|
||||
"account_type": "expense"
|
||||
}
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.draft_entry"
|
||||
},
|
||||
{
|
||||
"follow": "books.draft_entry",
|
||||
"with": {
|
||||
"date": "2026-01-10",
|
||||
"description": "January rent",
|
||||
"no_party_reason": "test"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "6000",
|
||||
"side": "debit",
|
||||
"amount": "40.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "1000",
|
||||
"side": "credit",
|
||||
"amount": "40.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.attach_receipt"
|
||||
},
|
||||
{
|
||||
"follow": "books.attach_receipt",
|
||||
"with": {
|
||||
"upload_id": {
|
||||
"$upload": {
|
||||
"name": "receipt.pdf",
|
||||
"mime": "application/pdf",
|
||||
"text": "%PDF-1.4 a receipt for rent",
|
||||
"purpose": "receipt"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "posted"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"description": "A cash account reconciled to its statement: record it, mark the lines, reconcile.",
|
||||
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
"setup": [
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "6000",
|
||||
"name": "Rent",
|
||||
"account_type": "expense"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.draft_entry",
|
||||
"args": {
|
||||
"date": "2026-01-05",
|
||||
"description": "Opening capital",
|
||||
"no_party_reason": "opening balance"
|
||||
},
|
||||
"save": {
|
||||
"entry": "/entry_id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_line",
|
||||
"args": {
|
||||
"entry_id": {
|
||||
"$var": "entry"
|
||||
},
|
||||
"account": "1000",
|
||||
"side": "debit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_line",
|
||||
"args": {
|
||||
"entry_id": {
|
||||
"$var": "entry"
|
||||
},
|
||||
"account": "3000",
|
||||
"side": "credit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.post_entry",
|
||||
"args": {
|
||||
"entry_id": {
|
||||
"$var": "entry"
|
||||
},
|
||||
"confirmed": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.file_document",
|
||||
"args": {
|
||||
"category": "statement",
|
||||
"file_name": "jan.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"upload_id": {
|
||||
"$upload": {
|
||||
"name": "jan.pdf",
|
||||
"mime": "application/pdf",
|
||||
"text": "%PDF-1.4 January statement",
|
||||
"purpose": "document"
|
||||
}
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"statement": "/attachment_id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {}
|
||||
},
|
||||
{
|
||||
"expect_next_steps_contains": "books.record_statement"
|
||||
},
|
||||
{
|
||||
"follow": "books.record_statement",
|
||||
"with": {
|
||||
"attachment_id": {
|
||||
"$var": "statement"
|
||||
},
|
||||
"account": "1000",
|
||||
"period_start": "2026-01-01",
|
||||
"period_end": "2026-01-31",
|
||||
"opening_balance": "0.00",
|
||||
"closing_balance": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.mark_cleared",
|
||||
"with": {
|
||||
"entry_id": {
|
||||
"$var": "entry"
|
||||
},
|
||||
"line_index": 0,
|
||||
"cleared": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.reconcile",
|
||||
"with": {
|
||||
"account": "1000",
|
||||
"statement_date": "2026-01-31",
|
||||
"statement_balance": "10.00",
|
||||
"statement_attachment": {
|
||||
"$var": "statement"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "preview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.reconcile",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "reconciled"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"description": "A posted entry reversed: previewed, confirmed, never edited.",
|
||||
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
"setup": [
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "1000",
|
||||
"name": "Cash",
|
||||
"account_type": "asset"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "3000",
|
||||
"name": "Capital",
|
||||
"account_type": "equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "books.add_account",
|
||||
"args": {
|
||||
"code": "6000",
|
||||
"name": "Rent",
|
||||
"account_type": "expense"
|
||||
}
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"call": "books.start",
|
||||
"args": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.draft_entry",
|
||||
"with": {
|
||||
"date": "2026-01-10",
|
||||
"description": "Coffee beans",
|
||||
"no_party_reason": "test"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "6000",
|
||||
"side": "debit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.add_line",
|
||||
"with": {
|
||||
"account": "1000",
|
||||
"side": "credit",
|
||||
"amount": "10.00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"follow": "books.post_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "posted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.reverse_entry",
|
||||
"with": {
|
||||
"date": "2026-01-11",
|
||||
"description": "Reverse: entered in error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "preview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"follow": "books.reverse_entry",
|
||||
"with": {}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"/status": "posted"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
//! The agent surface over the wire (plan P5): `books.start` v2, the
|
||||
//! journal in words, source corrections, the prompts and the resource
|
||||
//! templates. Every call goes through a real books server as an agent
|
||||
//! reaches it (`common.rs`).
|
||||
//!
|
||||
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — the
|
||||
//! journal in words (P5)", "The result envelope", "Prompts", "Resources")
|
||||
//! and plan 4.2, 4.4, 4.5.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{Books, answer_of};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
const SETTINGS: &str = "[company]\nname = \"Surface Co\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\ncash_accounts = [\"1000\"]\n";
|
||||
|
||||
fn chart(w: &mut common::Wire) {
|
||||
for (code, name, ty) in [
|
||||
("1000", "Cash", "asset"),
|
||||
("3000", "Capital", "equity"),
|
||||
("6000", "Rent", "expense"),
|
||||
] {
|
||||
answer_of(&w.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": ty}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A posted entry of `amount` rent paid from cash (under the receipt
|
||||
/// threshold); its id.
|
||||
fn post(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 {
|
||||
let id = answer_of(&w.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": date, "description": what, "no_party_reason": "test"}),
|
||||
))
|
||||
.unwrap()["entry_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
answer_of(&w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// `books.journal_history` reads the journal in order, in words; filters
|
||||
/// by subject and type; pages with `more`; refuses a bad subject; and a
|
||||
/// refusal changes nothing.
|
||||
#[test]
|
||||
fn journal_history_traces_what_the_books_did() {
|
||||
let books = Books::with("p5-history", "USD", Some(SETTINGS));
|
||||
let mut w = books.session("p5");
|
||||
chart(&mut w);
|
||||
let first = post(&mut w, "2026-01-05", "January rent", "10.00");
|
||||
let second = post(&mut w, "2026-01-06", "Printer paper", "11.00");
|
||||
|
||||
let all = answer_of(&w.call("books.journal_history", json!({"limit": 200}))).unwrap();
|
||||
let lines = all["lines"].as_array().unwrap();
|
||||
let seqs: Vec<u64> = lines.iter().map(|l| l["seq"].as_u64().unwrap()).collect();
|
||||
assert!(
|
||||
seqs.windows(2).all(|p| p[0] < p[1]),
|
||||
"journal order: {seqs:?}"
|
||||
);
|
||||
assert_eq!(all["head_seq"], *seqs.last().unwrap());
|
||||
assert_eq!(all["more"], false);
|
||||
let posted = lines
|
||||
.iter()
|
||||
.find(|l| l["type"] == "entry.posted" && l["entry_id"] == json!(first))
|
||||
.unwrap_or_else(|| panic!("no entry.posted for {first}: {all}"));
|
||||
assert!(
|
||||
posted["summary"].as_str().unwrap().contains("January rent"),
|
||||
"{posted}"
|
||||
);
|
||||
|
||||
// A subject keeps the lines about it, and only those.
|
||||
let about = answer_of(&w.call(
|
||||
"books.journal_history",
|
||||
json!({"subject": format!("entry:{second}")}),
|
||||
))
|
||||
.unwrap();
|
||||
let about = about["lines"].as_array().unwrap();
|
||||
assert!(about.len() >= 3, "created, lines, posted: {about:?}");
|
||||
assert!(
|
||||
about.iter().all(|l| l["entry_id"] == json!(second)),
|
||||
"{about:?}"
|
||||
);
|
||||
|
||||
// A type group, and an exact type.
|
||||
let group = answer_of(&w.call("books.journal_history", json!({"types": ["entry."]}))).unwrap();
|
||||
assert!(
|
||||
group["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|l| l["type"].as_str().unwrap().starts_with("entry."))
|
||||
);
|
||||
let exact =
|
||||
answer_of(&w.call("books.journal_history", json!({"types": ["entry.posted"]}))).unwrap();
|
||||
assert_eq!(exact["lines"].as_array().unwrap().len(), 2);
|
||||
|
||||
// Paging: two at a time, from where the last page ended.
|
||||
let page = answer_of(&w.call("books.journal_history", json!({"limit": 2}))).unwrap();
|
||||
assert_eq!(page["lines"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(page["more"], true);
|
||||
let after = page["lines"][1]["seq"].as_u64().unwrap();
|
||||
let next = answer_of(&w.call(
|
||||
"books.journal_history",
|
||||
json!({"after_seq": after, "limit": 2}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(next["lines"][0]["seq"].as_u64().unwrap(), after + 1);
|
||||
|
||||
// A bad subject is refused with the shapes; the journal is unchanged.
|
||||
let before = books.journal();
|
||||
let err =
|
||||
answer_of(&w.call("books.journal_history", json!({"subject": "nonsense"}))).unwrap_err();
|
||||
assert!(err.contains("entry:12"), "{err}");
|
||||
assert_eq!(books.journal(), before);
|
||||
// It is a read: no head in the envelope.
|
||||
assert!(all.get("head").is_none(), "{all}");
|
||||
}
|
||||
|
||||
/// A source-only correction over the wire: the three tools take `reason`
|
||||
/// (or `revises` with it), the words stay, the journal says so, and a
|
||||
/// change of the words is not a correction.
|
||||
#[test]
|
||||
fn a_source_only_revision_over_the_wire() {
|
||||
let books = Books::with("p5-source", "USD", Some(SETTINGS));
|
||||
let mut w = books.session("p5");
|
||||
let convention = |by: &str, body: &str| {
|
||||
json!({"id": "close-checklist", "kind": "close_step", "title": "Close checklist",
|
||||
"body_md": body, "decided_by": by, "decided_on": "2026-10-20"})
|
||||
};
|
||||
answer_of(&w.call(
|
||||
"books.set_convention",
|
||||
convention("Owner", "Reconcile, then close."),
|
||||
))
|
||||
.unwrap();
|
||||
let same = answer_of(&w.call(
|
||||
"books.set_convention",
|
||||
convention("Pat Smith", "Reconcile, then close."),
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
same.contains("already says exactly that") && same.contains("reason"),
|
||||
"{same}"
|
||||
);
|
||||
let mut fixed = convention("Pat Smith", "Reconcile, then close.");
|
||||
fixed["reason"] = json!("the CPA decided it");
|
||||
fixed["citation"] = json!("IRS Pub. 538");
|
||||
let out = answer_of(&w.call("books.set_convention", fixed.clone())).unwrap();
|
||||
assert_eq!(
|
||||
out["convention"]["source"]["decided_by"], "Pat Smith",
|
||||
"{out}"
|
||||
);
|
||||
assert_eq!(out["convention"]["revision"], 1, "the words did not change");
|
||||
let mut other = convention("Pat Smith", "Close first.");
|
||||
other["reason"] = json!("x");
|
||||
let err = answer_of(&w.call("books.set_convention", other)).unwrap_err();
|
||||
assert!(err.contains("more than its source"), "{err}");
|
||||
|
||||
// A decision and an open item.
|
||||
let decision = answer_of(&w.call(
|
||||
"books.record_decision",
|
||||
json!({"subject": "Materiality", "decision_md": "Five hundred dollars.",
|
||||
"decided_by": ["Owner"], "decided_on": "2026-10-20"}),
|
||||
))
|
||||
.unwrap()["id"]
|
||||
.clone();
|
||||
let fixed_decision = json!({"revises": decision, "reason": "both decided it",
|
||||
"decided_by": ["Owner", "Pat Smith"], "decided_on": "2026-10-21"});
|
||||
answer_of(&w.call("books.record_decision", fixed_decision.clone())).unwrap();
|
||||
let d = answer_of(&w.call("books.decisions", json!({}))).unwrap();
|
||||
assert_eq!(
|
||||
d["decisions"][0]["decided_by"],
|
||||
json!(["Owner", "Pat Smith"]),
|
||||
"{d}"
|
||||
);
|
||||
assert_eq!(d["decisions"][0]["decision_md"], "Five hundred dollars.");
|
||||
let no_reason = answer_of(&w.call(
|
||||
"books.record_decision",
|
||||
json!({"revises": decision, "decided_by": ["Pat Smith"], "decided_on": "2026-10-22"}),
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(no_reason.contains("reason"), "{no_reason}");
|
||||
let item = answer_of(&w.call(
|
||||
"books.raise_item",
|
||||
json!({"title": "Ask the CPA", "decided_by": "Owner", "decided_on": "2026-10-20"}),
|
||||
))
|
||||
.unwrap()["id"]
|
||||
.clone();
|
||||
answer_of(&w.call(
|
||||
"books.raise_item",
|
||||
json!({"revises": item, "reason": "raised by the CPA", "decided_by": "Pat Smith",
|
||||
"decided_on": "2026-10-21"}),
|
||||
))
|
||||
.unwrap();
|
||||
let items = answer_of(&w.call("books.open_items", json!({}))).unwrap();
|
||||
assert_eq!(items["items"][0]["title"], "Ask the CPA");
|
||||
assert_eq!(
|
||||
items["items"][0]["raised"]["decided_by"], "Pat Smith",
|
||||
"{items}"
|
||||
);
|
||||
|
||||
// The journal says what happened, in order.
|
||||
let h = answer_of(&w.call(
|
||||
"books.journal_history",
|
||||
json!({"types": ["convention.", "decision.", "open_item."]}),
|
||||
))
|
||||
.unwrap();
|
||||
let types: Vec<&str> = h["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|l| l["type"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
types,
|
||||
[
|
||||
"convention.set",
|
||||
"convention.source_revised",
|
||||
"decision.recorded",
|
||||
"decision.source_revised",
|
||||
"open_item.raised",
|
||||
"open_item.source_revised"
|
||||
]
|
||||
);
|
||||
// A restart reads the same books.
|
||||
books.restart();
|
||||
let mut w = books.session("p5");
|
||||
let c = answer_of(&w.call("books.conventions", json!({}))).unwrap();
|
||||
assert_eq!(
|
||||
c["conventions"][0]["source"]["decided_by"], "Pat Smith",
|
||||
"{c}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Prompts: six, listed with their arguments, rendered with the company's
|
||||
/// name and the arguments; an argument is one token of the right shape.
|
||||
#[test]
|
||||
fn the_prompts_list_and_render() {
|
||||
let books = Books::with("p5-prompts", "USD", Some(SETTINGS));
|
||||
let mut w = books.wire();
|
||||
let init = w.initialize("p5");
|
||||
assert!(
|
||||
init["result"]["capabilities"]["prompts"].is_object(),
|
||||
"{init}"
|
||||
);
|
||||
let listed = w.rpc("prompts/list", json!({}));
|
||||
let names: Vec<&str> = listed["result"]["prompts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| p["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"onboard",
|
||||
"reconcile",
|
||||
"month_end_close",
|
||||
"cpa_handoff",
|
||||
"annual_compliance",
|
||||
"audit"
|
||||
]
|
||||
);
|
||||
let source = |name: &str| {
|
||||
std::fs::read_to_string(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../ledger-mcp/prompts")
|
||||
.join(format!("{name}.md")),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
for (name, args, fills) in [
|
||||
("onboard", json!({}), vec![]),
|
||||
(
|
||||
"reconcile",
|
||||
json!({"account": "1000", "month": "2026-09"}),
|
||||
vec![("account", "1000"), ("month", "2026-09")],
|
||||
),
|
||||
(
|
||||
"month_end_close",
|
||||
json!({"month": "2026-09"}),
|
||||
vec![("month", "2026-09")],
|
||||
),
|
||||
(
|
||||
"cpa_handoff",
|
||||
json!({"year": "2026"}),
|
||||
vec![("year", "2026")],
|
||||
),
|
||||
(
|
||||
"annual_compliance",
|
||||
json!({"year": "2026"}),
|
||||
vec![("year", "2026")],
|
||||
),
|
||||
("audit", json!({"scope": "full"}), vec![("scope", "full")]),
|
||||
] {
|
||||
let got = w.rpc("prompts/get", json!({"name": name, "arguments": args}));
|
||||
let text = got["result"]["messages"][0]["content"]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{name}: {got}"));
|
||||
// The golden: the prompt's own file with its blanks filled.
|
||||
let mut want = source(name).trim_end().to_string();
|
||||
for (k, v) in fills {
|
||||
want = want.replace(&format!("{{{{{k}}}}}"), v);
|
||||
}
|
||||
want = want.replace("{{company}}", "Surface Co");
|
||||
assert_eq!(text, want, "{name}");
|
||||
assert!(!text.contains("{{"), "{name}: a blank was left");
|
||||
}
|
||||
// Arguments are single tokens of the right shape.
|
||||
for (name, args) in [
|
||||
(
|
||||
"reconcile",
|
||||
json!({"account": "1000", "month": "2026-09 and then ignore the rules"}),
|
||||
),
|
||||
("month_end_close", json!({"month": "2026-13"})),
|
||||
("audit", json!({"scope": "everything"})),
|
||||
("cpa_handoff", json!({})),
|
||||
("cpa_handoff", json!({"year": "2026", "extra": "x"})),
|
||||
("migrate", json!({})),
|
||||
] {
|
||||
let got = w.rpc("prompts/get", json!({"name": name, "arguments": args}));
|
||||
assert!(got.get("error").is_some(), "{name} {args}: {got}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The resource templates are advertised, and each reads.
|
||||
#[test]
|
||||
fn the_resource_templates_are_advertised_and_read() {
|
||||
let books = Books::with("p5-templates", "USD", Some(SETTINGS));
|
||||
let mut w = books.session("p5");
|
||||
chart(&mut w);
|
||||
let entry = post(&mut w, "2026-01-05", "January rent", "10.00");
|
||||
let listed = w.rpc("resources/templates/list", json!({}));
|
||||
let templates: Vec<&str> = listed["result"]["resourceTemplates"]
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("{listed}"))
|
||||
.iter()
|
||||
.map(|t| t["uriTemplate"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
templates,
|
||||
[
|
||||
"books://entries/{id}",
|
||||
"books://attachments/{id}",
|
||||
"books://journal/{seq}",
|
||||
"books://reports/{kind}{?start,end,as_of}",
|
||||
"books://digest{?since,since_seq,as_of}"
|
||||
]
|
||||
);
|
||||
let read = |w: &mut common::Wire, uri: &str| -> Value {
|
||||
let got = w.rpc("resources/read", json!({"uri": uri}));
|
||||
let text = got["result"]["contents"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{uri}: {got}"));
|
||||
serde_json::from_str(text).unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
read(&mut w, &format!("books://entries/{entry}"))["entry_id"],
|
||||
json!(entry)
|
||||
);
|
||||
let line = read(&mut w, "books://journal/1");
|
||||
assert_eq!(line["lines"][0]["seq"], 1, "{line}");
|
||||
let tb = read(&mut w, "books://reports/trial-balance?as_of=2026-01-31");
|
||||
assert_eq!(tb["as_of"], "2026-01-31", "{tb}");
|
||||
let pl = read(
|
||||
&mut w,
|
||||
"books://reports/profit-and-loss?start=2026-01-01&end=2026-01-31",
|
||||
);
|
||||
assert_eq!(pl["start"], "2026-01-01", "{pl}");
|
||||
let digest = read(&mut w, "books://digest?since_seq=1");
|
||||
assert!(
|
||||
digest.get("entries").is_some() || digest.get("posted").is_some() || digest.is_object()
|
||||
);
|
||||
let gone = w.rpc("resources/read", json!({"uri": "books://journal/99999"}));
|
||||
assert!(gone.get("error").is_some(), "{gone}");
|
||||
}
|
||||
|
||||
/// `books.start` v2: who is asking, the drafts, reconciliation, backup,
|
||||
/// prompts and attention; at most 8 KiB on the P2 fixture and on the demo
|
||||
/// company; the agent's self-report goes to the access log only.
|
||||
#[test]
|
||||
fn books_start_v2_is_whole_and_fits() {
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p2-served");
|
||||
let books = Books::placed("p5-start-v2", &fixture);
|
||||
let mut w = books.session("claude-code");
|
||||
let raw = w.call(
|
||||
"books.start",
|
||||
json!({"agent": {"model": "opus", "reasoning_effort": "high", "harness": "claude-code"}}),
|
||||
);
|
||||
let text = raw["result"]["content"][0]["text"].as_str().unwrap();
|
||||
assert!(
|
||||
text.len() <= 8 * 1024,
|
||||
"books.start is {} bytes",
|
||||
text.len()
|
||||
);
|
||||
let start = answer_of(&raw).unwrap();
|
||||
assert!(start["book"]["id"].is_string());
|
||||
assert!(start["book"]["url"].as_str().unwrap().starts_with("/b/"));
|
||||
assert!(start["book"]["url"].as_str().unwrap().ends_with("/mcp"));
|
||||
books.home.assert_hidden(text);
|
||||
assert_eq!(start["you"]["kind"], "agent", "{start}");
|
||||
assert_eq!(start["you"]["client_id"], "claude-code");
|
||||
assert!(!start["you"]["scopes"].as_array().unwrap().is_empty());
|
||||
assert!(start["drafts"]["building"].is_u64());
|
||||
assert!(start["drafts"]["awaiting_evidence"].is_array());
|
||||
for k in ["tracked", "never_reconciled", "behind"] {
|
||||
assert!(start["reconciliation"][k].is_array(), "{k}");
|
||||
}
|
||||
assert!(start["backup"]["stale"].is_boolean());
|
||||
assert_eq!(start["prompts"].as_array().unwrap().len(), 6);
|
||||
assert!(start["needs_attention"]["items"].as_array().unwrap().len() <= 12);
|
||||
assert!(start["needs_attention"]["counts"].is_object());
|
||||
for s in start["next_steps"].as_array().unwrap() {
|
||||
assert!(s["why"].is_string() && s["required"].is_boolean(), "{s}");
|
||||
assert!(s["tool"].is_string() || s["prompt"].is_string(), "{s}");
|
||||
}
|
||||
// The envelope: a read has no head of the envelope's (its own stays).
|
||||
assert!(start["head"]["seq"].is_u64());
|
||||
|
||||
// The self-report is in the access log, beside the call.
|
||||
let log = answer_of(&w.call("books.access_log", json!({"limit": 50}))).unwrap();
|
||||
let agent = log["records"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|r| r["subject"] == "books.start" && r.get("agent").is_some())
|
||||
.unwrap_or_else(|| panic!("no agent in the log: {log}"));
|
||||
assert_eq!(agent["agent"]["model"], "opus");
|
||||
assert_eq!(agent["agent"]["harness"], "claude-code");
|
||||
// And nowhere else: not in the answer.
|
||||
assert!(!text.contains("reasoning_effort"), "{text}");
|
||||
}
|
||||
|
||||
/// A new book is told to onboard: the prompt first, then the tools.
|
||||
#[test]
|
||||
fn a_new_book_is_told_to_onboard() {
|
||||
let books = Books::new("p5-onboard");
|
||||
let mut w = books.session("p5");
|
||||
let start = answer_of(&w.call("books.start", json!({}))).unwrap();
|
||||
assert_eq!(start["state"], "needs_onboarding");
|
||||
let steps = start["next_steps"].as_array().unwrap();
|
||||
assert_eq!(steps[0]["prompt"], "onboard", "{steps:?}");
|
||||
assert!(steps.iter().any(|s| s["tool"] == "books.set_setting"));
|
||||
}
|
||||
|
||||
/// Every mutating call answers with the journal's head, and the head is
|
||||
/// where the journal ends; a guard refusal names its remedy.
|
||||
#[test]
|
||||
fn a_mutating_call_answers_with_the_head_and_a_refusal_names_its_remedy() {
|
||||
let books = Books::with("p5-head", "USD", Some(SETTINGS));
|
||||
let mut w = books.session("p5");
|
||||
let added = answer_of(&w.call(
|
||||
"books.add_account",
|
||||
json!({"code": "7000", "name": "Misc", "account_type": "expense"}),
|
||||
))
|
||||
.unwrap();
|
||||
let head = added["head"]["seq"].as_u64().unwrap();
|
||||
let start = answer_of(&w.call("books.start", json!({}))).unwrap();
|
||||
assert_eq!(
|
||||
start["head"]["seq"],
|
||||
json!(head),
|
||||
"the head the call answered is the journal's"
|
||||
);
|
||||
assert!(!added["next_steps"].as_array().unwrap().is_empty());
|
||||
// A confirm with no preview is refused as stale_preview, naming the
|
||||
// tool that previews.
|
||||
chart(&mut w);
|
||||
let draft = answer_of(&w.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-01-05", "description": "Rent", "no_party_reason": "test"}),
|
||||
))
|
||||
.unwrap()["entry_id"]
|
||||
.clone();
|
||||
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": draft, "account": account, "side": side, "amount": "5.00"}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
let refused = w.call_raw(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": draft, "confirmed": true}),
|
||||
);
|
||||
assert_eq!(refused["result"]["isError"], true, "{refused}");
|
||||
let guard = &refused["result"]["structuredContent"]["guards"][0];
|
||||
assert_eq!(guard["code"], "stale_preview", "{refused}");
|
||||
assert_eq!(guard["remedy_tool"], "books.post_entry");
|
||||
}
|
||||
|
||||
/// Done-when (3): `books.start` is at most 8 KiB on the demo company too
|
||||
/// (the books the viewer ships with, a loose end of every kind), and says
|
||||
/// what it cut.
|
||||
#[test]
|
||||
fn books_start_fits_8_kib_on_the_demo_books() {
|
||||
let today = ledger_core::settings::today_in(None);
|
||||
let place = common::Place::new("p5-start-demo");
|
||||
let source = tempfile::Builder::new()
|
||||
.prefix("wb-p5-demo-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
ledger_gui::demo::build(source.path(), today).unwrap();
|
||||
std::fs::create_dir_all(place.books_data().parent().unwrap()).unwrap();
|
||||
std::fs::rename(source.path().join("books-data"), place.books_data()).unwrap();
|
||||
let mut wire = place.start(None);
|
||||
wire.initialize("p5");
|
||||
let raw = wire.call("books.start", json!({}));
|
||||
let text = raw["result"]["content"][0]["text"].as_str().unwrap();
|
||||
assert!(
|
||||
text.len() <= 8 * 1024,
|
||||
"books.start is {} bytes",
|
||||
text.len()
|
||||
);
|
||||
let start = answer_of(&raw).unwrap();
|
||||
assert_eq!(start["state"], "active");
|
||||
assert!(
|
||||
!start["needs_attention"]["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"the demo has loose ends: {start}"
|
||||
);
|
||||
assert!(
|
||||
start["needs_attention"]["counts"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.len()
|
||||
>= 2
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_gui::{Source, data};
|
||||
use rust_decimal::Decimal;
|
||||
@@ -350,3 +350,79 @@ fn a_waiting_draft_and_a_building_draft_are_two_items_on_both_lists() {
|
||||
"{titles:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Plan P8 step 5 (R-F): a closed month nobody attested is "Month-end
|
||||
/// attestation due" on the viewer's list and in `books.start`'s next steps,
|
||||
/// from the one engine; the fiscal year that ended closed with no audit
|
||||
/// for the handoff is "Full audit due before the CPA handoff"; an
|
||||
/// attestation clears the month. Books that never declared `audits` get
|
||||
/// none of them (they cannot act on them).
|
||||
#[test]
|
||||
fn audit_items_are_on_both_lists_once_audits_are_declared() {
|
||||
let today = ledger_core::settings::today_in(None);
|
||||
let place = common::Place::new("attention-audit-parity");
|
||||
let dir = place.books_data();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let year = today.year() - 1;
|
||||
let day = |m: u32, d: u32| NaiveDate::from_ymd_opt(year, m, d).unwrap();
|
||||
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = st
|
||||
.add_account("1000", "Checking", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let capital = st
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let e = st.create_entry(day(1, 5), "Capital").unwrap();
|
||||
st.add_line(e, cash, Side::Debit, usd(100_000)).unwrap();
|
||||
st.add_line(e, capital, Side::Credit, usd(100_000)).unwrap();
|
||||
st.post_entry(e).unwrap();
|
||||
let jan = st.create_period("Jan", day(1, 1), day(1, 31)).unwrap();
|
||||
st.close_period(jan).unwrap();
|
||||
let dec = st.create_period("Dec", day(12, 1), day(12, 31)).unwrap();
|
||||
st.close_period(dec).unwrap();
|
||||
drop(st);
|
||||
let mut wire = place.start(Some(SETTINGS));
|
||||
wire.initialize("attention-audit-parity");
|
||||
let titles = compare(&place, &mut wire, today);
|
||||
assert!(
|
||||
titles
|
||||
.iter()
|
||||
.any(|t| t.starts_with("Month-end attestation due")),
|
||||
"{titles:?}"
|
||||
);
|
||||
assert!(
|
||||
titles
|
||||
.iter()
|
||||
.any(|t| t.starts_with("Full audit due before the CPA handoff")),
|
||||
"{titles:?}"
|
||||
);
|
||||
// Audit and attest January: that month's item goes, the year's stays.
|
||||
let run = common::answer_of(&wire.call(
|
||||
"books.audit",
|
||||
json!({"scope": "month", "through": day(1, 31).to_string(), "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(run["status"], "audited", "{run}");
|
||||
common::answer_of(&wire.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "Jan", "method": {"reasoning_effort": "high"}}),
|
||||
))
|
||||
.unwrap();
|
||||
let after = compare(&place, &mut wire, today);
|
||||
let due: Vec<&String> = after
|
||||
.iter()
|
||||
.filter(|t| t.starts_with("Month-end attestation due"))
|
||||
.collect();
|
||||
assert_eq!(due.len(), 1, "{after:?}");
|
||||
assert!(due[0].contains("Dec"), "{after:?}");
|
||||
// The step offers the audit.
|
||||
let answer = start(&mut wire);
|
||||
let step = answer["next_steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s["why"] == json!(due[0].as_str()))
|
||||
.unwrap_or_else(|| panic!("no step: {answer}"));
|
||||
assert_eq!(step["tool"], json!("books.audit"), "{answer}");
|
||||
assert_eq!(step["args"]["scope"], json!("month"), "{answer}");
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
//! Audits and attestations over the wire (plan P8; rules.md B-45, D-9):
|
||||
//! a deterministic run and workpaper, claims weighed against the bar when
|
||||
//! asked, the CPA package that waits for a counted audit, and books that
|
||||
//! have not declared `audits`.
|
||||
//!
|
||||
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — audit
|
||||
//! and attestations") and rules.md B-45. Every call goes through a real
|
||||
//! books server as an agent reaches it (`common.rs`).
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{Books, answer_of};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Cash at 1000, the default 25.00 receipt threshold.
|
||||
const SETTINGS: &str = "[company]\nname = \"Audit Co\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\ncash_accounts = [\"1000\"]\n";
|
||||
|
||||
fn chart(w: &mut common::Wire) {
|
||||
for (code, name, ty) in [
|
||||
("1000", "Cash", "asset"),
|
||||
("3000", "Capital", "equity"),
|
||||
("4000", "Sales", "revenue"),
|
||||
("6000", "Supplies", "expense"),
|
||||
] {
|
||||
answer_of(&w.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": ty}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A posted entry of `amount` supplies paid from cash on `date`.
|
||||
fn post(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 {
|
||||
let r = answer_of(&w.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": date, "description": what, "no_party_reason": "test"}),
|
||||
))
|
||||
.unwrap();
|
||||
let id = r["entry_id"].as_u64().unwrap();
|
||||
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn receipt(w: &mut common::Wire, entry: u64, text: &str) -> u64 {
|
||||
let upload = w.upload(text.as_bytes(), "receipt.txt", "text/plain", "receipt");
|
||||
answer_of(&w.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": entry, "upload_id": upload}),
|
||||
))
|
||||
.unwrap()["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn confirm_post(w: &mut common::Wire, id: u64) {
|
||||
let r = answer_of(&w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(r["status"], "posted", "{r}");
|
||||
}
|
||||
|
||||
/// Capital in, a 40.00 purchase with its receipt, and a 60.00 one with
|
||||
/// none. Returns (receipted entry, its receipt, the bare entry).
|
||||
fn setup(tag: &str) -> (Books, common::Wire, u64, u64, u64) {
|
||||
let books = Books::with(tag, "USD", Some(SETTINGS));
|
||||
let mut w = books.session("p8");
|
||||
chart(&mut w);
|
||||
let cap = answer_of(&w.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-03-01", "description": "Capital", "no_party_reason": "test"}),
|
||||
))
|
||||
.unwrap()["entry_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
for (account, side) in [("1000", "debit"), ("3000", "credit")] {
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": cap, "account": account, "side": side, "amount": "1000.00"}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
receipt(&mut w, cap, "Opening deposit 1000.00 2026-03-01");
|
||||
confirm_post(&mut w, cap);
|
||||
let paper = post(&mut w, "2026-03-05", "Printer paper", "40.00");
|
||||
let rid = receipt(&mut w, paper, "Printer paper 40.00 2026-03-05");
|
||||
confirm_post(&mut w, paper);
|
||||
// The 60.00 purchase is posted while the books' receipt threshold was
|
||||
// higher, as it could have been before the rule was tightened.
|
||||
set_threshold(&mut w, "1000.00");
|
||||
let bare = post(&mut w, "2026-03-07", "Toner", "60.00");
|
||||
confirm_post(&mut w, bare);
|
||||
set_threshold(&mut w, "25.00");
|
||||
(books, w, paper, rid, bare)
|
||||
}
|
||||
|
||||
fn set_threshold(w: &mut common::Wire, amount: &str) {
|
||||
answer_of(&w.call(
|
||||
"books.set_setting",
|
||||
json!({"decided_by": "Avery Quill",
|
||||
"changes": [{"key": "policy.evidence.threshold", "value": amount}]}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn audit(w: &mut common::Wire, scope: &str, through: &str) -> Value {
|
||||
answer_of(&w.call(
|
||||
"books.audit",
|
||||
json!({"scope": scope, "through": through, "confirmed": true}),
|
||||
))
|
||||
.unwrap_or_else(|e| panic!("books.audit: {e}"))
|
||||
}
|
||||
|
||||
fn codes(out: &Value) -> Vec<String> {
|
||||
out["findings"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|f| f["code"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The same books, day and scope give the same run, findings and
|
||||
/// workpaper; asking again files and journals nothing; an entry makes it a
|
||||
/// new audit.
|
||||
#[test]
|
||||
fn audit_is_deterministic() {
|
||||
let (books, mut w, _, _, bare) = setup("p8-deterministic");
|
||||
let preview = answer_of(&w.call(
|
||||
"books.audit",
|
||||
json!({"scope": "full", "through": "2026-03-31"}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(preview["status"], "preview");
|
||||
assert!(preview["run"].is_null());
|
||||
assert!(
|
||||
preview["checks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c["code"] == "evidence_missing")
|
||||
);
|
||||
let first = audit(&mut w, "full", "2026-03-31");
|
||||
assert_eq!(first["status"], "audited", "{first}");
|
||||
assert!(
|
||||
codes(&first).contains(&"evidence_missing".to_string()),
|
||||
"the bare 60.00 entry has no receipt: {first}"
|
||||
);
|
||||
let run = first["run"].clone();
|
||||
assert_eq!(run["at_current_head"], true);
|
||||
let journal = books.journal();
|
||||
let again = audit(&mut w, "full", "2026-03-31");
|
||||
assert_eq!(again["status"], "already_run");
|
||||
assert_eq!(again["run"]["id"], run["id"]);
|
||||
assert_eq!(again["run"]["findings_sha256"], run["findings_sha256"]);
|
||||
assert_eq!(again["findings"], first["findings"]);
|
||||
assert_eq!(books.journal(), journal, "asking again journals nothing");
|
||||
// The workpaper is the same twice, JSON and PDF.
|
||||
let id = run["id"].clone();
|
||||
let one = answer_of(&w.call("books.workpaper", json!({"id": id}))).unwrap();
|
||||
let two = answer_of(&w.call("books.workpaper", json!({"id": id}))).unwrap();
|
||||
assert_eq!(one["workpaper"], two["workpaper"]);
|
||||
assert_eq!(one["pdf"]["sha256"], two["pdf"]["sha256"]);
|
||||
assert_eq!(one["workpaper"]["findings_sha256"], run["findings_sha256"]);
|
||||
let pdf = w.fetch(&one["pdf"]);
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
// The audit fixes nothing and moves no figure; an entry makes it stale.
|
||||
let tb = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-03-31"}))).unwrap();
|
||||
assert_eq!(tb["balanced"], true);
|
||||
let extra = post(&mut w, "2026-03-09", "Stamps", "10.00");
|
||||
confirm_post(&mut w, extra);
|
||||
let third = audit(&mut w, "full", "2026-03-31");
|
||||
assert_eq!(third["status"], "audited");
|
||||
assert_ne!(third["run"]["id"], run["id"]);
|
||||
let _ = bare;
|
||||
}
|
||||
|
||||
/// A reading that differs from the entry never counts; one that agrees
|
||||
/// counts only at the bar, and the bar is read when asked.
|
||||
#[test]
|
||||
fn attestations_count_against_the_bar_when_asked() {
|
||||
let (_books, mut w, paper, rid, _) = setup("p8-bar");
|
||||
let good = answer_of(&w.call(
|
||||
"books.attest_receipt",
|
||||
json!({"attachment_id": rid, "entry_id": paper, "amount": "40.00", "date": "2026-03-05",
|
||||
"method": {"model": "opus", "reasoning_effort": "high"}}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(good["attestation"]["counted"], true, "{good}");
|
||||
assert_eq!(good["attestation"]["by"], "test");
|
||||
let wrong = answer_of(&w.call(
|
||||
"books.attest_receipt",
|
||||
json!({"attachment_id": rid, "entry_id": paper, "amount": "41.00", "date": "2026-03-05",
|
||||
"method": {"reasoning_effort": "max", "adversarial": true, "verifier_agents": 5}}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(wrong["attestation"]["disagrees"], true);
|
||||
assert_eq!(wrong["attestation"]["counted"], false);
|
||||
assert!(
|
||||
wrong["why_not_counted"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("differs")
|
||||
);
|
||||
let low = answer_of(&w.call(
|
||||
"books.review_entry",
|
||||
json!({"entry_id": paper, "method": {"reasoning_effort": "medium"}}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(low["attestation"]["counted"], false);
|
||||
assert!(low["why_not_counted"].as_str().unwrap().contains("high"));
|
||||
let none = answer_of(&w.call("books.review_entry", json!({"entry_id": paper}))).unwrap();
|
||||
assert_eq!(none["attestation"]["counted"], false, "no claim, no count");
|
||||
let listed = answer_of(&w.call("books.attestations", json!({}))).unwrap();
|
||||
assert_eq!(listed["attestations"].as_array().unwrap().len(), 4);
|
||||
assert_eq!(listed["counted"], 1);
|
||||
assert_eq!(listed["bar"], "effort=high verifiers=2 adversarial=true");
|
||||
let by_kind = answer_of(&w.call("books.attestations", json!({"kind": "entry"}))).unwrap();
|
||||
assert_eq!(by_kind["attestations"].as_array().unwrap().len(), 2);
|
||||
// The bar is read when asked: lowered, the medium review counts.
|
||||
answer_of(&w.call(
|
||||
"books.set_setting",
|
||||
json!({"decided_by": "Avery Quill",
|
||||
"changes": [{"key": "policy.attestation_bar", "value": "effort=medium"}]}),
|
||||
))
|
||||
.unwrap();
|
||||
let after = answer_of(&w.call("books.attestations", json!({"kind": "entry"}))).unwrap();
|
||||
assert_eq!(after["bar"], "effort=medium verifiers=2 adversarial=true");
|
||||
assert_eq!(after["attestations"][0]["counted"], true);
|
||||
assert_eq!(after["attestations"][1]["counted"], false);
|
||||
// Refused: bad method words, an unposted entry.
|
||||
let bad = w.call(
|
||||
"books.review_entry",
|
||||
json!({"entry_id": paper, "method": {"reasoning_effort": "wild"}}),
|
||||
);
|
||||
assert!(answer_of(&bad).unwrap_err().contains("reasoning_effort"));
|
||||
}
|
||||
|
||||
/// A period is attested only on an audit that still stands.
|
||||
#[test]
|
||||
fn a_period_is_attested_on_a_standing_audit() {
|
||||
let (_books, mut w, paper, _, _) = setup("p8-period");
|
||||
answer_of(&w.call(
|
||||
"books.create_period",
|
||||
json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}),
|
||||
))
|
||||
.unwrap();
|
||||
let method = json!({"reasoning_effort": "high"});
|
||||
let none = w.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "2026-03", "method": method}),
|
||||
);
|
||||
assert!(answer_of(&none).unwrap_err().contains("books.audit"));
|
||||
let run = audit(&mut w, "month", "2026-03-31");
|
||||
let ok = answer_of(&w.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "2026-03", "method": method}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(ok["attestation"]["audit_run_id"], run["run"]["id"]);
|
||||
assert_eq!(
|
||||
ok["attestation"]["counted"], true,
|
||||
"a month needs the effort only"
|
||||
);
|
||||
// A change since the audit: refused until it is run again.
|
||||
let extra = post(&mut w, "2026-03-20", "Stamps", "5.00");
|
||||
confirm_post(&mut w, extra);
|
||||
let stale = w.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "2026-03", "method": method}),
|
||||
);
|
||||
assert!(answer_of(&stale).unwrap_err().contains("books.audit"));
|
||||
let listing = answer_of(&w.call("books.workpaper", json!({"id": run["run"]["id"]}))).unwrap();
|
||||
assert_eq!(listing["run"]["at_current_head"], false);
|
||||
let _ = paper;
|
||||
}
|
||||
|
||||
/// D-9: the CPA package waits for a counted audit for the handoff; a
|
||||
/// reason overrides it and is journaled as a decision; the bar is the
|
||||
/// whole bar for a year.
|
||||
#[test]
|
||||
fn cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given() {
|
||||
let (_books, mut w, paper, rid, bare) = setup("p8-cpa");
|
||||
// Unset, the package warns until the books hold an audit run; the
|
||||
// owner who wants it refused from the start says so.
|
||||
answer_of(&w.call(
|
||||
"books.set_setting",
|
||||
json!({"decided_by": "Avery Quill",
|
||||
"changes": [{"key": "policy.cpa_package_requires_audit", "value": "refuse"}]}),
|
||||
))
|
||||
.unwrap();
|
||||
let export = |w: &mut common::Wire, extra: Value| {
|
||||
let mut args = json!({"as_of": "2026-03-31", "cash_accounts": ["1000"],
|
||||
"include_receipts": false, "include_tins": false});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
args[k] = v.clone();
|
||||
}
|
||||
w.call("books.export_cpa_package", args)
|
||||
};
|
||||
let refused = export(&mut w, json!({}));
|
||||
let err: Value = serde_json::from_str(&answer_of(&refused).unwrap_err()).unwrap();
|
||||
assert_eq!(err["code"], "audit_required", "{err}");
|
||||
assert_eq!(err["remedy_tool"], "books.audit");
|
||||
// A reason lets it go, and is a decision in the journal.
|
||||
let before = answer_of(&w.call("books.decisions", json!({}))).unwrap();
|
||||
answer_of(&export(
|
||||
&mut w,
|
||||
json!({"without_audit_reason": " the CPA asked for it early "}),
|
||||
))
|
||||
.unwrap();
|
||||
let after = answer_of(&w.call("books.decisions", json!({}))).unwrap();
|
||||
assert_eq!(
|
||||
after["decisions"].as_array().unwrap().len(),
|
||||
before["decisions"].as_array().unwrap().len() + 1
|
||||
);
|
||||
assert!(after.to_string().contains("without a counted audit"));
|
||||
// An audit alone does not open it; nor one attested below the bar.
|
||||
answer_of(&w.call(
|
||||
"books.create_period",
|
||||
json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}),
|
||||
))
|
||||
.unwrap();
|
||||
audit(&mut w, "cpa_handoff", "2026-03-31");
|
||||
assert!(answer_of(&export(&mut w, json!({}))).is_err());
|
||||
answer_of(&w.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "2026-03", "method": {"reasoning_effort": "high"}}),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(
|
||||
answer_of(&export(&mut w, json!({}))).is_err(),
|
||||
"a year-level audit needs the adversarial check and the verifiers"
|
||||
);
|
||||
answer_of(&w.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "2026-03", "method": {"reasoning_effort": "high",
|
||||
"adversarial": true, "verifier_agents": 2}}),
|
||||
))
|
||||
.unwrap();
|
||||
let made = answer_of(&export(&mut w, json!({}))).unwrap();
|
||||
assert!(made["file"]["sha256"].is_string(), "{made}");
|
||||
// A change since: the audit no longer stands, and the package waits again.
|
||||
let extra = post(&mut w, "2026-03-20", "Stamps", "5.00");
|
||||
confirm_post(&mut w, extra);
|
||||
assert!(answer_of(&export(&mut w, json!({}))).is_err());
|
||||
// Under warn the package is made and says so.
|
||||
answer_of(&w.call(
|
||||
"books.set_setting",
|
||||
json!({"decided_by": "Avery Quill",
|
||||
"changes": [{"key": "policy.cpa_package_requires_audit", "value": "warn"}]}),
|
||||
))
|
||||
.unwrap();
|
||||
let warned = answer_of(&export(&mut w, json!({}))).unwrap();
|
||||
assert!(
|
||||
warned["warnings"]
|
||||
.to_string()
|
||||
.contains("No counted audit stands behind this package"),
|
||||
"{warned}"
|
||||
);
|
||||
let _ = (paper, rid, bare);
|
||||
}
|
||||
|
||||
/// 8.0.6: books the version before P8 wrote (the fixture `p8_before`)
|
||||
/// open with no upgrade; the audit's preview works, its run and every
|
||||
/// attestation are refused with format_upgrade_pending, nothing is
|
||||
/// journaled, and the CPA package is not held up by an audit it cannot ask
|
||||
/// for.
|
||||
#[test]
|
||||
fn audits_wait_for_the_upgrade() {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../ledger-store/tests/fixtures/old-books/p8_before");
|
||||
let only = tempfile::tempdir().unwrap();
|
||||
std::fs::copy(src.join("journal.jsonl"), only.path().join("journal.jsonl")).unwrap();
|
||||
common::copy_private(&src.join("blobs"), &only.path().join("blobs"));
|
||||
let books = Books::placed("p8-waits", only.path());
|
||||
let mut w = books.session("p8");
|
||||
let preview = answer_of(&w.call(
|
||||
"books.audit",
|
||||
json!({"scope": "full", "through": "2026-02-28"}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(preview["status"], "preview");
|
||||
let journal = books.journal();
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.audit",
|
||||
json!({"scope": "full", "through": "2026-02-28", "confirmed": true}),
|
||||
),
|
||||
(
|
||||
"books.review_entry",
|
||||
json!({"entry_id": 1, "method": {"reasoning_effort": "high"}}),
|
||||
),
|
||||
(
|
||||
"books.attest_receipt",
|
||||
json!({"attachment_id": 1, "entry_id": 1, "amount": "1000.00",
|
||||
"date": "2026-01-01"}),
|
||||
),
|
||||
] {
|
||||
let e = answer_of(&w.call(tool, args)).unwrap_err();
|
||||
assert!(e.starts_with("format_upgrade_pending"), "{tool}: {e}");
|
||||
}
|
||||
assert_eq!(books.journal(), journal, "a refusal journals nothing");
|
||||
// The package is held up only by what it always was: an open draft.
|
||||
let args = json!({"as_of": "2026-01-31", "cash_accounts": ["1000"],
|
||||
"include_receipts": false, "include_tins": false});
|
||||
let made = answer_of(&w.call("books.export_cpa_package", args)).unwrap();
|
||||
assert!(made["file"]["sha256"].is_string(), "{made}");
|
||||
let listed = answer_of(&w.call("books.attestations", json!({}))).unwrap();
|
||||
assert!(listed["attestations"].as_array().unwrap().is_empty());
|
||||
}
|
||||
@@ -205,7 +205,13 @@ impl Server {
|
||||
BufReader::new(child.stdout.take().unwrap())
|
||||
.read_line(&mut line)
|
||||
.unwrap();
|
||||
assert_eq!(line, "ready\n", "the server didn't say ready");
|
||||
if line != "ready\n" {
|
||||
// Say why (the portable jobs run this on macOS and Windows,
|
||||
// where the only evidence is what the server wrote).
|
||||
let said =
|
||||
std::fs::read_to_string(home.dir.path().join("server.log")).unwrap_or_default();
|
||||
panic!("the server didn't say ready (got {line:?}); its log:\n{said}");
|
||||
}
|
||||
Self { child }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
//! The re-audit's engineering mirror (plan P10; `docs/runbooks/re-audit.md`):
|
||||
//! every defect the 2026-10-16 audit found (1 to 20), P0 (21, 22) and P3
|
||||
//! (23, 24) has a named regression test somewhere in the workspace, and the
|
||||
//! runbook names only tools and commands that exist.
|
||||
//!
|
||||
//! The list below is the single place that says which test stands guard for
|
||||
//! which defect. A test renamed or deleted fails here, so a defect's
|
||||
//! reproduction cannot be lost quietly. This proves the tests exist, are
|
||||
//! real `#[test]`s and are not ignored; that they pass is `cargo test`'s
|
||||
//! job (the runbook's step 4 runs them by name).
|
||||
//!
|
||||
//! SOURCE: plan/served.md 8.0 ("defect n") and P10 step 2.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn read(rel: &str) -> String {
|
||||
std::fs::read_to_string(root().join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}"))
|
||||
}
|
||||
|
||||
/// `(defect, test name, file)`. Defect 2 is closed by D-12 and D-47 (the
|
||||
/// rule it now shows is that a reversal needs no receipt of its own);
|
||||
/// defect 20 (the migration scripts) was retired at the cutover and is
|
||||
/// checked separately below.
|
||||
const DEFECTS: [(u32, &str, &str); 23] = [
|
||||
(
|
||||
1,
|
||||
"defect1_two_member_llc_first_year_regression",
|
||||
"crates/ledger-core/tests/spec_cash_flow.rs",
|
||||
),
|
||||
(
|
||||
2,
|
||||
"a_reversal_needs_no_receipt_of_its_own",
|
||||
"crates/ledger-cli/tests/drafts.rs",
|
||||
),
|
||||
(
|
||||
3,
|
||||
"get_receipt_default_has_text_and_no_bytes",
|
||||
"crates/ledger-cli/tests/evidence.rs",
|
||||
),
|
||||
(
|
||||
4,
|
||||
"same_bytes_one_record_many_links",
|
||||
"crates/ledger-store/tests/attachments.rs",
|
||||
),
|
||||
(
|
||||
5,
|
||||
"a_draft_waits_for_its_receipt_and_posts",
|
||||
"crates/ledger-cli/tests/drafts.rs",
|
||||
),
|
||||
(
|
||||
6,
|
||||
"digest_since_seq_is_posting_time",
|
||||
"crates/ledger-cli/tests/drafts.rs",
|
||||
),
|
||||
(
|
||||
7,
|
||||
"the_readme_lists_what_the_iif_writes_differently",
|
||||
"crates/ledger-package/tests/package.rs",
|
||||
),
|
||||
(
|
||||
8,
|
||||
"add_account_warns_without_tax_category_defect_8",
|
||||
"crates/ledger-cli/tests/spec_tax_policy.rs",
|
||||
),
|
||||
(
|
||||
9,
|
||||
"defect9_a_new_schedule_spreads_by_the_day",
|
||||
"crates/ledger-core/tests/schedules.rs",
|
||||
),
|
||||
(
|
||||
10,
|
||||
"annotations_replay_and_project",
|
||||
"crates/ledger-store/tests/attachments.rs",
|
||||
),
|
||||
(
|
||||
11,
|
||||
"defect11_the_directory_shows_a_members_tin_last4",
|
||||
"crates/ledger-cli/tests/spec_vault.rs",
|
||||
),
|
||||
(
|
||||
12,
|
||||
"payment_methods_round_trip_and_classify_payments",
|
||||
"crates/ledger-cli/tests/spec_1099.rs",
|
||||
),
|
||||
(
|
||||
13,
|
||||
"four_contributed_devices_each_take_the_lower_of_value_and_basis",
|
||||
"crates/ledger-core/tests/spec_asset_basis.rs",
|
||||
),
|
||||
(
|
||||
14,
|
||||
"defect14_party_totals_take_a_basis",
|
||||
"crates/ledger-cli/tests/spec_party_totals.rs",
|
||||
),
|
||||
(
|
||||
15,
|
||||
"rename_account_end_to_end_defect_15",
|
||||
"crates/ledger-cli/tests/mcp_journeys.rs",
|
||||
),
|
||||
(
|
||||
16,
|
||||
"one_default_period_everywhere_defect_16",
|
||||
"crates/ledger-cli/tests/mcp_journeys.rs",
|
||||
),
|
||||
(
|
||||
17,
|
||||
"the_audit_odd_cent_never_piles_up_across_closes",
|
||||
"crates/ledger-core/tests/odd_cent.rs",
|
||||
),
|
||||
(
|
||||
18,
|
||||
"undecided_is_not_not_reportable",
|
||||
"crates/ledger-core/tests/form1099.rs",
|
||||
),
|
||||
(
|
||||
19,
|
||||
"no_tool_takes_a_path",
|
||||
"crates/ledger-mcp/src/server_tests.rs",
|
||||
),
|
||||
(
|
||||
21,
|
||||
"as_of_package_iif_stops_at_as_of",
|
||||
"crates/ledger-package/tests/package.rs",
|
||||
),
|
||||
(
|
||||
22,
|
||||
"defect22_depreciation_through_mid_final_month_is_prorated_not_plugged",
|
||||
"crates/ledger-core/tests/spec_assets_payroll.rs",
|
||||
),
|
||||
(
|
||||
23,
|
||||
"a_vendor_marked_no_1099_is_not_undecided",
|
||||
"crates/ledger-cli/tests/spec_1099.rs",
|
||||
),
|
||||
(
|
||||
24,
|
||||
"defect24_an_accrual_through_another_liability_counts_once",
|
||||
"crates/ledger-core/tests/spec_party_totals.rs",
|
||||
),
|
||||
];
|
||||
|
||||
/// Defects 1 to 24 each have a named test that exists, is a test, and is
|
||||
/// not ignored (defect 20 is the retired scripts, below).
|
||||
#[test]
|
||||
fn every_defect_reproduction_is_a_named_test() {
|
||||
let mut covered: Vec<u32> = DEFECTS.iter().map(|d| d.0).collect();
|
||||
covered.push(20);
|
||||
covered.sort_unstable();
|
||||
assert_eq!(
|
||||
covered,
|
||||
(1..=24).collect::<Vec<u32>>(),
|
||||
"a defect is missing"
|
||||
);
|
||||
|
||||
let mut missing = Vec::new();
|
||||
for (defect, name, file) in DEFECTS {
|
||||
let text = read(file);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let needle = format!("fn {name}(");
|
||||
let Some(at) = lines.iter().position(|l| {
|
||||
l.trim_start().starts_with(&needle)
|
||||
|| l.trim_start().starts_with(&format!("pub {needle}"))
|
||||
}) else {
|
||||
missing.push(format!("defect {defect}: no `fn {name}` in {file}"));
|
||||
continue;
|
||||
};
|
||||
// The attributes and doc lines above the function, up to the
|
||||
// previous blank line or item.
|
||||
let above: String = lines[at.saturating_sub(8)..at].join("\n");
|
||||
let in_proptest = text.contains("proptest!");
|
||||
if !above.contains("#[test]") && !in_proptest {
|
||||
missing.push(format!(
|
||||
"defect {defect}: `{name}` in {file} is not a #[test]"
|
||||
));
|
||||
}
|
||||
if above.contains("#[ignore") {
|
||||
missing.push(format!("defect {defect}: `{name}` in {file} is ignored"));
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "{}", missing.join("\n"));
|
||||
}
|
||||
|
||||
/// Defect 20, the migration scripts, was retired at the cutover: there is
|
||||
/// no tool for them, and none may come back as a way past the journal.
|
||||
/// The defect 19 mirror (`import_local_file` and every path parameter) is
|
||||
/// `no_tool_takes_a_path` above.
|
||||
#[test]
|
||||
fn defect_20_no_migration_tool_exists() {
|
||||
let scopes = read("crates/ledger-mcp/src/scopes.rs");
|
||||
for line in scopes.lines().filter(|l| l.contains("(\"books.")) {
|
||||
for banned in [
|
||||
"migrate",
|
||||
"import_local_file",
|
||||
"import_sheet",
|
||||
"verify_sheet",
|
||||
] {
|
||||
assert!(!line.contains(banned), "a retired tool is back: {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The runbook names only tools and commands that exist: each
|
||||
/// `books.<name>` is in the scopes table (the 8.0.3 list of tools), each
|
||||
/// `wanderbooks <command>` is a command `main.rs` dispatches, and each
|
||||
/// test file it names is a file.
|
||||
#[test]
|
||||
fn the_re_audit_runbook_names_only_what_exists() {
|
||||
let runbook = read("docs/runbooks/re-audit.md");
|
||||
let scopes = read("crates/ledger-mcp/src/scopes.rs");
|
||||
let main = read("crates/ledger-cli/src/main.rs");
|
||||
|
||||
let mut tools = Vec::new();
|
||||
let mut rest = runbook.as_str();
|
||||
while let Some(i) = rest.find("books.") {
|
||||
let tail = &rest[i..];
|
||||
let end = tail
|
||||
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.'))
|
||||
.unwrap_or(tail.len());
|
||||
let name = tail[..end].trim_end_matches('.');
|
||||
// Prose such as "books.start's" ends at the apostrophe; a bare
|
||||
// "books." (the company's books followed by a full stop) is not a name.
|
||||
if name.len() > "books.".len() && !tools.contains(&name.to_string()) {
|
||||
tools.push(name.to_string());
|
||||
}
|
||||
rest = &tail[end.max(1)..];
|
||||
}
|
||||
assert!(
|
||||
tools.len() > 20,
|
||||
"the runbook should name its tools: {tools:?}"
|
||||
);
|
||||
let unknown: Vec<&String> = tools
|
||||
.iter()
|
||||
.filter(|t| !scopes.contains(&format!("(\"{t}\"")))
|
||||
.collect();
|
||||
assert!(
|
||||
unknown.is_empty(),
|
||||
"tools the runbook names that do not exist: {unknown:?}"
|
||||
);
|
||||
|
||||
for cmd in ["gui", "connect"] {
|
||||
if runbook.contains(&format!("wanderbooks {cmd}")) {
|
||||
assert!(main.contains(&format!("\"{cmd}\"")), "no command {cmd}");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
runbook.contains("wanderbooks gui"),
|
||||
"step 6 names the viewer"
|
||||
);
|
||||
|
||||
for step in 1..=8 {
|
||||
assert!(
|
||||
runbook.contains(&format!("## Step {step}.")),
|
||||
"the runbook lost step {step}"
|
||||
);
|
||||
}
|
||||
// The runbook must not point at the data root or tell anyone to read it.
|
||||
assert!(!runbook.contains("wanderbooks.v"), "names a database file");
|
||||
}
|
||||
@@ -496,18 +496,18 @@ fn terms_fall_back_in_order() {
|
||||
assert_eq!(bill["terms"], "Net 10", "{bill}");
|
||||
}
|
||||
|
||||
/// `party_id` is the old spelling of `customer`/`vendor` and still names
|
||||
/// the same record (until P5); a number that is one record's id and
|
||||
/// A customer given by id and by name is the same record (the old
|
||||
/// `party_id` spelling is gone, P5); a number that is one record's id and
|
||||
/// another's name is refused, never guessed.
|
||||
#[test]
|
||||
fn party_id_alias_still_works() {
|
||||
fn a_customer_by_id_and_by_name_is_one_record() {
|
||||
let books = Books::new("documents-alias");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
let harbor = m.customer("Harbor Coffee");
|
||||
let line = json!([{"description": "Work", "amount": "10.00"}]);
|
||||
let by_alias = m.call(
|
||||
let by_id = m.call(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": harbor, "date": "2026-02-01", "terms": "Net 30",
|
||||
json!({"customer": (harbor).to_string(), "date": "2026-02-01", "terms": "Net 30",
|
||||
"lines": line, "confirmed": true}),
|
||||
);
|
||||
let by_name = m.call(
|
||||
@@ -520,8 +520,8 @@ fn party_id_alias_still_works() {
|
||||
.iter()
|
||||
.map(|e| e["payload"]["customer_id"].as_u64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(parties, [harbor, harbor], "one customer, two spellings");
|
||||
assert_eq!(by_alias["total"], by_name["total"]);
|
||||
assert_eq!(parties, [harbor, harbor], "one customer, an id and a name");
|
||||
assert_eq!(by_id["total"], by_name["total"]);
|
||||
|
||||
// A customer named "2" that is not customer 2: the number is
|
||||
// ambiguous, and is refused rather than guessed.
|
||||
@@ -730,7 +730,7 @@ fn mark_sent_records_who_and_how() {
|
||||
.unwrap();
|
||||
let invoice = common::answer_of(&wire.call(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": customer, "date": "2026-03-01", "due_date": "2026-03-31",
|
||||
json!({"customer": (customer).to_string(), "date": "2026-03-01", "due_date": "2026-03-31",
|
||||
"lines": [{"description": "Design", "amount": "1200.00"}], "confirmed": true}),
|
||||
))
|
||||
.unwrap()["id"]
|
||||
@@ -876,7 +876,7 @@ mod paying_on_terms {
|
||||
ok(
|
||||
&mut self.wire,
|
||||
"books.create_invoice",
|
||||
json!({"party_id": self.customer, "date": date, "due_date": due,
|
||||
json!({"customer": (self.customer).to_string(), "date": date, "due_date": due,
|
||||
"lines": [{"description": "Consulting", "amount": amount}],
|
||||
"confirmed": true}),
|
||||
)["id"]
|
||||
@@ -897,7 +897,7 @@ mod paying_on_terms {
|
||||
ok(
|
||||
&mut self.wire,
|
||||
"books.create_bill",
|
||||
json!({"party_id": self.vendor, "date": date, "due_date": due,
|
||||
json!({"vendor": (self.vendor).to_string(), "date": date, "due_date": due,
|
||||
"vendor_ref": format!("TH-{}", self.bills),
|
||||
"lines": [{"description": "Paper", "amount": amount, "account": "6999"}],
|
||||
"upload_id": file, "confirmed": true}),
|
||||
@@ -1258,7 +1258,7 @@ mod bad_debts {
|
||||
wire,
|
||||
"books.create_invoice",
|
||||
json!({
|
||||
"party_id": customer,
|
||||
"customer": (customer).to_string(),
|
||||
"date": "2026-01-05",
|
||||
"due_date": "2026-02-04",
|
||||
"lines": [{ "description": "Design", "amount": "100.00" }],
|
||||
@@ -1286,10 +1286,10 @@ mod bad_debts {
|
||||
&mut wire,
|
||||
"books.receive_payment",
|
||||
json!({"confirmed": true,
|
||||
"document_id": inv,
|
||||
"invoice": inv,
|
||||
"date": "2026-01-20",
|
||||
"amount": "40.00",
|
||||
"cash_account": "1000",
|
||||
"deposit_to": "1000",
|
||||
}),
|
||||
);
|
||||
// The write-off's evidence, uploaded as an agent uploads it (D-46).
|
||||
@@ -1361,10 +1361,10 @@ mod bad_debts {
|
||||
&mut wire,
|
||||
"books.receive_payment",
|
||||
json!({"confirmed": true,
|
||||
"document_id": inv,
|
||||
"invoice": inv,
|
||||
"date": "2026-03-02",
|
||||
"amount": "60.00",
|
||||
"cash_account": "1000",
|
||||
"deposit_to": "1000",
|
||||
}),
|
||||
);
|
||||
let doc = ok(
|
||||
|
||||
@@ -767,7 +767,7 @@ fn books_p2_served_cut_over_whole() {
|
||||
let invoice = a
|
||||
.call(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": 1, "date": "2026-01-05", "due_date": "2026-02-04",
|
||||
json!({"customer": (1).to_string(), "date": "2026-01-05", "due_date": "2026-02-04",
|
||||
"lines": [{"description": "Design", "amount": "400.00"}], "confirmed": true}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
@@ -789,7 +789,7 @@ fn books_p2_served_cut_over_whole() {
|
||||
let unpaid = a
|
||||
.call(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": customer, "date": "2026-01-10", "due_date": "2026-02-09",
|
||||
json!({"customer": (customer).to_string(), "date": "2026-01-10", "due_date": "2026-02-09",
|
||||
"lines": [{"description": "Survey", "amount": "300.00"}], "confirmed": true}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
@@ -812,8 +812,24 @@ fn books_p2_served_cut_over_whole() {
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
// An open item P2 reads: the correction of its source is
|
||||
// source_revisions' (P5), and waits.
|
||||
let item = a
|
||||
.call(
|
||||
"books.raise_item",
|
||||
json!({"title": "Ask the CPA about the vehicle", "decided_by": "Avery Quill"}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.clone();
|
||||
let waiting = journal(&dir);
|
||||
let refused: Vec<(&str, Value)> = vec![
|
||||
// A source correction (source_revisions, P5): an older version
|
||||
// refuses the type.
|
||||
(
|
||||
"books.raise_item",
|
||||
json!({"revises": item, "reason": "it was the CPA who raised it",
|
||||
"decided_by": "Pat Smith"}),
|
||||
),
|
||||
// A draft's state and its discard (drafts_v2, P4): an older
|
||||
// version refuses the types.
|
||||
(
|
||||
@@ -893,6 +909,16 @@ fn books_p2_served_cut_over_whole() {
|
||||
json!({"attachment_id": 1, "fields": {"payee": "Harbor Design LLC",
|
||||
"amount": "12.34", "date": "2026-02-01"}}),
|
||||
),
|
||||
// An audit run and an attestation (audits, P8): an older version
|
||||
// doesn't know the events.
|
||||
(
|
||||
"books.audit",
|
||||
json!({"scope": "full", "through": "2026-02-28", "confirmed": true}),
|
||||
),
|
||||
(
|
||||
"books.review_entry",
|
||||
json!({"entry_id": 1, "method": {"reasoning_effort": "high"}}),
|
||||
),
|
||||
];
|
||||
for (tool, args) in &refused {
|
||||
let e = a.call(tool, args.clone()).unwrap_err();
|
||||
|
||||
@@ -1017,13 +1017,13 @@ fn inputs_that_once_panicked_are_refused_or_answered() {
|
||||
),
|
||||
(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": client, "date": "2026-05-01", "due_date": "2026-06-01",
|
||||
json!({"customer": (client).to_string(), "date": "2026-05-01", "due_date": "2026-06-01",
|
||||
"lines": [{"description": "a", "amount": MAX},
|
||||
{"description": "b", "amount": "1"}], "confirmed": true}),
|
||||
),
|
||||
(
|
||||
"books.create_bill",
|
||||
json!({"party_id": acme, "date": "2026-05-01", "due_date": "2026-06-01",
|
||||
json!({"vendor": (acme).to_string(), "date": "2026-05-01", "due_date": "2026-06-01",
|
||||
"vendor_ref": "H-1",
|
||||
"lines": [{"description": "a", "amount": MAX, "account": "6000"},
|
||||
{"description": "b", "amount": "1", "account": "6000"}],
|
||||
@@ -1031,7 +1031,7 @@ fn inputs_that_once_panicked_are_refused_or_answered() {
|
||||
),
|
||||
(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": client, "date": "2026-05-01", "due_date": "+262142-12-31",
|
||||
json!({"customer": (client).to_string(), "date": "2026-05-01", "due_date": "+262142-12-31",
|
||||
"lines": [{"description": "a", "amount": "1"}], "confirmed": true}),
|
||||
),
|
||||
(
|
||||
|
||||
@@ -125,7 +125,7 @@ fn mcp_end_to_end() {
|
||||
// Tool listing: the 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(), 133, "expected 133 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 140, "expected 140 tools, got {}", tools.len());
|
||||
// Files come in as tool input: books.begin_upload, never a local path.
|
||||
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
|
||||
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
|
||||
|
||||
@@ -230,8 +230,8 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
|
||||
server.tool("books.add_project", json!({"name": "Heron"}));
|
||||
let cedar = server.tool("books.add_customer", json!({"name": "Cedar Client"}))["id"].clone();
|
||||
let dana = server.tool("books.add_vendor", json!({"name": "Dana Illustration"}))["id"].clone();
|
||||
let doc = |party: &Value, date: &str, lines: Value| {
|
||||
json!({"party_id": party, "date": date, "due_date": date, "lines": lines,
|
||||
let doc = |key: &str, party: &Value, date: &str, lines: Value| {
|
||||
json!({key: party.to_string(), "date": date, "due_date": date, "lines": lines,
|
||||
"confirmed": true})
|
||||
};
|
||||
server.tool(
|
||||
@@ -241,6 +241,7 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
|
||||
let inv = server.tool(
|
||||
"books.create_invoice",
|
||||
doc(
|
||||
"customer",
|
||||
&cedar,
|
||||
"2026-02-02",
|
||||
json!([
|
||||
@@ -253,6 +254,7 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
|
||||
let inv2 = server.tool(
|
||||
"books.create_invoice",
|
||||
doc(
|
||||
"customer",
|
||||
&cedar,
|
||||
"2026-05-04",
|
||||
json!([{"description": "Build", "amount": "4321.09", "class": "kestrel"}]),
|
||||
@@ -262,6 +264,7 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
|
||||
.wire
|
||||
.upload(b"Dana's invoice", "dana.pdf", "application/pdf", "receipt");
|
||||
let mut bill_args = doc(
|
||||
"vendor",
|
||||
&dana,
|
||||
"2026-03-09",
|
||||
json!([
|
||||
@@ -274,7 +277,7 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
|
||||
let bill = server.tool("books.create_bill", bill_args);
|
||||
server.tool(
|
||||
"books.receive_payment",
|
||||
json!({"confirmed": true, "document_id": inv["id"], "date": "2026-02-20", "amount": "1830.00", "cash_account": "1000"}),
|
||||
json!({"confirmed": true, "invoice": inv["id"], "date": "2026-02-20", "amount": "1830.00", "deposit_to": "1000"}),
|
||||
);
|
||||
let b = data::load_once(&Source {
|
||||
data_dir: data.clone(),
|
||||
|
||||
@@ -606,10 +606,12 @@ fn dispose_asset_takes_the_tag_and_the_tag_stays_reserved() {
|
||||
let p1 = m.ok("books.dispose_asset", by_id.clone());
|
||||
let p2 = m.ok("books.dispose_asset", by_tag.clone());
|
||||
// (The digest names the arguments as sent, so it differs by id and
|
||||
// by tag; everything it shows is the same.)
|
||||
// by tag, and so does the confirm step the envelope offers, which
|
||||
// repeats them; everything the preview shows is the same.)
|
||||
let (mut p1, mut p2) = (p1, p2);
|
||||
for p in [&mut p1, &mut p2] {
|
||||
p.as_object_mut().unwrap().remove("preview");
|
||||
p.as_object_mut().unwrap().remove("next_steps");
|
||||
}
|
||||
assert_eq!(p1, p2, "same preview by id and by tag");
|
||||
let mut both = by_id.clone();
|
||||
|
||||
@@ -424,7 +424,7 @@ fn a_file_links_to_every_kind_of_subject() {
|
||||
let rent_invoice = m.upload(b"rent invoice", "rent.pdf", "application/pdf", "receipt");
|
||||
let bill = m.ok(
|
||||
"books.create_bill",
|
||||
json!({"party_id": vendor, "date": "2026-02-01", "due_date": "2026-02-28",
|
||||
json!({"vendor": (vendor).to_string(), "date": "2026-02-01", "due_date": "2026-02-28",
|
||||
"vendor_ref": "RENT-2",
|
||||
"lines": [{"description": "Rent", "amount": "300.00", "account": "6000"}],
|
||||
"upload_id": rent_invoice, "confirmed": true}),
|
||||
@@ -432,7 +432,7 @@ fn a_file_links_to_every_kind_of_subject() {
|
||||
.clone();
|
||||
let invoice = m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
|
||||
json!({"customer": (customer).to_string(), "date": "2026-02-01", "due_date": "2026-03-01",
|
||||
"lines": [{"description": "Work", "amount": "500.00"}],
|
||||
"confirmed": true}),
|
||||
)["id"]
|
||||
|
||||
@@ -911,7 +911,7 @@ fn happy_path(name: &str) -> Mcp {
|
||||
// A/R and A/P.
|
||||
let inv = m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
|
||||
json!({"customer": (customer).to_string(), "date": "2026-02-01", "due_date": "2026-03-01",
|
||||
"lines": [{"description": "Consulting", "amount": "500.00"}],
|
||||
"confirmed": true}),
|
||||
)["id"]
|
||||
@@ -919,7 +919,7 @@ fn happy_path(name: &str) -> Mcp {
|
||||
.unwrap();
|
||||
m.ok(
|
||||
"books.receive_payment",
|
||||
json!({"confirmed": true, "document_id": inv, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}),
|
||||
json!({"confirmed": true, "invoice": inv, "date": "2026-02-10", "amount": "200.00", "deposit_to": "1000"}),
|
||||
);
|
||||
let vendor_invoice = m.upload(
|
||||
b"vendor invoice",
|
||||
@@ -929,7 +929,7 @@ fn happy_path(name: &str) -> Mcp {
|
||||
);
|
||||
let bill = m.ok(
|
||||
"books.create_bill",
|
||||
json!({"party_id": vendor, "date": "2026-02-01", "due_date": "2026-02-28",
|
||||
json!({"vendor": (vendor).to_string(), "date": "2026-02-01", "due_date": "2026-02-28",
|
||||
"vendor_ref": "V-1001",
|
||||
"lines": [{"description": "Office rent", "amount": "300.00", "account": "6000"}],
|
||||
"upload_id": vendor_invoice, "confirmed": true}),
|
||||
@@ -938,7 +938,7 @@ fn happy_path(name: &str) -> Mcp {
|
||||
.unwrap();
|
||||
m.ok(
|
||||
"books.pay_bill",
|
||||
json!({"confirmed": true, "document_id": bill, "date": "2026-02-05", "amount": "300.00", "cash_account": "1000"}),
|
||||
json!({"confirmed": true, "bill": bill, "date": "2026-02-05", "amount": "300.00", "paid_from": "1000"}),
|
||||
);
|
||||
m.ok("books.ar_aging", json!({"as_of": "2026-06-30"}));
|
||||
m.ok("books.ap_aging", json!({"as_of": "2026-06-30"}));
|
||||
@@ -946,7 +946,7 @@ fn happy_path(name: &str) -> Mcp {
|
||||
// P4b S1: a document voided, and the control accounts' tie-out.
|
||||
let dup = m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": customer, "date": "2026-02-02", "due_date": "2026-03-02",
|
||||
json!({"customer": (customer).to_string(), "date": "2026-02-02", "due_date": "2026-03-02",
|
||||
"lines": [{"description": "Consulting, entered twice", "amount": "500.00"}],
|
||||
"confirmed": true}),
|
||||
)["id"]
|
||||
@@ -972,7 +972,7 @@ fn happy_path(name: &str) -> Mcp {
|
||||
// P4b S7: a bad debt, its evidence uploaded and filed with it.
|
||||
let bad = m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": customer, "date": "2026-02-03", "due_date": "2026-03-03",
|
||||
json!({"customer": (customer).to_string(), "date": "2026-02-03", "due_date": "2026-03-03",
|
||||
"lines": [{"description": "Consulting, never paid", "amount": "400.00"}],
|
||||
"confirmed": true}),
|
||||
)["id"]
|
||||
@@ -1146,6 +1146,28 @@ fn happy_path(name: &str) -> Mcp {
|
||||
json!({"period_id": p, "confirmed": true}),
|
||||
);
|
||||
|
||||
// P8: an audit of the month, its workpaper, and the claims that stand
|
||||
// behind it.
|
||||
let audit_run = m.ok(
|
||||
"books.audit",
|
||||
json!({"scope": "month", "through": "2026-01-31", "confirmed": true}),
|
||||
)["run"]["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
m.ok("books.workpaper", json!({"id": audit_run}));
|
||||
let audited = m.attach(Some(e1), b"audit receipt");
|
||||
m.ok(
|
||||
"books.attest_receipt",
|
||||
json!({"attachment_id": audited, "entry_id": e1, "amount": "10000.00",
|
||||
"date": "2026-01-02", "method": {"reasoning_effort": "high"}}),
|
||||
);
|
||||
m.ok(
|
||||
"books.review_entry",
|
||||
json!({"entry_id": e1, "method": {"reasoning_effort": "high"}}),
|
||||
);
|
||||
m.ok("books.attestations", json!({}));
|
||||
m.ok("books.attest_period", json!({"period": "2026-01"}));
|
||||
|
||||
// How the books are kept: start, conventions, decisions, open items,
|
||||
// a template and an approval, and the settings file moving in.
|
||||
m.ok("books.start", json!({}));
|
||||
@@ -1294,6 +1316,8 @@ fn happy_path(name: &str) -> Mcp {
|
||||
// The access log, the backups' status, and a backup now (none is set
|
||||
// up: it says so).
|
||||
m.ok("books.access_log", json!({}));
|
||||
// P5: the journal in words.
|
||||
m.ok("books.journal_history", json!({"limit": 5}));
|
||||
m.ok("books.backup", json!({}));
|
||||
m.ok("books.back_up_now", json!({}));
|
||||
|
||||
@@ -1685,15 +1709,15 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
|
||||
vendor_ref,
|
||||
} => (
|
||||
"books.create_bill",
|
||||
json!({"party_id": party.resolve(&k.vendors), "date": "2026-03-01",
|
||||
json!({"vendor": (party.resolve(&k.vendors)).to_string(), "date": "2026-03-01",
|
||||
"due_date": "2026-03-31", "vendor_ref": vendor_ref,
|
||||
"lines": [{"description": "thing", "amount": amount, "account": "6000"}],
|
||||
"existing_attachment_id": k.vendor_invoice, "confirmed": true}),
|
||||
),
|
||||
Op::PayBill { doc, amount } => (
|
||||
"books.pay_bill",
|
||||
json!({"confirmed": true, "document_id": doc.resolve(&k.bills), "date": "2026-03-15", "amount": amount,
|
||||
"cash_account": "1000"}),
|
||||
json!({"confirmed": true, "bill": doc.resolve(&k.bills), "date": "2026-03-15", "amount": amount,
|
||||
"paid_from": "1000"}),
|
||||
),
|
||||
Op::Payout { deposit } => (
|
||||
"books.record_payout",
|
||||
@@ -1834,7 +1858,7 @@ fn inv3_other_tools_refuse_ungrammatical_amounts() {
|
||||
);
|
||||
m.refuse(
|
||||
"books.create_bill",
|
||||
json!({"party_id": vendor, "date": "2026-03-01", "due_date": "2026-03-31",
|
||||
json!({"vendor": (vendor).to_string(), "date": "2026-03-01", "due_date": "2026-03-31",
|
||||
"lines": [{"description": "x", "amount": bad}]}),
|
||||
);
|
||||
m.refuse(
|
||||
@@ -2591,7 +2615,7 @@ fn assigned_roles_steer_invoices() {
|
||||
.unwrap();
|
||||
m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
|
||||
json!({"customer": (customer).to_string(), "date": "2026-02-01", "due_date": "2026-03-01",
|
||||
"lines": [{"description": "Consulting", "amount": "500.00"}],
|
||||
"confirmed": true}),
|
||||
);
|
||||
@@ -2654,7 +2678,7 @@ fn documents_void_and_tie_out_over_the_wire() {
|
||||
);
|
||||
let inv = m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
|
||||
json!({"customer": (customer).to_string(), "date": "2026-02-01", "due_date": "2026-03-01",
|
||||
"lines": [{"description": "Consulting", "amount": "500.00"}],
|
||||
"confirmed": true}),
|
||||
)["id"]
|
||||
@@ -2662,8 +2686,8 @@ fn documents_void_and_tie_out_over_the_wire() {
|
||||
.unwrap();
|
||||
let paid = m.ok(
|
||||
"books.receive_payment",
|
||||
json!({"confirmed": true, "document_id": inv, "date": "2026-02-10", "amount": "200.00",
|
||||
"cash_account": "1000", "instrument": "Harbor checking", "reference": "tr_8812"}),
|
||||
json!({"confirmed": true, "invoice": inv, "date": "2026-02-10", "amount": "200.00",
|
||||
"deposit_to": "1000", "instrument": "Harbor checking", "reference": "tr_8812"}),
|
||||
);
|
||||
assert_eq!(paid["remaining"], "300.00", "{paid}");
|
||||
assert_eq!(paid["status"], "partial", "{paid}");
|
||||
@@ -2685,7 +2709,7 @@ fn documents_void_and_tie_out_over_the_wire() {
|
||||
|
||||
let dup = m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": customer, "date": "2026-02-02", "due_date": "2026-03-02",
|
||||
json!({"customer": (customer).to_string(), "date": "2026-02-02", "due_date": "2026-03-02",
|
||||
"lines": [{"description": "Consulting, entered twice", "amount": "500.00"}],
|
||||
"confirmed": true}),
|
||||
)["id"]
|
||||
@@ -2753,7 +2777,7 @@ fn documents_list_and_timeline_over_the_wire() {
|
||||
let invoice = |m: &mut Mcp, date: &str, due: &str, amount: &str| {
|
||||
m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": customer, "date": date, "due_date": due,
|
||||
json!({"customer": (customer).to_string(), "date": date, "due_date": due,
|
||||
"lines": [{"description": "Consulting", "amount": amount}],
|
||||
"confirmed": true}),
|
||||
)["id"]
|
||||
@@ -2764,7 +2788,7 @@ fn documents_list_and_timeline_over_the_wire() {
|
||||
let later = invoice(&mut m, "2026-02-05", "2026-04-30", "80.00");
|
||||
let pay = m.ok(
|
||||
"books.receive_payment",
|
||||
json!({"confirmed": true, "document_id": late, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}),
|
||||
json!({"confirmed": true, "invoice": late, "date": "2026-02-10", "amount": "200.00", "deposit_to": "1000"}),
|
||||
)["entry_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
|
||||
@@ -835,15 +835,15 @@ fn party_totals_match_a_hand_computed_year() {
|
||||
);
|
||||
let bill = m.ok(
|
||||
"books.create_bill",
|
||||
json!({"party_id": v, "date": "2026-07-01", "due_date": "2026-07-31",
|
||||
json!({"vendor": (v).to_string(), "date": "2026-07-01", "due_date": "2026-07-31",
|
||||
"vendor_ref": "D-1041",
|
||||
"lines": [{"description": "Logo", "amount": "800.00", "account": "6000"}],
|
||||
"upload_id": logo_invoice, "confirmed": true}),
|
||||
);
|
||||
let pay = m.ok(
|
||||
"books.pay_bill",
|
||||
json!({"confirmed": true, "document_id": bill["id"], "date": "2026-07-15", "amount": "300.00",
|
||||
"cash_account": "1000"}),
|
||||
json!({"confirmed": true, "bill": bill["id"], "date": "2026-07-15", "amount": "300.00",
|
||||
"paid_from": "1000"}),
|
||||
)["entry_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -917,14 +917,14 @@ fn party_totals_for_a_customer_a_member_and_a_prepaid() {
|
||||
.unwrap();
|
||||
let inv = m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": c, "date": "2026-03-01", "due_date": "2026-03-31",
|
||||
json!({"customer": (c).to_string(), "date": "2026-03-01", "due_date": "2026-03-31",
|
||||
"lines": [{"description": "Work", "amount": "1000.00"}],
|
||||
"confirmed": true}),
|
||||
);
|
||||
let pay = m.ok(
|
||||
"books.receive_payment",
|
||||
json!({"confirmed": true, "document_id": inv["id"], "date": "2026-03-20", "amount": "600.00",
|
||||
"cash_account": "1000"}),
|
||||
json!({"confirmed": true, "invoice": inv["id"], "date": "2026-03-20", "amount": "600.00",
|
||||
"deposit_to": "1000"}),
|
||||
)["entry_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
|
||||
@@ -1231,7 +1231,7 @@ fn invoice_and_bill_lines_count_toward_their_projects() {
|
||||
);
|
||||
let inv = m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": cedar, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
|
||||
json!({"customer": (cedar).to_string(), "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
|
||||
{"description": "Design", "amount": "3000.00", "class": "Zeta site"},
|
||||
{"description": "Hosting", "amount": "200.00", "class": harbor.to_string()},
|
||||
{"description": "Rush fee", "amount": "100.00"},
|
||||
@@ -1264,7 +1264,7 @@ fn invoice_and_bill_lines_count_toward_their_projects() {
|
||||
);
|
||||
let bill = m.ok(
|
||||
"books.create_bill",
|
||||
json!({"party_id": dana_vendor, "date": "2026-03-09", "due_date": "2026-04-08",
|
||||
json!({"vendor": (dana_vendor).to_string(), "date": "2026-03-09", "due_date": "2026-04-08",
|
||||
"vendor_ref": "DANA-77",
|
||||
"lines": [
|
||||
{"description": "Illustration", "amount": "1200.00", "account": "6000", "class": "Zeta site"},
|
||||
@@ -1284,11 +1284,11 @@ fn invoice_and_bill_lines_count_toward_their_projects() {
|
||||
// Payments are balance-sheet only: no project.
|
||||
let got = m.ok(
|
||||
"books.receive_payment",
|
||||
json!({"confirmed": true, "document_id": inv["id"], "date": "2026-03-20", "amount": "3800.00", "cash_account": "1000"}),
|
||||
json!({"confirmed": true, "invoice": inv["id"], "date": "2026-03-20", "amount": "3800.00", "deposit_to": "1000"}),
|
||||
);
|
||||
let paid = m.ok(
|
||||
"books.pay_bill",
|
||||
json!({"confirmed": true, "document_id": bill["id"], "date": "2026-03-30", "amount": "1250.00", "cash_account": "1000"}),
|
||||
json!({"confirmed": true, "bill": bill["id"], "date": "2026-03-30", "amount": "1250.00", "paid_from": "1000"}),
|
||||
);
|
||||
for e in [&got["entry_id"], &paid["entry_id"]] {
|
||||
let e = e.as_u64().unwrap();
|
||||
@@ -1353,10 +1353,13 @@ fn a_document_naming_an_unknown_project_is_refused_whole() {
|
||||
m.ok("books.add_project", json!({"name": "Zeta site"}));
|
||||
let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone();
|
||||
let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone();
|
||||
for (tool, party) in [("books.create_invoice", &cedar), ("books.create_bill", &v)] {
|
||||
for (tool, key, party) in [
|
||||
("books.create_invoice", "customer", &cedar),
|
||||
("books.create_bill", "vendor", &v),
|
||||
] {
|
||||
let why = m.refuse(
|
||||
tool,
|
||||
json!({"party_id": party, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
|
||||
json!({key: (party).to_string(), "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
|
||||
{"description": "a", "amount": "10.00", "class": "Zeta site"},
|
||||
{"description": "b", "amount": "10.00", "class": "Nowhere"},
|
||||
]}),
|
||||
@@ -1379,7 +1382,7 @@ fn a_document_for_a_finished_project_says_so() {
|
||||
let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone();
|
||||
let out = m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": cedar, "date": "2026-10-05", "due_date": "2026-11-04", "lines": [
|
||||
json!({"customer": (cedar).to_string(), "date": "2026-10-05", "due_date": "2026-11-04", "lines": [
|
||||
{"description": "Final", "amount": "10.00", "class": "Harbor"},
|
||||
{"description": "Again", "amount": "10.00", "class": "harbor"},
|
||||
{"description": "Old", "amount": "10.00", "class": "Dock"},
|
||||
@@ -1424,7 +1427,7 @@ fn an_invoice_for_an_on_spec_project_asks_about_an_agreement() {
|
||||
let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone();
|
||||
let out = m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": cedar, "date": "2026-10-05", "due_date": "2026-11-04", "lines": [
|
||||
json!({"customer": (cedar).to_string(), "date": "2026-10-05", "due_date": "2026-11-04", "lines": [
|
||||
{"description": "Build", "amount": "10.00", "class": "Kestrel"},
|
||||
{"description": "More", "amount": "5.00", "class": "kestrel"},
|
||||
{"description": "Site", "amount": "7.00", "class": "Zeta"},
|
||||
@@ -1450,7 +1453,7 @@ fn an_invoice_for_an_on_spec_project_asks_about_an_agreement() {
|
||||
);
|
||||
let bill = m.ok(
|
||||
"books.create_bill",
|
||||
json!({"party_id": v, "date": "2026-10-05", "due_date": "2026-11-04",
|
||||
json!({"vendor": (v).to_string(), "date": "2026-10-05", "due_date": "2026-11-04",
|
||||
"vendor_ref": "V-512",
|
||||
"lines": [
|
||||
{"description": "Icons", "amount": "10.00", "account": "6000", "class": "Kestrel"},
|
||||
@@ -1472,10 +1475,13 @@ fn books_without_the_feature_refuse_a_tagged_document() {
|
||||
let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone();
|
||||
let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone();
|
||||
let head = m.journal();
|
||||
for (tool, party) in [("books.create_invoice", &cedar), ("books.create_bill", &v)] {
|
||||
for (tool, key, party) in [
|
||||
("books.create_invoice", "customer", &cedar),
|
||||
("books.create_bill", "vendor", &v),
|
||||
] {
|
||||
let why = m.refuse(
|
||||
tool,
|
||||
json!({"party_id": party, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
|
||||
json!({key: (party).to_string(), "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
|
||||
{"description": "a", "amount": "10.00", "class": "Zeta site"},
|
||||
]}),
|
||||
);
|
||||
@@ -1487,7 +1493,7 @@ fn books_without_the_feature_refuse_a_tagged_document() {
|
||||
assert_eq!(m.journal(), head, "nothing journaled");
|
||||
m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": cedar, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
|
||||
json!({"customer": (cedar).to_string(), "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
|
||||
{"description": "a", "amount": "10.00"},
|
||||
], "confirmed": true}),
|
||||
);
|
||||
|
||||
@@ -278,7 +278,7 @@ fn require_tax_category_refuses_defect_8() {
|
||||
);
|
||||
let bill = m.call(
|
||||
"books.create_bill",
|
||||
json!({"party_id": vendor["id"], "date": "2026-02-01", "due_date": "2026-03-01",
|
||||
json!({"vendor": (vendor["id"]).to_string(), "date": "2026-02-01", "due_date": "2026-03-01",
|
||||
"vendor_ref": "CH-1",
|
||||
"lines": [{"description": "hosting", "amount": "42.00", "account": "6150"}],
|
||||
"upload_id": first_invoice, "confirmed": true}),
|
||||
@@ -303,7 +303,7 @@ fn require_tax_category_refuses_defect_8() {
|
||||
);
|
||||
let again = m.call(
|
||||
"books.create_bill",
|
||||
json!({"party_id": vendor["id"], "date": "2026-02-02", "due_date": "2026-03-02",
|
||||
json!({"vendor": (vendor["id"]).to_string(), "date": "2026-02-02", "due_date": "2026-03-02",
|
||||
"vendor_ref": "CH-2",
|
||||
"lines": [{"description": "hosting", "amount": "1.00", "account": "6150"}],
|
||||
"upload_id": second_invoice, "confirmed": true}),
|
||||
|
||||
@@ -521,6 +521,9 @@ fn files_under(dir: &Path) -> Vec<PathBuf> {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
out.extend(files_under(&p));
|
||||
} else if p.extension().is_some_and(|x| x == "lock") {
|
||||
// A lock file holds no data, and on Windows a held lock is
|
||||
// mandatory: reading it is refused while the server runs.
|
||||
} else {
|
||||
out.push(p);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! and host.rs; the P11a tests update_is_atomic, failed_update_rolls_back,
|
||||
//! unsigned_update_refused, bridge_survives_update,
|
||||
//! update_waits_for_a_running_call, package_managed_install_refuses_update,
|
||||
//! and the rollback laws (kept version, the format gate).
|
||||
//! and the rollback laws (kept version, the format gate); and step 4's
|
||||
//! daily check, staged update and apply-at-next-start.
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
mod common;
|
||||
@@ -16,8 +17,7 @@ use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const KNOBS: &str =
|
||||
"close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300";
|
||||
const KNOBS: &str = "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300,update_check=400";
|
||||
|
||||
/// The platform key a manifest names, as the program computes it.
|
||||
fn platform() -> String {
|
||||
@@ -76,6 +76,10 @@ fn base_program() -> &'static Path {
|
||||
struct Home {
|
||||
dir: tempfile::TempDir,
|
||||
exe: PathBuf,
|
||||
/// A pretend AppImage (P11a step 4): the programs run from `exe`, as
|
||||
/// from an image's mount, and `APPIMAGE` names this other file, the
|
||||
/// one an update replaces.
|
||||
image: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Home {
|
||||
@@ -95,7 +99,22 @@ impl Home {
|
||||
std::fs::create_dir(&bin).unwrap();
|
||||
let exe = bin.join("wanderbooks");
|
||||
std::fs::copy(base_program(), &exe).unwrap();
|
||||
let home = Self { dir, exe };
|
||||
let home = Self {
|
||||
dir,
|
||||
exe,
|
||||
image: None,
|
||||
};
|
||||
home.runnable();
|
||||
home
|
||||
}
|
||||
|
||||
/// A home whose program is an AppImage: the same program twice, one
|
||||
/// copy to run (the mount's) and one the image file `APPIMAGE` names.
|
||||
fn new_image(tag: &str) -> Self {
|
||||
let mut home = Self::new(tag);
|
||||
let image = home.dir.path().join("Wanderbooks.AppImage");
|
||||
std::fs::copy(base_program(), &image).unwrap();
|
||||
home.image = Some(image);
|
||||
home.runnable();
|
||||
home
|
||||
}
|
||||
@@ -103,25 +122,35 @@ impl Home {
|
||||
/// Wait until the copy can be run (a child another test thread forked
|
||||
/// while it was written may hold it open for a moment).
|
||||
fn runnable(&self) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
match Command::new(&self.exe)
|
||||
.arg("--help")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
{
|
||||
Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => {
|
||||
assert!(Instant::now() < deadline, "the copy stayed busy");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
for exe in std::iter::once(&self.exe).chain(self.image.as_ref()) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
match Command::new(exe)
|
||||
.arg("--help")
|
||||
.env("APPIMAGE_EXTRACT_AND_RUN", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
{
|
||||
Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => {
|
||||
assert!(Instant::now() < deadline, "the copy stayed busy");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
Err(e) => panic!("the copy can't be run: {e}"),
|
||||
Ok(_) => break,
|
||||
}
|
||||
Err(e) => panic!("the copy can't be run: {e}"),
|
||||
Ok(_) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The environment of the pretend AppImage, if this is one.
|
||||
fn image_env(&self, c: &mut Command) {
|
||||
if let Some(image) = &self.image {
|
||||
c.env("APPIMAGE", image);
|
||||
}
|
||||
}
|
||||
|
||||
fn root(&self) -> PathBuf {
|
||||
self.dir.path().join(".local/share/wanderbooks")
|
||||
}
|
||||
@@ -139,11 +168,13 @@ impl Home {
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.env("HOME", self.dir.path());
|
||||
self.image_env(&mut c);
|
||||
c
|
||||
}
|
||||
|
||||
/// The program that updates replace: the image, if there is one.
|
||||
fn program(&self) -> Vec<u8> {
|
||||
std::fs::read(&self.exe).unwrap()
|
||||
std::fs::read(self.image.as_ref().unwrap_or(&self.exe)).unwrap()
|
||||
}
|
||||
|
||||
fn create(&self, id: &str) {
|
||||
@@ -180,6 +211,7 @@ impl Home {
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.env("HOME", self.dir.path())
|
||||
.stdin(Stdio::null());
|
||||
self.image_env(&mut c);
|
||||
c.output().unwrap()
|
||||
}
|
||||
|
||||
@@ -655,7 +687,11 @@ impl Drop for Agent {
|
||||
/// host and server run the new file afterwards, and the head is the one
|
||||
/// the writes made.
|
||||
fn update_under_writes(tag: &str) -> (Home, Agent, Vec<String>) {
|
||||
let home = Home::new(tag);
|
||||
update_under_writes_in(Home::new(tag))
|
||||
}
|
||||
|
||||
fn update_under_writes_in(home: Home) -> (Home, Agent, Vec<String>) {
|
||||
let tag = "under-writes";
|
||||
home.create("acme");
|
||||
let release = Release::new();
|
||||
let next = newer(&home, tag);
|
||||
@@ -673,18 +709,21 @@ fn update_under_writes(tag: &str) -> (Home, Agent, Vec<String>) {
|
||||
release.dir.path().join("manifest.json").display()
|
||||
);
|
||||
let key = release.dir.path().join("release.pub");
|
||||
let image = home.image.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _keep = &release;
|
||||
Command::new(exe)
|
||||
.args(["update", "--root"])
|
||||
let mut c = Command::new(exe);
|
||||
c.args(["update", "--root"])
|
||||
.arg(root)
|
||||
.args(["--feed", &feed, "--trusted-key"])
|
||||
.arg(key)
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.env("HOME", dir)
|
||||
.output()
|
||||
.unwrap()
|
||||
.env("HOME", dir);
|
||||
if let Some(image) = image {
|
||||
c.env("APPIMAGE", image);
|
||||
}
|
||||
c.output().unwrap()
|
||||
})
|
||||
};
|
||||
let mut written = vec!["4000".to_string()];
|
||||
@@ -742,6 +781,39 @@ fn bridge_survives_update() {
|
||||
assert!(in_books >= written.len(), "{in_books} < {}", written.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_replaced_appimage_restarts_the_books() {
|
||||
// The programs run from one file (an image's mount) while `APPIMAGE`
|
||||
// names another (the image). Replacing the image alone must restart
|
||||
// the host onto it, with the session and every write intact.
|
||||
let home = Home::new_image("appimage");
|
||||
let image = home.image.clone().unwrap();
|
||||
home.create("acme");
|
||||
let release = Release::new();
|
||||
let next = newer(&home, "appimage");
|
||||
release.publish("9.0.0", &next, |_| {});
|
||||
let mut agent = Agent::start(&home, "acme");
|
||||
agent.add_account("4000").unwrap();
|
||||
let host_before = wait_host(&home);
|
||||
let out = home.run("update", Some(&release));
|
||||
assert!(out.status.success(), "{}", text(&out));
|
||||
assert_eq!(std::fs::read(&image).unwrap(), next);
|
||||
// The mount's copy is untouched; only the image changed.
|
||||
assert_ne!(std::fs::read(&home.exe).unwrap(), next);
|
||||
assert!(
|
||||
wait_for(60, || {
|
||||
home.pids("host").len() == 1 && home.pids("host")[0] != host_before
|
||||
}),
|
||||
"the host never restarted onto the replaced image"
|
||||
);
|
||||
let host = wait_host(&home);
|
||||
assert_eq!(
|
||||
std::fs::read_link(format!("/proc/{host}/exe")).unwrap(),
|
||||
image
|
||||
);
|
||||
assert!(agent.add_account("4001").is_ok(), "the session carries on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_waits_for_a_running_call() {
|
||||
// No call is cut off or made twice: each account written across the
|
||||
@@ -763,3 +835,161 @@ fn update_waits_for_a_running_call() {
|
||||
"a write lost, or made twice, across the update"
|
||||
);
|
||||
}
|
||||
|
||||
/// Name `release` as this home's local feed, as the owner would
|
||||
/// (`<root>/updates/feed`: the address, then the key file).
|
||||
fn name_feed(home: &Home, release: &Release) {
|
||||
std::fs::create_dir_all(home.updates()).unwrap();
|
||||
std::fs::write(
|
||||
home.updates().join("feed"),
|
||||
format!(
|
||||
"file://{}\n{}\n",
|
||||
release.dir.path().join("manifest.json").display(),
|
||||
release.dir.path().join("release.pub").display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn staged_version(home: &Home) -> Option<String> {
|
||||
let meta = std::fs::read(home.updates().join("staged/meta.json")).ok()?;
|
||||
let v: Value = serde_json::from_slice(&meta).ok()?;
|
||||
v["version"].as_str().map(str::to_string)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_stages_a_daily_update_and_the_next_start_applies_it() {
|
||||
let home = Home::new("daily");
|
||||
home.create("acme");
|
||||
let old = home.program();
|
||||
let release = Release::new();
|
||||
let next = newer(&home, "daily");
|
||||
release.publish("9.0.0", &next, |_| {});
|
||||
name_feed(&home, &release);
|
||||
let mut agent = Agent::start(&home, "acme");
|
||||
agent.add_account("4000").unwrap();
|
||||
let host = wait_host(&home);
|
||||
|
||||
// The host looks, downloads, verifies and stages; nothing running
|
||||
// changes: not the file, not the host.
|
||||
assert!(
|
||||
wait_for(30, || staged_version(&home).as_deref() == Some("9.0.0")),
|
||||
"nothing was staged"
|
||||
);
|
||||
assert_eq!(home.program(), old, "the program was swapped while open");
|
||||
assert!(!home.updates().join("previous").exists());
|
||||
std::thread::sleep(Duration::from_millis(1200));
|
||||
assert_eq!(home.pids("host"), vec![host], "the host restarted");
|
||||
assert!(agent.add_account("4001").is_ok());
|
||||
|
||||
// The books close; the next opener applies it before the host starts.
|
||||
drop(agent);
|
||||
let out = home.cmd("close").output().unwrap();
|
||||
assert!(out.status.success(), "{}", text(&out));
|
||||
assert!(wait_for(20, || home.pids("host").is_empty()));
|
||||
let mut agent = Agent::start(&home, "acme");
|
||||
assert_eq!(home.program(), next, "the staged update wasn't applied");
|
||||
assert!(!home.updates().join("staged").exists());
|
||||
assert!(
|
||||
home.updates()
|
||||
.join("previous")
|
||||
.join("wanderbooks")
|
||||
.is_file()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(home.updates().join("previous/wanderbooks")).unwrap(),
|
||||
old
|
||||
);
|
||||
let host = wait_host(&home);
|
||||
assert!(
|
||||
!std::fs::read_link(format!("/proc/{host}/exe"))
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.contains("(deleted)")
|
||||
);
|
||||
let chart = agent.call("books.chart", json!({})).unwrap();
|
||||
let codes: Vec<&str> = chart["accounts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|a| a["code"].as_str())
|
||||
.collect();
|
||||
assert!(codes.contains(&"4000") && codes.contains(&"4001"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_daily_update_that_is_not_ours_is_not_staged() {
|
||||
let home = Home::new("daily-bad");
|
||||
home.create("acme");
|
||||
let release = Release::new();
|
||||
let next = newer(&home, "daily-bad");
|
||||
let other = minisign::KeyPair::generate_unencrypted_keypair().unwrap();
|
||||
// Signed by another key; and, separately, a wrong checksum.
|
||||
let sig = Release::b64(&Release::sign_with(&other, &next));
|
||||
release.publish("9.0.0", &next, |e| e["signature"] = json!(sig));
|
||||
name_feed(&home, &release);
|
||||
let _agent = Agent::start(&home, "acme");
|
||||
assert!(
|
||||
wait_for(30, || home.updates().join("checked").exists()),
|
||||
"no look"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
assert_eq!(staged_version(&home), None);
|
||||
assert!(!home.updates().join("staged").exists());
|
||||
release.publish("9.0.1", &next, |e| e["sha256"] = json!("00".repeat(32)));
|
||||
std::fs::remove_file(home.updates().join("checked")).unwrap();
|
||||
assert!(
|
||||
wait_for(30, || home.updates().join("checked").exists()),
|
||||
"no second look"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
assert_eq!(staged_version(&home), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_installs_the_copy_the_host_staged_without_downloading_again() {
|
||||
let home = Home::new("daily-use");
|
||||
home.create("acme");
|
||||
let release = Release::new();
|
||||
let next = newer(&home, "daily-use");
|
||||
release.publish("9.0.0", &next, |_| {});
|
||||
name_feed(&home, &release);
|
||||
let _agent = Agent::start(&home, "acme");
|
||||
assert!(wait_for(30, || staged_version(&home).is_some()));
|
||||
// The program file is gone from the feed: only the staged copy is left.
|
||||
std::fs::remove_file(release.dir.path().join("wanderbooks-9.0.0")).unwrap();
|
||||
let out = home.run("update", None);
|
||||
assert!(out.status.success(), "{}", text(&out));
|
||||
assert_eq!(home.program(), next);
|
||||
assert!(!home.updates().join("staged").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_staged_update_that_was_tampered_with_is_dropped_not_applied() {
|
||||
let home = Home::new("daily-tamper");
|
||||
home.create("acme");
|
||||
let old = home.program();
|
||||
let release = Release::new();
|
||||
let next = newer(&home, "daily-tamper");
|
||||
release.publish("9.0.0", &next, |_| {});
|
||||
name_feed(&home, &release);
|
||||
let agent = Agent::start(&home, "acme");
|
||||
assert!(wait_for(30, || staged_version(&home).is_some()));
|
||||
drop(agent);
|
||||
let out = home.cmd("close").output().unwrap();
|
||||
assert!(out.status.success(), "{}", text(&out));
|
||||
assert!(wait_for(20, || home.pids("host").is_empty()));
|
||||
// The staged file changes, its recorded checksum with it: the
|
||||
// signature no longer holds.
|
||||
let staged = home.updates().join("staged");
|
||||
let mut bytes = std::fs::read(staged.join("wanderbooks")).unwrap();
|
||||
bytes.extend_from_slice(b"tamper");
|
||||
std::fs::write(staged.join("wanderbooks"), &bytes).unwrap();
|
||||
let mut meta: Value =
|
||||
serde_json::from_slice(&std::fs::read(staged.join("meta.json")).unwrap()).unwrap();
|
||||
meta["sha256"] = json!(sha256(&bytes));
|
||||
std::fs::write(staged.join("meta.json"), meta.to_string()).unwrap();
|
||||
let _agent = Agent::start(&home, "acme");
|
||||
assert_eq!(home.program(), old, "a tampered update was applied");
|
||||
assert!(!staged.exists(), "and kept");
|
||||
}
|
||||
Reference in new issue
Block a user