Compare commits

...

2 commits

Author SHA1 Message Date
d95d80580c Merge pull request 'test(balance): integration + regression coverage (#217)' (#226) from issue-217-tests into main 2026-06-10 01:08:01 +00:00
le king fu
6c82501d6d test(balance): integration + regression coverage for per-security detail (#217)
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) <noreply@anthropic.com>
2026-06-06 14:03:58 -04:00
2 changed files with 925 additions and 0 deletions

View file

@ -2768,5 +2768,536 @@ mod tests {
); );
assert!(dup.is_err(), "consolidated securities must reject case-dupes"); 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<f64>)], // 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<f64>, Option<f64>, 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<f64>, Option<f64>, 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");
}
} }

View file

@ -54,6 +54,9 @@ import {
unlinkTransfer, unlinkTransfer,
listAccountTransfers, listAccountTransfers,
computeAccountReturn, computeAccountReturn,
saveSnapshotAtomic,
deleteSnapshot,
getSnapshotTotalsByDate,
BalanceServiceError, BalanceServiceError,
PRICED_VALUE_TOLERANCE, PRICED_VALUE_TOLERANCE,
} from "../services/balance.service"; } from "../services/balance.service";
@ -573,3 +576,394 @@ describe("integration — computeAccountReturn validates dates client-side", ()
expect(out.value_start).toBeNull(); 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;
}