Compare commits
2 commits
c8a6f74a1d
...
d95d80580c
| Author | SHA1 | Date | |
|---|---|---|---|
| d95d80580c | |||
|
|
6c82501d6d |
2 changed files with 925 additions and 0 deletions
|
|
@ -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<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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue