Files
wanderbooks/crates/ledger-store/tests/snapshot.rs
T
swaitsandClaude Opus 5.5 3a9aa92315 P3: the books' format moves only with the owner's go-ahead (D-39)
Books an update could write a newer format for open on their old format
and are asked at every open; anything needing a new feature is refused
until the owner agrees with the passphrase, after a verified backup that
includes the keys. New books start on the current format. Each projection
schema has its own database file, so an older version still opens the
books until the owner agrees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 14:04:11 -06:00

210 lines
6.9 KiB
Rust

//! Snapshot: a read-only view that follows a live writer.
use chrono::NaiveDate;
use ledger_core::reports::snapshot;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::{JOURNAL_FILE, Snapshot, Store};
use rust_decimal::Decimal;
use std::io::Write;
use std::path::PathBuf;
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(PathBuf);
impl std::ops::Deref for TestDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::path::Path> for TestDir {
fn as_ref(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn test_dir(name: &str) -> TestDir {
let dir = std::env::temp_dir().join(format!("wanderbooks-snap-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn usd(n: i64) -> Money {
Money::new(Decimal::from(n), Currency::USD)
}
fn post(store: &mut Store, day: u32, dr: u64, cr: u64, amount: i64) -> u64 {
let d = NaiveDate::from_ymd_opt(2026, 10, day).unwrap();
let e = store.create_entry(d, "x").unwrap();
store.add_line(e, dr, Side::Debit, usd(amount)).unwrap();
store.add_line(e, cr, Side::Credit, usd(amount)).unwrap();
store.post_entry(e).unwrap();
e
}
#[test]
fn a_snapshot_follows_the_writer_and_never_writes() {
let dir = test_dir("follow");
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", "Equity", AccountType::Equity, None, false)
.unwrap();
let e = post(&mut store, 1, cash, equity, 1000);
store
.attach_receipt(Some(e), "deposit.png", "image/png", b"png-bytes", None)
.unwrap();
let listing = |dir: &std::path::Path| {
let mut v: Vec<_> = walk(dir);
v.sort();
v
};
let before = listing(&dir);
let mut snap = Snapshot::load(&dir).unwrap();
assert_eq!(listing(&dir), before, "loading writes nothing");
assert_eq!(snap.journal_seq(), store.journal_seq());
assert_eq!(
snapshot(snap.ledger()).unwrap(),
snapshot(store.ledger()).unwrap()
);
let atts = snap.attachments(Some(e)).unwrap();
assert_eq!(atts.len(), 1);
assert_eq!(snap.attachment_bytes(atts[0].id).unwrap(), b"png-bytes");
// The writer keeps going; refresh picks up exactly the new lines.
assert_eq!(snap.refresh().unwrap(), 0);
let seq = store.journal_seq();
post(&mut store, 2, cash, equity, 250);
let added = snap.refresh().unwrap();
assert_eq!(added as u64, store.journal_seq() - seq);
assert_eq!(
snapshot(snap.ledger()).unwrap(),
snapshot(store.ledger()).unwrap()
);
}
#[test]
fn a_half_written_line_waits_for_the_next_refresh() {
let dir = test_dir("torn");
let mut store = Store::create(&dir, Currency::USD).unwrap();
store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
drop(store);
let mut snap = Snapshot::load(&dir).unwrap();
let seq = snap.journal_seq();
// Simulate the writer mid-append: half a line, no newline yet.
let journal = dir.join(JOURNAL_FILE);
let full = std::fs::read(&journal).unwrap();
let mut store = Store::open(&dir).unwrap();
store
.add_account("3000", "Equity", AccountType::Equity, None, false)
.unwrap();
drop(store);
let grown = std::fs::read(&journal).unwrap();
let new_line = &grown[full.len()..];
std::fs::write(&journal, &full).unwrap();
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&journal)
.unwrap();
f.write_all(&new_line[..new_line.len() / 2]).unwrap();
f.flush().unwrap();
assert_eq!(snap.refresh().unwrap(), 0, "torn tail is not corruption");
f.write_all(&new_line[new_line.len() / 2..]).unwrap();
f.flush().unwrap();
assert_eq!(snap.refresh().unwrap(), 1);
assert_eq!(snap.journal_seq(), seq + 1);
assert!(snap.ledger().chart().find_by_code("3000").is_some());
}
#[test]
fn a_rewritten_journal_reloads() {
let dir = test_dir("rewrite");
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", "Equity", AccountType::Equity, None, false)
.unwrap();
let journal = dir.join(JOURNAL_FILE);
let early = std::fs::read(&journal).unwrap();
post(&mut store, 1, cash, equity, 10);
drop(store);
let mut snap = Snapshot::load(&dir).unwrap();
let later = snap.journal_seq();
// Restored from an earlier backup: shorter file.
std::fs::write(&journal, &early).unwrap();
snap.refresh().unwrap();
assert!(snap.journal_seq() < later);
assert!(snap.ledger().posted_entries().next().is_none());
}
fn walk(dir: &std::path::Path) -> Vec<(String, u64)> {
let mut out = Vec::new();
for e in std::fs::read_dir(dir).unwrap() {
let e = e.unwrap();
let m = e.metadata().unwrap();
if m.is_dir() {
out.extend(walk(&e.path()));
} else {
out.push((e.path().display().to_string(), m.len()));
}
}
out
}
#[test]
fn a_snapshot_checks_the_writers_database_and_files() {
use ledger_store::DiskCheck;
let dir = test_dir("disk");
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", "Equity", AccountType::Equity, None, false)
.unwrap();
let e = post(&mut store, 1, cash, equity, 100);
store
.attach_receipt(Some(e), "r.png", "image/png", b"receipt-bytes", None)
.unwrap();
drop(store);
let snap = Snapshot::load(&dir).unwrap();
assert_eq!(snap.check_disk(), DiskCheck::Matches);
assert_eq!(snap.verify_blobs(), (1, vec![]));
// Someone edits the database behind the journal's back.
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
conn.execute(
"UPDATE accounts SET name = 'Petty cash' WHERE code = '1000'",
[],
)
.unwrap();
drop(conn);
match snap.check_disk() {
DiskCheck::Differs(why) => assert!(why.contains("accounts"), "{why}"),
other => panic!("drift not seen: {other:?}"),
}
// A damaged file is caught too.
let blob = walk(&dir.join("blobs"))
.into_iter()
.map(|(p, _)| p)
.find(|p| !p.ends_with(".tmp"))
.unwrap();
std::fs::write(&blob, b"tampered").unwrap();
let (ok, bad) = snap.verify_blobs();
assert_eq!((ok, bad.len()), (0, 1));
}