diff --git a/crates/ledger-gui/tests/attachments.rs b/crates/ledger-gui/tests/attachments.rs index cb9ac3b..82d3148 100644 --- a/crates/ledger-gui/tests/attachments.rs +++ b/crates/ledger-gui/tests/attachments.rs @@ -224,12 +224,16 @@ fn reconcile( }; let opening = report(s, month_start.pred_opt().unwrap()); let closing = report(s, through); + // Each call files a new statement (a wrong one is voided and another + // comes), so each has its own bytes: identical bytes would be one + // record (plan P6 step 4), and a record is one statement. + let filing = s.statements(None).unwrap().len() + 1; let (att, _, _) = s .file_document( "statement", &format!("statement-{through}.pdf"), "application/pdf", - format!("statement {through}").as_bytes(), + format!("statement {filing}: {through} for account {account}").as_bytes(), None, ) .unwrap(); diff --git a/crates/ledger-gui/tests/goldens/sidenav-1440x920.png b/crates/ledger-gui/tests/goldens/sidenav-1440x920.png index ae05c57..dad4a45 100644 Binary files a/crates/ledger-gui/tests/goldens/sidenav-1440x920.png and b/crates/ledger-gui/tests/goldens/sidenav-1440x920.png differ diff --git a/crates/ledger-gui/tests/goldens/sidenav-1440x920.shapes b/crates/ledger-gui/tests/goldens/sidenav-1440x920.shapes index cb1dc9c..c5220a6 100644 --- a/crates/ledger-gui/tests/goldens/sidenav-1440x920.shapes +++ b/crates/ledger-gui/tests/goldens/sidenav-1440x920.shapes @@ -1 +1 @@ -5ccd68030ec4a54b +b4c03adfe9dc03e6 diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index e7e1565..f618bfa 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -4488,9 +4488,11 @@ impl Store { /// INVARIANTS: /// 1. The bytes MUST be stored under their SHA-256 and read back /// identical, verified against it. - /// 2. The same bytes filed twice MUST share one stored file, and the - /// second filing MUST say deduplicated. - /// 3. Each filing MUST be its own attachment, journaled + /// 2. The same bytes filed twice MUST be one record (plan P6 step 4): + /// the second filing returns the first's id, says deduplicated, + /// and links that record to the new subject (`attachment.linked`) + /// when it isn't linked there yet. + /// 3. The first filing of bytes MUST be journaled /// (`receipt.attached`), with its name, type, and description. /// SCENARIOS: /// 1. IF `entry_id` names no entry: SHOULD refuse diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index d89f8ae..20e2bff 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -235,6 +235,19 @@ fn damage_before_the_last_newline_still_refuses_to_open() { assert_eq!(std::fs::read(&journal).unwrap(), raw); } +/// Every file and directory under `from`, under `to`. +fn copy_tree(from: &std::path::Path, to: &std::path::Path) { + std::fs::create_dir_all(to).unwrap(); + for e in std::fs::read_dir(from).unwrap() { + let e = e.unwrap(); + if e.file_type().unwrap().is_dir() { + copy_tree(&e.path(), &to.join(e.file_name())); + } else { + std::fs::copy(e.path(), to.join(e.file_name())).unwrap(); + } + } +} + /// Power loss at any byte of the last append: whatever prefix reached the /// disk, the books open with exactly what was acknowledged before it. #[test] @@ -246,14 +259,11 @@ fn power_loss_at_any_byte_of_the_last_append_is_recovered() { let before = ledger_core::reports::snapshot(store.ledger()).unwrap(); drop(store); // The books as they were on disk before the last append (the database - // is never ahead of the journal: the line is written first). + // is never ahead of the journal: the line is written first), blobs + // included: verify reads the receipt's extracted text from them (plan + // P6 step 2). let pre = test_dir("tornevery-pre"); - for e in std::fs::read_dir(&dir).unwrap() { - let e = e.unwrap(); - if e.file_type().unwrap().is_file() { - std::fs::copy(e.path(), pre.join(e.file_name())).unwrap(); - } - } + copy_tree(&dir, &pre); let mut store = Store::open(&dir).unwrap(); store.add_customer("Acme").unwrap(); drop(store); @@ -262,12 +272,7 @@ fn power_loss_at_any_byte_of_the_last_append_is_recovered() { assert_eq!(last.last(), Some(&b'\n')); for cut in 1..last.len() { let case = test_dir(&format!("tornevery-{cut}")); - for e in std::fs::read_dir(&pre).unwrap() { - let e = e.unwrap(); - if e.file_type().unwrap().is_file() { - std::fs::copy(e.path(), case.join(e.file_name())).unwrap(); - } - } + copy_tree(&pre, &case); let mut torn = before_bytes.clone(); torn.extend_from_slice(&last[..cut]); std::fs::write(case.join(ledger_store::JOURNAL_FILE), &torn).unwrap(); @@ -326,9 +331,12 @@ fn blob_dedup_and_hash_verification() { assert!(!dedup1); assert!(dedup2); assert_eq!(sha1, sha2); - // Only one blob row; two attachment rows. + // One blob row and one attachment row: the same bytes filed twice are + // one record (plan P6 step 4), under the first filing's name. assert_eq!(store.verify_blobs().unwrap(), 1); - assert_eq!(store.list_attachments(None).unwrap().len(), 2); + let records = store.list_attachments(None).unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].file_name, "w2.pdf"); // Corrupt the blob file on disk → the next read detects it. let shard = dir @@ -1691,7 +1699,8 @@ mod refusals { // The converse law: an accepted command is on the record, or // changed nothing at all (a batch run with nothing due; a // setting changed to the value it has; a 1099 decision, W-9 - // flag or payment method already so). + // flag or payment method already so; bytes already on file, + // which are the record they were, plan P6 step 4). if result.is_ok() { let after = std::fs::read_to_string(&journal).unwrap(); if after.len() == before.len() { @@ -1711,6 +1720,8 @@ mod refusals { | Op::SetVendor1099(..) | Op::SetW9Flag(..) | Op::SetEntryInstrument(..) + | Op::AttachReceipt(..) + | Op::FileDocument(..) ), "accepted {:?} but journaled nothing", op