Files
wanderbooks/crates/ledger-store/tests/snapshot.rs
T
swaitsandClaude Opus 5.5 a833229d87 tests: every test dir is removed when its test ends, pass or panic
Many test helpers removed their temp dir only before the test, or only
on pass, so runs left them behind: 117,000 filled /tmp (RAM here) and
stopped a mutation run. Each now holds a guard that removes the dir on
drop, unwind included; servers started on a test dir hold its guard
until they stop. A full cargo test --workspace leaves 0 dirs (859
before). just sweep-test-tmp stays for runs killed mid-test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 20:47:44 -06:00

210 lines
6.8 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));
}