Compare commits
2 commits
604b97fc4d
...
4e4e4bd0d2
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e4e4bd0d2 | |||
|
|
90c115e0c0 |
1 changed files with 393 additions and 0 deletions
|
|
@ -250,6 +250,68 @@ pub fn run() {
|
||||||
SELECT id FROM balance_categories WHERE kind = 'priced');",
|
SELECT id FROM balance_categories WHERE kind = 'priced');",
|
||||||
kind: MigrationKind::Up,
|
kind: MigrationKind::Up,
|
||||||
},
|
},
|
||||||
|
// Migration v16 — Bilan détail par titre (Étape 2), data conversion part.
|
||||||
|
// Converts each existing single-security "priced" account into a
|
||||||
|
// `detailed` account holding exactly one position, with zero data loss.
|
||||||
|
//
|
||||||
|
// Step 1 mints a shared, deduped `balance_securities` row per priced
|
||||||
|
// account symbol (normalized UPPER(TRIM), deduped via ON CONFLICT(symbol)
|
||||||
|
// DO NOTHING — the symbol UNIQUE is COLLATE NOCASE so case-variants merge).
|
||||||
|
// Critically it converts ONLY accounts whose category carries a real
|
||||||
|
// `asset_type` (stock|crypto): balance_securities.asset_type is NOT NULL,
|
||||||
|
// and a priced account whose category has asset_type IS NULL has no valid
|
||||||
|
// routing — it must be left intact.
|
||||||
|
//
|
||||||
|
// Step 2 mirrors each existing priced line into one holding
|
||||||
|
// (quantity/unit_price/value/price_source/price_fetched_at copied verbatim;
|
||||||
|
// book_cost stays NULL — no retroactive acquisition cost for historical
|
||||||
|
// lines). UNIQUE(snapshot_line_id, security_id) + ON CONFLICT DO NOTHING
|
||||||
|
// makes a re-run a strict no-op.
|
||||||
|
//
|
||||||
|
// Step 3 collapses the now-redundant per-line qty/unit_price to NULL so a
|
||||||
|
// detailed account's line keeps only its total `value` (the per-title
|
||||||
|
// breakdown lives in holdings). The 🔴 security fix: this NULLing is scoped
|
||||||
|
// to `id IN (SELECT snapshot_line_id FROM balance_snapshot_holdings)` — a
|
||||||
|
// line that never received a holding (priced-without-asset_type) is never
|
||||||
|
// NULLed, so no silent data loss. NULLing BOTH qty+unit_price together
|
||||||
|
// preserves balance_snapshot_lines' (both NULL | both NOT NULL) CHECK.
|
||||||
|
//
|
||||||
|
// The trailing TEMP-table guard asserts the invariant "qty was NULLed ⇒ a
|
||||||
|
// holding exists" and ABORTS (CHECK(ok = 1) on an inserted 0) so any breach
|
||||||
|
// rolls back the whole v16 transaction. Idempotent: ON CONFLICT no-ops on
|
||||||
|
// re-run, and the guard re-passes since every NULLed line still has its
|
||||||
|
// holding.
|
||||||
|
Migration {
|
||||||
|
version: 16,
|
||||||
|
description: "convert existing priced accounts to detailed (one security + mirror holding)",
|
||||||
|
sql: "INSERT INTO balance_securities (symbol, currency, asset_type) \
|
||||||
|
SELECT DISTINCT UPPER(TRIM(a.symbol)), a.currency, c.asset_type \
|
||||||
|
FROM balance_accounts a \
|
||||||
|
JOIN balance_categories c ON c.id = a.balance_category_id \
|
||||||
|
WHERE a.symbol IS NOT NULL AND c.asset_type IS NOT NULL \
|
||||||
|
ON CONFLICT(symbol) DO NOTHING; \
|
||||||
|
INSERT INTO balance_snapshot_holdings \
|
||||||
|
(snapshot_line_id, security_id, quantity, unit_price, value, price_source, price_fetched_at) \
|
||||||
|
SELECT sl.id, s.id, sl.quantity, sl.unit_price, sl.value, sl.price_source, sl.price_fetched_at \
|
||||||
|
FROM balance_snapshot_lines sl \
|
||||||
|
JOIN balance_accounts a ON a.id = sl.account_id \
|
||||||
|
JOIN balance_securities s ON s.symbol = UPPER(TRIM(a.symbol)) \
|
||||||
|
WHERE a.symbol IS NOT NULL AND sl.quantity IS NOT NULL \
|
||||||
|
ON CONFLICT(snapshot_line_id, security_id) DO NOTHING; \
|
||||||
|
UPDATE balance_snapshot_lines \
|
||||||
|
SET quantity = NULL, unit_price = NULL \
|
||||||
|
WHERE quantity IS NOT NULL \
|
||||||
|
AND id IN (SELECT snapshot_line_id FROM balance_snapshot_holdings); \
|
||||||
|
CREATE TEMP TABLE _v16_guard (ok INTEGER CHECK (ok = 1)); \
|
||||||
|
INSERT INTO _v16_guard(ok) SELECT CASE WHEN EXISTS ( \
|
||||||
|
SELECT 1 FROM balance_snapshot_lines sl \
|
||||||
|
JOIN balance_accounts a ON a.id = sl.account_id \
|
||||||
|
WHERE a.symbol IS NOT NULL AND sl.quantity IS NULL \
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM balance_snapshot_holdings h WHERE h.snapshot_line_id = sl.id) \
|
||||||
|
) THEN 0 ELSE 1 END; \
|
||||||
|
DROP TABLE _v16_guard;",
|
||||||
|
kind: MigrationKind::Up,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
|
@ -2204,6 +2266,337 @@ mod tests {
|
||||||
assert_eq!(kind, "simple", "kind must default to 'simple'");
|
assert_eq!(kind, "simple", "kind must default to 'simple'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Migration v16 — Bilan détail par titre (Étape 2) — issue #211
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Data conversion: each existing single-security priced account becomes a
|
||||||
|
// detailed account with one security + one mirror holding, zero data loss.
|
||||||
|
// - Step 1: one balance_securities per priced account symbol (normalized,
|
||||||
|
// deduped), ONLY for accounts whose category has a real asset_type.
|
||||||
|
// - Step 2: one holding per priced line (qty/price/value/source mirrored).
|
||||||
|
// - Step 3: NULL the per-line qty/unit_price ONLY where a holding now
|
||||||
|
// exists (the 🔴 fix — never NULL a line that got no holding).
|
||||||
|
// - Trailing TEMP-table CHECK(ok = 1) aborts if any NULLed line lacks a
|
||||||
|
// holding, rolling back the whole v16 transaction.
|
||||||
|
//
|
||||||
|
// V16_SQL is statement-equivalent to the Migration { version: 16 } entry,
|
||||||
|
// kept in sync by hand (same pattern as V10..V15 above).
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Production v16 SQL — kept in sync with the Migration { version: 16 } entry.
|
||||||
|
const V16_SQL: &str = "INSERT INTO balance_securities (symbol, currency, asset_type) \
|
||||||
|
SELECT DISTINCT UPPER(TRIM(a.symbol)), a.currency, c.asset_type \
|
||||||
|
FROM balance_accounts a \
|
||||||
|
JOIN balance_categories c ON c.id = a.balance_category_id \
|
||||||
|
WHERE a.symbol IS NOT NULL AND c.asset_type IS NOT NULL \
|
||||||
|
ON CONFLICT(symbol) DO NOTHING; \
|
||||||
|
INSERT INTO balance_snapshot_holdings \
|
||||||
|
(snapshot_line_id, security_id, quantity, unit_price, value, price_source, price_fetched_at) \
|
||||||
|
SELECT sl.id, s.id, sl.quantity, sl.unit_price, sl.value, sl.price_source, sl.price_fetched_at \
|
||||||
|
FROM balance_snapshot_lines sl \
|
||||||
|
JOIN balance_accounts a ON a.id = sl.account_id \
|
||||||
|
JOIN balance_securities s ON s.symbol = UPPER(TRIM(a.symbol)) \
|
||||||
|
WHERE a.symbol IS NOT NULL AND sl.quantity IS NOT NULL \
|
||||||
|
ON CONFLICT(snapshot_line_id, security_id) DO NOTHING; \
|
||||||
|
UPDATE balance_snapshot_lines \
|
||||||
|
SET quantity = NULL, unit_price = NULL \
|
||||||
|
WHERE quantity IS NOT NULL \
|
||||||
|
AND id IN (SELECT snapshot_line_id FROM balance_snapshot_holdings); \
|
||||||
|
CREATE TEMP TABLE _v16_guard (ok INTEGER CHECK (ok = 1)); \
|
||||||
|
INSERT INTO _v16_guard(ok) SELECT CASE WHEN EXISTS ( \
|
||||||
|
SELECT 1 FROM balance_snapshot_lines sl \
|
||||||
|
JOIN balance_accounts a ON a.id = sl.account_id \
|
||||||
|
WHERE a.symbol IS NOT NULL AND sl.quantity IS NULL \
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM balance_snapshot_holdings h WHERE h.snapshot_line_id = sl.id) \
|
||||||
|
) THEN 0 ELSE 1 END; \
|
||||||
|
DROP TABLE _v16_guard;";
|
||||||
|
|
||||||
|
/// Build a realistic pre-v16 DB: full v10→v15 chain applied, then two priced
|
||||||
|
/// accounts seeded — one CONVERTIBLE (seed `stock` category, asset_type set)
|
||||||
|
/// and one NON-CONVERTIBLE (custom priced category with asset_type still
|
||||||
|
/// NULL) — each with one snapshot line. Returns (conn, convertible_line_id,
|
||||||
|
/// non_convertible_line_id).
|
||||||
|
fn db_pre_v16() -> (Connection, i64, i64) {
|
||||||
|
let conn = db_through_v13();
|
||||||
|
conn.execute_batch(V14_SQL).expect("apply v14");
|
||||||
|
conn.execute_batch(V15_SQL).expect("apply v15");
|
||||||
|
|
||||||
|
// (a) Convertible: seed `stock` category carries asset_type = 'stock' (v10).
|
||||||
|
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, kind) \
|
||||||
|
VALUES (?1, 'Courtage AAPL', ' aapl ', 'detailed')",
|
||||||
|
[stock_cat],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let acc_a: i64 = conn
|
||||||
|
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// (b) Non-convertible: a custom priced category with NO asset_type.
|
||||||
|
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, kind) \
|
||||||
|
VALUES (?1, 'Courtage XYZ', 'XYZ', 'detailed')",
|
||||||
|
[custom_cat],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let acc_b: i64 = conn
|
||||||
|
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// One snapshot, one priced line per account.
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-06-01')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let snap_id: 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, price_fetched_at) \
|
||||||
|
VALUES (?1, ?2, 10.0, 50.0, 500.0, 'maximus-api', '2026-06-01T12:00:00')",
|
||||||
|
rusqlite::params![snap_id, acc_a],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let line_a: 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, 3.0, 7.0, 21.0, 'manual')",
|
||||||
|
rusqlite::params![snap_id, acc_b],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let line_b: i64 = conn
|
||||||
|
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
(conn, line_a, line_b)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v16_converts_priced_with_asset_type_and_preserves_values() {
|
||||||
|
let (conn, line_a, _line_b) = db_pre_v16();
|
||||||
|
conn.execute_batch(V16_SQL).expect("apply v16");
|
||||||
|
|
||||||
|
// A security was minted from the normalized symbol (' aapl ' → 'AAPL').
|
||||||
|
let sec_id: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM balance_securities WHERE symbol = 'AAPL'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.expect("security AAPL must exist (normalized UPPER(TRIM))");
|
||||||
|
let (sym, cur, at): (String, String, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT symbol, currency, asset_type FROM balance_securities WHERE id = ?1",
|
||||||
|
[sec_id],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(sym, "AAPL");
|
||||||
|
assert_eq!(cur, "CAD");
|
||||||
|
assert_eq!(at, "stock");
|
||||||
|
|
||||||
|
// One mirror holding on line_a with the original qty/price/value/source.
|
||||||
|
let (h_qty, h_price, h_val, h_src, h_fetched, h_book): (
|
||||||
|
f64,
|
||||||
|
f64,
|
||||||
|
f64,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
Option<f64>,
|
||||||
|
) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT quantity, unit_price, value, price_source, price_fetched_at, book_cost \
|
||||||
|
FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1",
|
||||||
|
[line_a],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)),
|
||||||
|
)
|
||||||
|
.expect("a holding must mirror the converted line");
|
||||||
|
assert_eq!(h_qty, 10.0);
|
||||||
|
assert_eq!(h_price, 50.0);
|
||||||
|
assert_eq!(h_val, 500.0);
|
||||||
|
assert_eq!(h_src, "maximus-api");
|
||||||
|
assert_eq!(h_fetched, "2026-06-01T12:00:00");
|
||||||
|
assert!(h_book.is_none(), "book_cost stays NULL for historical lines");
|
||||||
|
assert_eq!(
|
||||||
|
h_qty * h_price,
|
||||||
|
h_val,
|
||||||
|
"holding value must equal quantity * unit_price"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The aggregated line keeps its total value but qty/unit_price are NULLed.
|
||||||
|
let (l_qty, l_price, l_val): (Option<f64>, Option<f64>, f64) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT quantity, unit_price, value FROM balance_snapshot_lines WHERE id = ?1",
|
||||||
|
[line_a],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(l_qty.is_none(), "converted line quantity must be NULL");
|
||||||
|
assert!(l_price.is_none(), "converted line unit_price must be NULL");
|
||||||
|
assert_eq!(l_val, 500.0, "converted line value must be preserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v16_leaves_priced_without_asset_type_intact() {
|
||||||
|
let (conn, _line_a, line_b) = db_pre_v16();
|
||||||
|
conn.execute_batch(V16_SQL).expect("apply v16");
|
||||||
|
|
||||||
|
// No security minted for the symbol 'XYZ' (its category had no asset_type).
|
||||||
|
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");
|
||||||
|
|
||||||
|
// No holding attached to line_b.
|
||||||
|
let holdings_b: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1",
|
||||||
|
[line_b],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(holdings_b, 0, "non-convertible line must get no holding");
|
||||||
|
|
||||||
|
// line_b is left fully intact — qty/unit_price NOT NULLed (no silent loss).
|
||||||
|
let (q, p, v): (Option<f64>, Option<f64>, f64) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT quantity, unit_price, value FROM balance_snapshot_lines WHERE id = ?1",
|
||||||
|
[line_b],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(q, Some(3.0), "intact line keeps its quantity");
|
||||||
|
assert_eq!(p, Some(7.0), "intact line keeps its unit_price");
|
||||||
|
assert_eq!(v, 21.0, "intact line keeps its value");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v16_is_idempotent_on_rerun() {
|
||||||
|
let (conn, line_a, _line_b) = db_pre_v16();
|
||||||
|
conn.execute_batch(V16_SQL).expect("first v16");
|
||||||
|
// Re-running must be a strict no-op (ON CONFLICT no-ops, guard re-passes).
|
||||||
|
conn.execute_batch(V16_SQL).expect("second v16 (idempotent)");
|
||||||
|
|
||||||
|
let n_secs: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM balance_securities WHERE symbol = 'AAPL'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(n_secs, 1, "re-run must not duplicate the security");
|
||||||
|
let n_holdings: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1",
|
||||||
|
[line_a],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(n_holdings, 1, "re-run must not duplicate the holding");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v16_guard_aborts_and_rolls_back_on_injected_failure() {
|
||||||
|
// Simulate a buggy v16 where the holdings-insert step is skipped: step 3
|
||||||
|
// would NULL a line that got no holding, and the trailing guard must
|
||||||
|
// catch the broken invariant and ABORT. We run the corrupted SQL inside
|
||||||
|
// an explicit rusqlite transaction that is dropped (→ rolled back) on the
|
||||||
|
// returned error — exactly how sqlx rolls back a failed migration. SQLite
|
||||||
|
// ABORT resolution only undoes the offending statement, so the whole-
|
||||||
|
// migration safety relies on the surrounding transaction being rolled
|
||||||
|
// back (which this test asserts).
|
||||||
|
let (mut conn, line_a, _line_b) = db_pre_v16();
|
||||||
|
|
||||||
|
// The corrupted batch: step 1 + step 3 + guard, with step 2 (holdings)
|
||||||
|
// intentionally removed. NULLing now happens on lines with no holding.
|
||||||
|
const V16_CORRUPT: &str = "\
|
||||||
|
INSERT INTO balance_securities (symbol, currency, asset_type) \
|
||||||
|
SELECT DISTINCT UPPER(TRIM(a.symbol)), a.currency, c.asset_type \
|
||||||
|
FROM balance_accounts a \
|
||||||
|
JOIN balance_categories c ON c.id = a.balance_category_id \
|
||||||
|
WHERE a.symbol IS NOT NULL AND c.asset_type IS NOT NULL \
|
||||||
|
ON CONFLICT(symbol) DO NOTHING; \
|
||||||
|
UPDATE balance_snapshot_lines SET quantity = NULL, unit_price = NULL \
|
||||||
|
WHERE quantity IS NOT NULL; \
|
||||||
|
CREATE TEMP TABLE _v16_guard (ok INTEGER CHECK (ok = 1)); \
|
||||||
|
INSERT INTO _v16_guard(ok) SELECT CASE WHEN EXISTS ( \
|
||||||
|
SELECT 1 FROM balance_snapshot_lines sl \
|
||||||
|
JOIN balance_accounts a ON a.id = sl.account_id \
|
||||||
|
WHERE a.symbol IS NOT NULL AND sl.quantity IS NULL \
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM balance_snapshot_holdings h WHERE h.snapshot_line_id = sl.id) \
|
||||||
|
) THEN 0 ELSE 1 END; \
|
||||||
|
DROP TABLE _v16_guard;";
|
||||||
|
|
||||||
|
{
|
||||||
|
let tx = conn.transaction().expect("begin tx");
|
||||||
|
let res = tx.execute_batch(V16_CORRUPT);
|
||||||
|
assert!(
|
||||||
|
res.is_err(),
|
||||||
|
"the CHECK(ok = 1) guard must abort when a NULLed line lacks a holding"
|
||||||
|
);
|
||||||
|
// tx dropped here without commit → ROLLBACK (mirrors sqlx on error).
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rollback left the DB at its pre-v16 state: zero holdings, qty intact.
|
||||||
|
let total_holdings: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM balance_snapshot_holdings", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(total_holdings, 0, "rollback must leave zero holdings");
|
||||||
|
let (q, p): (Option<f64>, Option<f64>) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT quantity, unit_price FROM balance_snapshot_lines WHERE id = ?1",
|
||||||
|
[line_a],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(q, Some(10.0), "rollback must leave quantity intact");
|
||||||
|
assert_eq!(p, Some(50.0), "rollback must leave unit_price intact");
|
||||||
|
// And no securities were committed either.
|
||||||
|
let total_secs: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM balance_securities", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(total_secs, 0, "rollback must leave zero securities");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v16_full_chain_applies_cleanly() {
|
||||||
|
// The whole v10→v16 chain on a fresh DB with no priced accounts is a
|
||||||
|
// no-op for v16 and must apply without error (guard passes vacuously).
|
||||||
|
let conn = db_through_v13();
|
||||||
|
conn.execute_batch(V14_SQL).expect("apply v14");
|
||||||
|
conn.execute_batch(V15_SQL).expect("apply v15");
|
||||||
|
conn.execute_batch(V16_SQL).expect("apply v16 on empty data");
|
||||||
|
let secs: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM balance_securities", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(secs, 0, "no securities when there are no priced accounts");
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Consolidated schema (new profiles) — issue #202
|
// Consolidated schema (new profiles) — issue #202
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue