From 57c28679d5f07740241c2638e80ab6ffc447a353 Mon Sep 17 00:00:00 2001 From: Stephen Waits <1+swaits@noreply.git.swaits.com> Date: Sun, 11 Oct 2026 01:37:21 +0000 Subject: [PATCH] First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) --- crates/ledger-store/tests/store.rs | 280 +++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 crates/ledger-store/tests/store.rs diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs new file mode 100644 index 0000000..f818bb5 --- /dev/null +++ b/crates/ledger-store/tests/store.rs @@ -0,0 +1,280 @@ +//! Store tests: the durability story, proven. +//! +//! Round-trip persistence, tamper detection, rebuild-from-journal healing, +//! blob deduplication and hash verification. + +use chrono::NaiveDate; +use ledger_core::{AccountType, Currency, Money, Side}; +use ledger_store::{JournalWriter, Store}; +use rust_decimal::Decimal; +use std::path::PathBuf; + +fn usd(amount: u64) -> Money { + Money::new(Decimal::from(amount), Currency::USD) +} + +fn test_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("wanderbooks-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn build_books(dir: &std::path::Path) -> Store { + let mut store = Store::create(dir, Currency::USD).unwrap(); + let cash = store + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let equity = store + .add_account("3000", "Owner's Equity", AccountType::Equity, None, false) + .unwrap(); + let revenue = store + .add_account("4000", "Service Revenue", AccountType::Revenue, None, false) + .unwrap(); + let rent = store + .add_account("6000", "Rent Expense", AccountType::Expense, None, false) + .unwrap(); + + let e1 = store + .create_entry( + NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(), + "Owner investment", + ) + .unwrap(); + store.add_line(e1, cash, Side::Debit, usd(10_000)).unwrap(); + store + .add_line(e1, equity, Side::Credit, usd(10_000)) + .unwrap(); + store.post_entry(e1).unwrap(); + + let e2 = store + .create_entry( + NaiveDate::from_ymd_opt(2026, 1, 10).unwrap(), + "Invoice client", + ) + .unwrap(); + store.add_line(e2, cash, Side::Debit, usd(4_000)).unwrap(); + store + .add_line(e2, revenue, Side::Credit, usd(4_000)) + .unwrap(); + store.post_entry(e2).unwrap(); + + let e3 = store + .create_entry( + NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(), + "January rent", + ) + .unwrap(); + store.add_line(e3, rent, Side::Debit, usd(800)).unwrap(); + store.add_line(e3, cash, Side::Credit, usd(800)).unwrap(); + store.post_entry(e3).unwrap(); + + // A receipt attached to the rent entry. + let (_rid, sha, dedup) = store + .attach_receipt( + Some(e3), + "rent-receipt.png", + "image/png", + b"fake-png-bytes", + Some("January rent receipt"), + ) + .unwrap(); + assert!(!dedup); + assert_eq!(sha.len(), 64); + + // A reversal, to exercise the reversal path through the journal. + store + .reverse_entry( + e3, + NaiveDate::from_ymd_opt(2026, 1, 16).unwrap(), + "Reverse rent", + ) + .unwrap(); + + store +} + +#[test] +fn round_trip_survives_reopen() { + let dir = test_dir("roundtrip"); + let store = build_books(&dir); + // Live ledger: rent was posted then reversed → net zero movement on rent. + assert_eq!( + store + .ledger() + .account_movement(store.ledger().chart().find_by_code("6000").unwrap().id), + Decimal::from(0) + ); + drop(store); + + let store = Store::open(&dir).unwrap(); + // Same facts after reopen: cash = 10000 + 4000 = 14000. + let cash_id = store.ledger().chart().find_by_code("1000").unwrap().id; + assert_eq!( + store.ledger().account_movement(cash_id), + Decimal::from(14_000) + ); + // Trial balance still balances. + let tb = ledger_core::reports::trial_balance(store.ledger()); + assert!(tb.balanced()); + assert_eq!(tb.total_debits, Decimal::from(14_000)); + // Receipt survived with its bytes. + let receipts = store.list_attachments(None).unwrap(); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].file_name, "rent-receipt.png"); + assert_eq!( + store.attachment_bytes(receipts[0].id).unwrap(), + b"fake-png-bytes" + ); + // Full verification passes. + store.verify().unwrap(); + assert_eq!(store.verify_blobs().unwrap(), 1); +} + +#[test] +fn journal_tamper_is_detected() { + let dir = test_dir("tamper"); + let store = build_books(&dir); + drop(store); + + // Flip a byte in the middle of the journal. + let journal_path = dir.join(ledger_store::JOURNAL_FILE); + let mut bytes = std::fs::read(&journal_path).unwrap(); + let mid = bytes.len() / 2; + bytes[mid] = if bytes[mid] == b'X' { b'Y' } else { b'X' }; + std::fs::write(&journal_path, &bytes).unwrap(); + + let err = match Store::open(&dir) { + Ok(_) => panic!("expected the tampered journal to be rejected"), + Err(e) => e, + }; + assert!( + format!("{err:?}").contains("JournalCorrupt"), + "expected JournalCorrupt, got {err:?}" + ); +} + +#[test] +fn torn_tail_is_truncatable() { + let dir = test_dir("torntail"); + let store = build_books(&dir); + let seq_before = { + let w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap(); + w.next_seq() - 1 + }; + drop(store); + + // Simulate a torn backup: append half a line. + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(dir.join(ledger_store::JOURNAL_FILE)) + .unwrap(); + f.write_all(b"{\"seq\": 999, \"trunca").unwrap(); + f.sync_all().unwrap(); + drop(f); + + // Strict open now fails... + assert!(Store::open(&dir).is_err()); + // ...but truncation recovers to the last good seq. + let last_good = + ledger_store::journal::truncate_to_last_good_seq(&dir.join(ledger_store::JOURNAL_FILE)) + .unwrap(); + assert_eq!(last_good, seq_before); + let store = Store::open(&dir).unwrap(); + store.verify().unwrap(); +} + +#[test] +fn rebuild_heals_a_lost_database() { + let dir = test_dir("rebuild"); + let mut store = build_books(&dir); + let tb_before = ledger_core::reports::trial_balance(store.ledger()); + // Simulate total SQLite loss. + store.rebuild().unwrap(); + let tb_after = ledger_core::reports::trial_balance(store.ledger()); + assert_eq!(tb_before.total_debits, tb_after.total_debits); + assert_eq!(tb_before.total_credits, tb_after.total_credits); + store.verify().unwrap(); + // Attachments survived (metadata re-applied from the journal). + assert_eq!(store.list_attachments(None).unwrap().len(), 1); +} + +#[test] +fn missing_database_heals_on_open() { + let dir = test_dir("healopen"); + let store = build_books(&dir); + drop(store); + std::fs::remove_file(dir.join(ledger_store::DB_FILE)).unwrap(); + // No database at all: open rebuilds the projection from the journal. + let store = Store::open(&dir).unwrap(); + let tb = ledger_core::reports::trial_balance(store.ledger()); + assert!(tb.balanced()); + store.verify().unwrap(); +} + +#[test] +fn blob_dedup_and_hash_verification() { + let dir = test_dir("blobs"); + let mut store = Store::create(&dir, Currency::USD).unwrap(); + let bytes = b"same bytes twice"; + let (_, sha1, dedup1) = store + .file_document("tax", "w2.pdf", "application/pdf", bytes, None) + .unwrap(); + let (_, sha2, dedup2) = store + .file_document("tax", "w2-copy.pdf", "application/pdf", bytes, None) + .unwrap(); + assert!(!dedup1); + assert!(dedup2); + assert_eq!(sha1, sha2); + // Only one blob row; two attachment rows. + assert_eq!(store.verify_blobs().unwrap(), 1); + assert_eq!(store.list_attachments(None).unwrap().len(), 2); + + // Corrupt the blob file on disk → the next read detects it. + let shard = dir + .join("blobs") + .join(&sha1[0..2]) + .join(&sha1[2..4]) + .join(&sha1); + let mut raw = std::fs::read(&shard).unwrap(); + raw[0] ^= 0xff; + std::fs::write(&shard, &raw).unwrap(); + let err = match store.verify_blobs() { + Ok(_) => panic!("expected the corrupted blob to be detected"), + Err(e) => e, + }; + assert!(format!("{err:?}").contains("CorruptBlob")); +} + +#[test] +fn unbalanced_entry_never_reaches_the_journal() { + let dir = test_dir("unbalanced"); + let mut store = Store::create(&dir, Currency::USD).unwrap(); + let cash = store + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let equity = store + .add_account("3000", "Owner's Equity", AccountType::Equity, None, false) + .unwrap(); + let seq_before = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)) + .unwrap() + .next_seq(); + + let e = store + .create_entry(NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(), "Bad") + .unwrap(); + store.add_line(e, cash, Side::Debit, usd(100)).unwrap(); + store.add_line(e, equity, Side::Credit, usd(99)).unwrap(); + assert!(store.post_entry(e).is_err()); + + // The failed post journaled nothing: only entry.created was added. + let seq_after = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)) + .unwrap() + .next_seq(); + assert_eq!(seq_after, seq_before + 1); + // And a fresh open sees a clean, balanced book. + drop(store); + let store = Store::open(&dir).unwrap(); + store.verify().unwrap(); +}