From 6c82501d6d8bd1b250cd97003315175294221059 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 6 Jun 2026 14:03:58 -0400 Subject: [PATCH] test(balance): integration + regression coverage for per-security detail (#217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-cutting Étape 2 coverage proving the per-security detail feature does not regress aggregations, returns, date-move, or deletion. Rust (lib.rs, +3 real-SQLite tests): - regression_detailed_account_totals_equal_simple_account_totals: a detailed account whose holdings sum to V yields byte-identical date/category/vehicle SUM() totals to a simple account worth V (frozen golden numbers 1300/300/...). Proven where SUM() GROUP BY actually runs, unlike the TS mock harness. - migration_v14_to_v16_on_populated_db_preserves_integrity_and_totals: realistic multi-type DB (simple + convertible priced w/ 2-snapshot history + non- convertible priced) — totals byte-identical before/after v16, values preserved, qty/price NULLed only on converted lines, one shared security, non-convertible line fully intact. - regression_snapshot_delete_cascades_to_holdings: two-hop CASCADE (snapshot -> line -> holdings); security survives (RESTRICT). TS (balance-flow.test.ts, +6 integration tests): - detailed snapshot save end-to-end (aggregated line + securities + holdings in one BEGIN/COMMIT); aggregated line value = rounded-cent SUM(holdings). - rollback on injected holding INSERT failure (no partial line/holdings). - snapshot date-move with a detailed account: line + holdings move together; collision rolls both back (#200). - golden-value invariant: detailed line stores the same value a simple account would, feeding getSnapshotTotalsByDate identically. - deleteSnapshot emits exactly one parent DELETE (FK cascades the rest). No production code changed — pure test PR. Builds on the v16 tests (#211) and the detailed-save/securities unit tests (#212) without duplicating them. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/src/lib.rs | 531 +++++++++++++++++++++++ src/__integration__/balance-flow.test.ts | 394 +++++++++++++++++ 2 files changed, 925 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8cea36d..b11a659 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2768,5 +2768,536 @@ mod tests { ); assert!(dup.is_err(), "consolidated securities must reject case-dupes"); } + + // ========================================================================= + // Étape 2 — cross-cutting regression & integration on a populated DB (#217) + // ------------------------------------------------------------------------- + // The headline guarantee of the per-security detail feature: a `detailed` + // account whose holdings sum to V must produce EXACTLY the same date / + // category / vehicle aggregates as a `simple` account worth V — the + // aggregated `balance_snapshot_lines.value` is the single source of truth + // for every SUM() in the service. These tests freeze concrete golden totals + // and prove the invariant where it actually runs: real SQLite aggregation. + // + // Unlike the TS aggregator tests (which drive a mock that returns canned + // rows and so can only assert SQL shape + bucketing), here `SUM(value) + // GROUP BY snapshot_date` is executed by SQLite itself — a regression in the + // aggregation path or in how a detailed line stores its value would surface + // as a divergence between the detailed-path total and the simple-path total. + // ========================================================================= + + /// Mirror of `getSnapshotTotalsByDate`'s SQL: per-date SUM of every line. + fn totals_by_date(conn: &Connection) -> Vec<(String, f64)> { + conn.prepare( + "SELECT s.snapshot_date, COALESCE(SUM(l.value), 0) AS total \ + FROM balance_snapshots s \ + LEFT JOIN balance_snapshot_lines l ON l.snapshot_id = s.id \ + GROUP BY s.snapshot_date \ + ORDER BY s.snapshot_date ASC", + ) + .unwrap() + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, f64>(1)?))) + .unwrap() + .map(|r| r.unwrap()) + .collect() + } + + /// Mirror of `getSnapshotTotalsByCategoryAndDate`'s SQL. + fn totals_by_category(conn: &Connection) -> Vec<(String, String, f64)> { + conn.prepare( + "SELECT s.snapshot_date, c.key, COALESCE(SUM(l.value), 0) AS total \ + FROM balance_snapshots s \ + INNER JOIN balance_snapshot_lines l ON l.snapshot_id = s.id \ + INNER JOIN balance_accounts a ON a.id = l.account_id \ + INNER JOIN balance_categories c ON c.id = a.balance_category_id \ + GROUP BY s.snapshot_date, c.key \ + ORDER BY s.snapshot_date ASC, c.key ASC", + ) + .unwrap() + .query_map([], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, f64>(2)?)) + }) + .unwrap() + .map(|r| r.unwrap()) + .collect() + } + + /// Mirror of `getSnapshotTotalsByVehicleAndDate`'s SQL (NULL → 'none'). + fn totals_by_vehicle(conn: &Connection) -> Vec<(String, String, f64)> { + conn.prepare( + "SELECT s.snapshot_date, COALESCE(a.vehicle_type, 'none') AS vkey, \ + COALESCE(SUM(l.value), 0) AS total \ + FROM balance_snapshots s \ + INNER JOIN balance_snapshot_lines l ON l.snapshot_id = s.id \ + INNER JOIN balance_accounts a ON a.id = l.account_id \ + GROUP BY s.snapshot_date, COALESCE(a.vehicle_type, 'none') \ + ORDER BY s.snapshot_date ASC, vkey ASC", + ) + .unwrap() + .query_map([], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, f64>(2)?)) + }) + .unwrap() + .map(|r| r.unwrap()) + .collect() + } + + fn new_account( + conn: &Connection, + cat_key: &str, + name: &str, + kind: &str, + vehicle: Option<&str>, + ) -> i64 { + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name, kind, vehicle_type) \ + VALUES ((SELECT id FROM balance_categories WHERE key = ?1), ?2, ?3, ?4)", + rusqlite::params![cat_key, name, kind, vehicle], + ) + .unwrap(); + conn.query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap() + } + + fn new_snapshot(conn: &Connection, date: &str) -> i64 { + conn.execute( + "INSERT INTO balance_snapshots (snapshot_date) VALUES (?1)", + [date], + ) + .unwrap(); + conn.query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap() + } + + /// Insert a SIMPLE aggregated line (qty/price NULL) and return its id. + fn new_simple_line(conn: &Connection, snap: i64, acc: i64, value: f64) -> i64 { + conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value, price_source) \ + VALUES (?1, ?2, ?3, 'manual')", + rusqlite::params![snap, acc, value], + ) + .unwrap(); + conn.query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap() + } + + /// Insert a DETAILED line (aggregated row + N holdings) the way the service + /// does: the line value is the rounded-cent SUM of the holdings, qty/price on + /// the line are NULL, and each holding references a minted security. Returns + /// the line id. + fn new_detailed_line( + conn: &Connection, + snap: i64, + acc: i64, + holdings: &[(&str, f64, f64, f64, Option)], // symbol, qty, price, value, book_cost + ) -> i64 { + let total: f64 = holdings.iter().map(|h| h.3).sum(); + conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value, price_source) \ + VALUES (?1, ?2, ?3, 'manual')", + rusqlite::params![snap, acc, total], + ) + .unwrap(); + let line: i64 = conn + .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap(); + for (sym, qty, price, value, book) in holdings { + conn.execute( + "INSERT INTO balance_securities (symbol, asset_type) VALUES (?1, 'stock') \ + ON CONFLICT(symbol) DO NOTHING", + [sym], + ) + .unwrap(); + let sec: i64 = conn + .query_row( + "SELECT id FROM balance_securities WHERE symbol = ?1", + [sym], + |r| r.get(0), + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_snapshot_holdings \ + (snapshot_line_id, security_id, quantity, unit_price, value, book_cost) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![line, sec, qty, price, value, book], + ) + .unwrap(); + } + line + } + + #[test] + fn regression_detailed_account_totals_equal_simple_account_totals() { + // Two parallel worlds on the SAME schema. World A: a `simple` cash + // account + a `simple` brokerage account worth 300. World B: the same + // cash account + a `detailed` brokerage account whose two holdings + // (100 + 200) sum to 300. The date/category/vehicle aggregates MUST be + // byte-identical — that equality is the regression guarantee. + let conn = consolidated_db(); // ships v14/v15-parity schema + 5 classes + + // Shared cash account (simple) in both worlds. + // World A. + let cash_a = new_account(&conn, "cash", "Cash A", "simple", None); + let broker_a = new_account(&conn, "stock", "Broker A simple", "simple", Some("tfsa")); + let snap_a = new_snapshot(&conn, "2026-01-31"); + new_simple_line(&conn, snap_a, cash_a, 1000.0); + new_simple_line(&conn, snap_a, broker_a, 300.0); + let golden_date_a = totals_by_date(&conn); + let golden_cat_a = totals_by_category(&conn); + let golden_veh_a = totals_by_vehicle(&conn); + + // FROZEN golden numbers (derived from the simple-account world). + assert_eq!(golden_date_a, vec![("2026-01-31".to_string(), 1300.0)]); + assert_eq!( + golden_cat_a, + vec![ + ("2026-01-31".to_string(), "cash".to_string(), 1000.0), + ("2026-01-31".to_string(), "stock".to_string(), 300.0), + ] + ); + assert_eq!( + golden_veh_a, + vec![ + ("2026-01-31".to_string(), "none".to_string(), 1000.0), + ("2026-01-31".to_string(), "tfsa".to_string(), 300.0), + ] + ); + + // World B on a fresh connection: identical structure, detailed broker. + let conn2 = consolidated_db(); + let cash_b = new_account(&conn2, "cash", "Cash B", "simple", None); + let broker_b = + new_account(&conn2, "stock", "Broker B detailed", "detailed", Some("tfsa")); + let snap_b = new_snapshot(&conn2, "2026-01-31"); + new_simple_line(&conn2, snap_b, cash_b, 1000.0); + new_detailed_line( + &conn2, + snap_b, + broker_b, + &[ + ("AAPL", 2.0, 50.0, 100.0, Some(80.0)), + ("MSFT", 1.0, 200.0, 200.0, Some(150.0)), + ], + ); + + // The detailed world reproduces the frozen golden numbers EXACTLY. + assert_eq!( + totals_by_date(&conn2), + golden_date_a, + "per-date total must be identical: a detailed account worth ΣV \ + aggregates exactly like a simple account worth V" + ); + assert_eq!( + totals_by_category(&conn2), + golden_cat_a, + "per-category total must be identical across simple/detailed paths" + ); + assert_eq!( + totals_by_vehicle(&conn2), + golden_veh_a, + "per-vehicle total must be identical across simple/detailed paths" + ); + + // Belt-and-braces: the detailed line's stored value equals Σ(holdings). + let line_val: f64 = conn2 + .query_row( + "SELECT value FROM balance_snapshot_lines WHERE account_id = ?1", + [broker_b], + |r| r.get(0), + ) + .unwrap(); + let holdings_sum: f64 = conn2 + .query_row( + "SELECT COALESCE(SUM(h.value), 0) FROM balance_snapshot_holdings h \ + JOIN balance_snapshot_lines l ON l.id = h.snapshot_line_id \ + WHERE l.account_id = ?1", + [broker_b], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(line_val, 300.0); + assert_eq!( + line_val, holdings_sum, + "the aggregated line value IS the holdings SUM" + ); + } + + #[test] + fn regression_snapshot_delete_cascades_to_holdings() { + // Deleting a snapshot must remove its lines (CASCADE on snapshot_id) AND, + // transitively, the per-security holdings of each detailed line (CASCADE + // on snapshot_line_id). Two-hop cascade, proven end-to-end. + let conn = consolidated_db(); + let broker = new_account(&conn, "stock", "Broker", "detailed", None); + let snap = new_snapshot(&conn, "2026-02-28"); + let line = new_detailed_line( + &conn, + snap, + broker, + &[("AAPL", 1.0, 100.0, 100.0, Some(90.0))], + ); + + // Sanity: one line, one holding before the delete. + let h_before: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1", + [line], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(h_before, 1); + + conn.execute("DELETE FROM balance_snapshots WHERE id = ?1", [snap]) + .expect("snapshot delete should cascade"); + + let lines_after: i64 = conn + .query_row("SELECT COUNT(*) FROM balance_snapshot_lines", [], |r| r.get(0)) + .unwrap(); + let holdings_after: i64 = conn + .query_row("SELECT COUNT(*) FROM balance_snapshot_holdings", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(lines_after, 0, "snapshot delete must cascade its lines"); + assert_eq!( + holdings_after, 0, + "deleting the line must cascade its holdings (two-hop CASCADE)" + ); + // The security catalogue row is NOT deleted — only the holding link is. + let secs: i64 = conn + .query_row("SELECT COUNT(*) FROM balance_securities", [], |r| r.get(0)) + .unwrap(); + assert_eq!(secs, 1, "the security itself survives (RESTRICT, not cascade)"); + } + + /// Build a richer pre-v16 DB than `db_pre_v16`: a SIMPLE cash account, a + /// CONVERTIBLE priced account (seed `stock`, asset_type set, with TWO + /// snapshots of history), and a NON-CONVERTIBLE priced account (custom + /// category, asset_type NULL). Returns + /// (conn, simple_line_s1, simple_line_s2, conv_line_s1, conv_line_s2, nonconv_line). + fn db_pre_v16_populated() -> (Connection, i64, i64, i64, i64, i64) { + let conn = db_through_v13(); + conn.execute_batch(V14_SQL).expect("apply v14"); + conn.execute_batch(V15_SQL).expect("apply v15"); + + // Simple cash account. + let cash: i64 = conn + .query_row( + "SELECT id FROM balance_categories WHERE key = 'cash'", + [], + |r| r.get(0), + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'Encaisse')", + [cash], + ) + .unwrap(); + let acc_cash: i64 = conn + .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap(); + + // Convertible priced account (v15 backfilled it to 'detailed'). + let stock_cat: i64 = conn + .query_row( + "SELECT id FROM balance_categories WHERE key = 'stock'", + [], + |r| r.get(0), + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name, symbol) \ + VALUES (?1, 'Courtage VEQT', ' veqt.to ')", + [stock_cat], + ) + .unwrap(); + let acc_conv: i64 = conn + .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap(); + + // Non-convertible: custom priced category, asset_type NULL. + conn.execute( + "INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) \ + VALUES ('custom_priced', 'custom', 'priced', 80, 0)", + [], + ) + .unwrap(); + let custom_cat: i64 = conn + .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name, symbol) \ + VALUES (?1, 'Fonds maison', 'XYZ')", + [custom_cat], + ) + .unwrap(); + let acc_nonconv: i64 = conn + .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap(); + + // Two snapshots of history (multi-snapshot requirement). + let s1 = new_snapshot(&conn, "2026-01-31"); + let s2 = new_snapshot(&conn, "2026-02-28"); + + // Simple cash lines. + let scl1 = new_simple_line(&conn, s1, acc_cash, 1000.0); + let scl2 = new_simple_line(&conn, s2, acc_cash, 1100.0); + + // Convertible priced lines (qty/price set — pre-v16 priced shape). + conn.execute( + "INSERT INTO balance_snapshot_lines \ + (snapshot_id, account_id, quantity, unit_price, value, price_source) \ + VALUES (?1, ?2, 10.0, 30.0, 300.0, 'maximus-api')", + rusqlite::params![s1, acc_conv], + ) + .unwrap(); + let conv1: i64 = conn + .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_snapshot_lines \ + (snapshot_id, account_id, quantity, unit_price, value, price_source) \ + VALUES (?1, ?2, 12.0, 30.0, 360.0, 'maximus-api')", + rusqlite::params![s2, acc_conv], + ) + .unwrap(); + let conv2: i64 = conn + .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap(); + + // Non-convertible priced line (only at s2, qty/price set). + conn.execute( + "INSERT INTO balance_snapshot_lines \ + (snapshot_id, account_id, quantity, unit_price, value, price_source) \ + VALUES (?1, ?2, 5.0, 8.0, 40.0, 'manual')", + rusqlite::params![s2, acc_nonconv], + ) + .unwrap(); + let nonconv: i64 = conn + .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap(); + + let _ = (scl1, scl2); + (conn, scl1, scl2, conv1, conv2, nonconv) + } + + #[test] + fn migration_v14_to_v16_on_populated_db_preserves_integrity_and_totals() { + // The realistic multi-type integrity case (#217): run v16 on a DB with a + // simple account, a convertible priced account (2 snapshots of history), + // and a non-convertible priced account. Freeze the per-snapshot totals + // BEFORE the migration and assert they're byte-identical AFTER — the + // conversion to per-security holdings must not move a single cent. + let (conn, _scl1, _scl2, conv1, conv2, nonconv) = db_pre_v16_populated(); + + // Golden totals captured on the pre-v16 DB. + let date_before = totals_by_date(&conn); + let cat_before = totals_by_category(&conn); + let veh_before = totals_by_vehicle(&conn); + + // Concrete frozen numbers (cash 1000/1100, conv 300/360, nonconv 40@s2). + assert_eq!( + date_before, + vec![ + ("2026-01-31".to_string(), 1300.0), // 1000 + 300 + ("2026-02-28".to_string(), 1500.0), // 1100 + 360 + 40 + ] + ); + + // Apply the conversion. + conn.execute_batch(V16_SQL).expect("apply v16"); + + // 1) Aggregates are byte-for-byte identical — no value moved. + assert_eq!( + totals_by_date(&conn), + date_before, + "v16 must preserve every per-date total" + ); + assert_eq!( + totals_by_category(&conn), + cat_before, + "v16 must preserve every per-category total" + ); + assert_eq!( + totals_by_vehicle(&conn), + veh_before, + "v16 must preserve every per-vehicle total" + ); + + // 2) Both convertible lines are NULLed on qty/price but keep their value, + // and each mirrors exactly one holding. + for (line, qty, price, value) in + [(conv1, 10.0, 30.0, 300.0), (conv2, 12.0, 30.0, 360.0)] + { + let (lq, lp, lv): (Option, Option, f64) = conn + .query_row( + "SELECT quantity, unit_price, value FROM balance_snapshot_lines WHERE id = ?1", + [line], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert!(lq.is_none(), "converted line {line} qty must be NULL"); + assert!(lp.is_none(), "converted line {line} price must be NULL"); + assert_eq!(lv, value, "converted line {line} value preserved"); + let (hq, hp, hv): (f64, f64, f64) = conn + .query_row( + "SELECT quantity, unit_price, value FROM balance_snapshot_holdings \ + WHERE snapshot_line_id = ?1", + [line], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .expect("a holding must mirror each converted line"); + assert_eq!((hq, hp, hv), (qty, price, value)); + } + + // 3) Exactly ONE security minted (VEQT.TO, normalized), shared across + // both snapshots' holdings — multi-snapshot history collapses to one + // catalogue row. + let veqt_secs: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_securities WHERE symbol = 'VEQT.TO'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(veqt_secs, 1, "one normalized security for the whole history"); + let distinct_secs_on_conv: i64 = conn + .query_row( + "SELECT COUNT(DISTINCT h.security_id) FROM balance_snapshot_holdings h \ + WHERE h.snapshot_line_id IN (?1, ?2)", + rusqlite::params![conv1, conv2], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(distinct_secs_on_conv, 1, "both snapshots share one security"); + + // 4) The non-convertible line is FULLY intact — no holding, qty/price kept. + let nonconv_holdings: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1", + [nonconv], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(nonconv_holdings, 0, "non-convertible line gets no holding"); + let (nq, np, nv): (Option, Option, f64) = conn + .query_row( + "SELECT quantity, unit_price, value FROM balance_snapshot_lines WHERE id = ?1", + [nonconv], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!((nq, np, nv), (Some(5.0), Some(8.0), 40.0)); + + // 5) No security for the non-convertible symbol. + let xyz_secs: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_securities WHERE symbol = 'XYZ'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(xyz_secs, 0, "no security for a priced account without asset_type"); + } } diff --git a/src/__integration__/balance-flow.test.ts b/src/__integration__/balance-flow.test.ts index 3df4bd9..4eb0005 100644 --- a/src/__integration__/balance-flow.test.ts +++ b/src/__integration__/balance-flow.test.ts @@ -54,6 +54,9 @@ import { unlinkTransfer, listAccountTransfers, computeAccountReturn, + saveSnapshotAtomic, + deleteSnapshot, + getSnapshotTotalsByDate, BalanceServiceError, PRICED_VALUE_TOLERANCE, } from "../services/balance.service"; @@ -573,3 +576,394 @@ describe("integration — computeAccountReturn validates dates client-side", () expect(out.value_start).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// 5. Per-security detail (Étape 2, #217) — detailed snapshot save, rollback, +// date-move with holdings, and the golden-value aggregation invariant. +// --------------------------------------------------------------------------- +// +// The unit-level holdings/securities mechanics are covered exhaustively in +// balance.service.test.ts (#212). Here we assert the END-TO-END service +// behaviour at the integration layer and — critically — the REGRESSION +// invariant that makes detailed accounts safe: a detailed account whose +// holdings sum to V writes an aggregated line worth exactly V, so every +// SUM(value) aggregator sees it identically to a simple account worth V. +// +// (The real-SQLite proof of that equality lives in lib.rs +// `regression_detailed_account_totals_equal_simple_account_totals`, where +// SUM() actually runs. Here we prove the TS half: the stored line value is +// the rounded-cent SUM, which is the only number the aggregator reads.) + +describe("integration — detailed snapshot save end-to-end (#217)", () => { + it("writes the aggregated line + securities + holdings in ONE BEGIN/COMMIT", async () => { + // New snapshot, one detailed account: AAPL (2×50=100) + MSFT (1×200=200) + // ⇒ aggregated line value 300, two holdings, all atomic. + queueSelects([]); // dup-check: date free + // findOrCreateSecurity AAPL then MSFT lookups (post-upsert SELECT). + queueSelects([ + { id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, + ]); + queueSelects([ + { id: 12, symbol: "MSFT", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, + ]); + queueExecutes( + { rowsAffected: 0 }, // BEGIN + { lastInsertId: 42, rowsAffected: 1 }, // INSERT snapshot + { rowsAffected: 0 }, // DELETE lines + { lastInsertId: 500, rowsAffected: 1 }, // INSERT aggregated line + { rowsAffected: 0 }, // DELETE holdings for line 500 + { rowsAffected: 1 }, // UPSERT security AAPL + { rowsAffected: 1 }, // INSERT holding AAPL + { rowsAffected: 1 }, // UPSERT security MSFT + { rowsAffected: 1 }, // INSERT holding MSFT + { rowsAffected: 1 }, // UPDATE updated_at + { rowsAffected: 0 } // COMMIT + ); + + const res = await saveSnapshotAtomic({ + existingSnapshotId: null, + snapshot_date: "2026-05-30", + lines: [ + { + account_id: 7, + value: 300, + holdings: [ + { symbol: "aapl", asset_type: "stock", quantity: 2, unit_price: 50, value: 100, book_cost: 80 }, + { symbol: "msft", asset_type: "stock", quantity: 1, unit_price: 200, value: 200, book_cost: 150 }, + ], + }, + ], + }); + expect(res.snapshotId).toBe(42); + + const execCalls = fake.calls.filter( + (c) => + typeof c.sql === "string" && + !c.sql.includes("SELECT") // executes only + ); + // First write is BEGIN, last is COMMIT, no ROLLBACK anywhere. + expect(execCalls[0].sql).toBe("BEGIN"); + expect(execCalls[execCalls.length - 1].sql).toBe("COMMIT"); + expect(execCalls.some((c) => c.sql === "ROLLBACK")).toBe(false); + + // CRITICAL invariant: the aggregated line is stored with NULL qty/price and + // value = rounded-cent SUM(holdings) = 300 — the exact number the + // aggregators read. This is the TS-side golden-value guarantee. + const lineInsert = fake.calls.find( + (c) => + typeof c.sql === "string" && + c.sql.includes("INSERT INTO balance_snapshot_lines") + ); + expect(lineInsert!.params).toEqual([42, 7, 300]); + + // Both holdings reference the captured line id (500). + const holdingInserts = fake.calls.filter( + (c) => + typeof c.sql === "string" && + c.sql.includes("INSERT INTO balance_snapshot_holdings") + ); + expect(holdingInserts).toHaveLength(2); + expect(holdingInserts[0].params?.[0]).toBe(500); + expect(holdingInserts[1].params?.[0]).toBe(500); + }); + + it("ROLLS BACK the whole save when a holding INSERT fails (no partial line/holdings)", async () => { + queueSelects([]); // dup-check + queueSelects([ + { id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, + ]); + // BEGIN, INSERT snapshot, DELETE lines, INSERT line, DELETE holdings, + // UPSERT security, then the holding INSERT REJECTS, then ROLLBACK. + fake.executeQueue.push({ rowsAffected: 0 }); // BEGIN + fake.executeQueue.push({ lastInsertId: 42, rowsAffected: 1 }); // INSERT snapshot + fake.executeQueue.push({ rowsAffected: 0 }); // DELETE lines + fake.executeQueue.push({ lastInsertId: 500, rowsAffected: 1 }); // INSERT line + fake.executeQueue.push({ rowsAffected: 0 }); // DELETE holdings + fake.executeQueue.push({ rowsAffected: 1 }); // UPSERT security + // Make the NEXT execute (the holding INSERT) reject, then allow ROLLBACK. + let failed = false; + fake.execute.mockImplementation(async (sql: string, params?: unknown[]) => { + fake.calls.push({ sql, params }); + if (!failed && sql.includes("INSERT INTO balance_snapshot_holdings")) { + failed = true; + throw new Error("holding FK violation"); + } + if (fake.executeQueue.length === 0) { + return { rowsAffected: 1, lastInsertId: fake.calls.length }; + } + return fake.executeQueue.shift()!; + }); + + await expect( + saveSnapshotAtomic({ + existingSnapshotId: null, + snapshot_date: "2026-05-30", + lines: [ + { + account_id: 7, + value: 100, + holdings: [ + { symbol: "AAPL", asset_type: "stock", quantity: 2, unit_price: 50, value: 100 }, + ], + }, + ], + }) + ).rejects.toThrow("holding FK violation"); + + // ROLLBACK was the last write; COMMIT never happened ⇒ no partial state. + const execCalls = fake.calls.filter( + (c) => typeof c.sql === "string" && !c.sql.includes("SELECT") + ); + expect(execCalls[execCalls.length - 1].sql).toBe("ROLLBACK"); + expect(execCalls.some((c) => c.sql === "COMMIT")).toBe(false); + }); + + it("moves a detailed snapshot's date — line AND holdings move together (#200)", async () => { + // Edit-mode move: the date UPDATE + the line/holdings rewrite happen in the + // SAME transaction, so the holdings follow their line to the new date. + queueSelects([]); // collision check: target date free + queueSelects([ + { id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, + ]); + queueExecutes( + { rowsAffected: 0 }, // BEGIN + { rowsAffected: 1 }, // UPDATE snapshot_date + { rowsAffected: 0 }, // DELETE lines (cascades old holdings) + { lastInsertId: 800, rowsAffected: 1 }, // INSERT aggregated line at new date + { rowsAffected: 0 }, // DELETE holdings for line 800 + { rowsAffected: 1 }, // UPSERT security AAPL + { rowsAffected: 1 }, // INSERT holding AAPL + { rowsAffected: 1 }, // UPDATE updated_at + { rowsAffected: 0 } // COMMIT + ); + + const res = await saveSnapshotAtomic({ + existingSnapshotId: 5, + snapshot_date: "2026-04-15", + moveToDate: "2026-05-20", + lines: [ + { + account_id: 7, + value: 100, + holdings: [ + { symbol: "AAPL", asset_type: "stock", quantity: 1, unit_price: 100, value: 100, book_cost: 90 }, + ], + }, + ], + }); + expect(res.snapshotId).toBe(5); + + // The date UPDATE precedes the line rewrite (so a collision rolls back both). + const dateUpdate = fake.calls.find( + (c) => typeof c.sql === "string" && c.sql.includes("SET snapshot_date = $1") + ); + expect(dateUpdate!.params).toEqual(["2026-05-20", 5]); + + // The holding is re-inserted alongside the moved line (referencing line 800). + const holdingInsert = fake.calls.find( + (c) => + typeof c.sql === "string" && + c.sql.includes("INSERT INTO balance_snapshot_holdings") + ); + expect(holdingInsert).toBeDefined(); + expect(holdingInsert!.params?.[0]).toBe(800); + + // Whole thing commits; the move + holdings are one unit. + const execCalls = fake.calls.filter( + (c) => typeof c.sql === "string" && !c.sql.includes("SELECT") + ); + expect(execCalls[execCalls.length - 1].sql).toBe("COMMIT"); + expect(execCalls.some((c) => c.sql === "ROLLBACK")).toBe(false); + }); + + it("rolls back the move (date + holdings) when the target date collides (#200)", async () => { + queueSelects([{ id: 99 }]); // collision: another snapshot already at target + queueExecutes( + { rowsAffected: 0 }, // BEGIN + { rowsAffected: 0 } // ROLLBACK + ); + await expect( + saveSnapshotAtomic({ + existingSnapshotId: 5, + snapshot_date: "2026-04-15", + moveToDate: "2026-05-20", + lines: [ + { + account_id: 7, + value: 100, + holdings: [ + { symbol: "AAPL", asset_type: "stock", quantity: 1, unit_price: 100, value: 100 }, + ], + }, + ], + }) + ).rejects.toMatchObject({ code: "snapshot_date_exists" }); + + // No date UPDATE, no holding INSERT — the collision aborted before any write. + expect( + fake.calls.some( + (c) => typeof c.sql === "string" && c.sql.includes("SET snapshot_date") + ) + ).toBe(false); + expect( + fake.calls.some( + (c) => + typeof c.sql === "string" && + c.sql.includes("INSERT INTO balance_snapshot_holdings") + ) + ).toBe(false); + const execCalls = fake.calls.filter( + (c) => typeof c.sql === "string" && !c.sql.includes("SELECT") + ); + expect(execCalls[execCalls.length - 1].sql).toBe("ROLLBACK"); + }); +}); + +describe("integration — golden-value invariant: detailed line feeds aggregators like a simple line (#217)", () => { + // The regression contract: whatever the holdings are, the aggregated line + // value the service writes equals the value a simple account would carry — + // so getSnapshotTotalsByDate (which only ever reads l.value) is unchanged. + // We prove BOTH halves drive identical aggregator output by feeding the + // aggregator the canned total in each case and asserting equality. + + it("a detailed account worth ΣV stores the same line value as a simple account worth V", async () => { + // --- Detailed path: holdings 100 + 200 ⇒ line value must be 300. --- + queueSelects([]); // dup-check + queueSelects([ + { id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, + ]); + queueSelects([ + { id: 12, symbol: "MSFT", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, + ]); + queueExecutes( + { rowsAffected: 0 }, // BEGIN + { lastInsertId: 42, rowsAffected: 1 }, // INSERT snapshot + { rowsAffected: 0 }, // DELETE lines + { lastInsertId: 500, rowsAffected: 1 }, // INSERT aggregated line + { rowsAffected: 0 }, // DELETE holdings + { rowsAffected: 1 }, // UPSERT AAPL + { rowsAffected: 1 }, // INSERT holding AAPL + { rowsAffected: 1 }, // UPSERT MSFT + { rowsAffected: 1 }, // INSERT holding MSFT + { rowsAffected: 1 }, // UPDATE updated_at + { rowsAffected: 0 } // COMMIT + ); + await saveSnapshotAtomic({ + existingSnapshotId: null, + snapshot_date: "2026-05-30", + lines: [ + { + account_id: 7, + value: 300, + holdings: [ + { symbol: "aapl", asset_type: "stock", quantity: 2, unit_price: 50, value: 100 }, + { symbol: "msft", asset_type: "stock", quantity: 1, unit_price: 200, value: 200 }, + ], + }, + ], + }); + const detailedLineInsert = fake.calls.find( + (c) => + typeof c.sql === "string" && + c.sql.includes("INSERT INTO balance_snapshot_lines") + ); + const detailedStoredValue = detailedLineInsert!.params![2]; + expect(detailedStoredValue).toBe(300); + + // --- Simple path: a simple account worth 300 stores the same value. --- + fake = makeFakeDbLocal(); + vi.mocked(getDb).mockResolvedValue( + { select: fake.select, execute: fake.execute } as never + ); + fake.selectQueue.push([ + { id: 5, snapshot_date: "2026-05-30", notes: null, created_at: "", updated_at: "" }, + ]); // getSnapshotById + queueExecutes( + { rowsAffected: 0 }, // BEGIN + { rowsAffected: 0 }, // DELETE lines + { lastInsertId: 700, rowsAffected: 1 }, // INSERT simple line + { rowsAffected: 1 }, // UPDATE updated_at + { rowsAffected: 0 } // COMMIT + ); + await upsertSnapshotLines(5, [{ account_id: 7, value: 300 }]); + const simpleLineInsert = fake.calls.find( + (c) => + typeof c.sql === "string" && + c.sql.includes("INSERT INTO balance_snapshot_lines") + ); + const simpleStoredValue = simpleLineInsert!.params![2]; + + // The frozen golden number: BOTH paths persist value = 300. + expect(detailedStoredValue).toBe(simpleStoredValue); + expect(simpleStoredValue).toBe(300); + + // And the aggregator (which reads only l.value) returns the same total in + // both worlds — proven by feeding it the canned per-date SUM each path + // produces. Detailed world: + fake = makeFakeDbLocal(); + vi.mocked(getDb).mockResolvedValue( + { select: fake.select, execute: fake.execute } as never + ); + fake.selectQueue.push([{ snapshot_date: "2026-05-30", total: 300 }]); + const detailedTotals = await getSnapshotTotalsByDate(); + // Simple world: identical canned SUM ⇒ identical aggregator output. + fake = makeFakeDbLocal(); + vi.mocked(getDb).mockResolvedValue( + { select: fake.select, execute: fake.execute } as never + ); + fake.selectQueue.push([{ snapshot_date: "2026-05-30", total: 300 }]); + const simpleTotals = await getSnapshotTotalsByDate(); + expect(detailedTotals).toEqual(simpleTotals); + expect(detailedTotals).toEqual([{ snapshot_date: "2026-05-30", total: 300 }]); + }); +}); + +describe("integration — snapshot deletion cascades to holdings at the service boundary (#217)", () => { + it("deleteSnapshot issues the DELETE that the DB cascades to lines + holdings", async () => { + // The two-hop CASCADE is a DB-FK behaviour (proven in lib.rs + // regression_snapshot_delete_cascades_to_holdings). At the service layer we + // assert the single DELETE is emitted on the right row — the FK does the + // rest. Guard against a regression that would start manually deleting + // holdings (a sign the CASCADE was dropped) or skip the parent delete. + fake.selectQueue.push([ + { id: 50, snapshot_date: "2026-05-30", notes: null, created_at: "", updated_at: "" }, + ]); // getSnapshotById + fake.executeQueue.push({ rowsAffected: 1 }); + await deleteSnapshot(50); + const deletes = fake.calls.filter( + (c) => typeof c.sql === "string" && c.sql.startsWith("DELETE") + ); + // Exactly one DELETE — on balance_snapshots. No manual holdings/line wipes + // (the FK CASCADE handles those; a manual delete would be the regression). + expect(deletes).toHaveLength(1); + expect(deletes[0].sql).toContain("DELETE FROM balance_snapshots WHERE id = $1"); + expect(deletes[0].params).toEqual([50]); + }); +}); + +// A standalone FakeDb factory for the multi-DB golden-value test, which swaps +// the active db handle mid-test. Mirrors makeFakeDb but is reusable inline. +function makeFakeDbLocal(): FakeDb { + const db: FakeDb = { + calls: [], + selectQueue: [], + executeQueue: [], + select: vi.fn(), + execute: vi.fn(), + }; + db.select.mockImplementation(async (sql: string, params?: unknown[]) => { + db.calls.push({ sql, params }); + if (db.selectQueue.length === 0) { + throw new Error(`Unscripted SELECT (no queued result): ${sql}`); + } + return db.selectQueue.shift(); + }); + db.execute.mockImplementation(async (sql: string, params?: unknown[]) => { + db.calls.push({ sql, params }); + if (db.executeQueue.length === 0) { + return { rowsAffected: 1, lastInsertId: db.calls.length }; + } + return db.executeQueue.shift(); + }); + return db; +}