Simpl-Resultat/src-tauri/src/lib.rs
le king fu 90c115e0c0 feat(balance): convert existing priced accounts to detailed (migration v16) (#211)
v16 is a purely additive, guarded, atomic data migration (Bilan détail par
titre, Étape 2). It converts each existing single-security priced account into
a detailed account holding exactly one position, with zero data loss.

  1. Mints one shared balance_securities row per priced account symbol
     (normalized UPPER(TRIM), deduped via ON CONFLICT(symbol) DO NOTHING on the
     COLLATE NOCASE UNIQUE), ONLY for accounts whose category carries a real
     asset_type — balance_securities.asset_type is NOT NULL, and a priced
     account whose category has asset_type IS NULL has no valid routing.
  2. Mirrors each existing priced line into one holding (qty/unit_price/value/
     price_source/price_fetched_at copied; book_cost stays NULL — no
     retroactive acquisition cost). UNIQUE(line, security) + ON CONFLICT DO
     NOTHING makes a re-run a strict no-op.
  3. Collapses the now-redundant per-line qty/unit_price to NULL ONLY where a
     holding now exists (the security fix — a line that got no holding, i.e.
     priced-without-asset_type, is never NULLed, so no silent data loss).
     NULLing both columns together preserves the lines' (both NULL | both NOT
     NULL) CHECK.

A trailing TEMP-table CHECK(ok = 1) asserts the invariant 'qty NULLed ⇒ has a
holding' and ABORTS on breach, rolling back the whole v16 transaction (sqlx
wraps each migration in a transaction). Priced accounts without asset_type or
without a symbol are left fully intact.

Integration tests (in-memory SQLite, apply v10→v16 via execute_batch, mirroring
the #210 migration-test style): convertible account gains a security + holding
with values/history preserved and its line qty NULLed; non-convertible priced
account untouched (qty intact, no holding); re-run idempotent; injected-failure
mid-v16 aborts on the guard and a transaction rollback restores the pre-v16
state (zero securities/holdings, quantity intact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:00:57 -04:00

2772 lines
116 KiB
Rust

mod commands;
mod database;
use std::sync::Mutex;
use tauri::{Emitter, Manager};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_sql::{Migration, MigrationKind};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let migrations = vec![
Migration {
version: 1,
description: "create initial schema",
sql: database::SCHEMA,
kind: MigrationKind::Up,
},
Migration {
version: 2,
description: "seed categories and keywords",
sql: database::SEED_CATEGORIES,
kind: MigrationKind::Up,
},
Migration {
version: 3,
description: "add has_header to import_sources",
sql: "ALTER TABLE import_sources ADD COLUMN has_header INTEGER NOT NULL DEFAULT 1;",
kind: MigrationKind::Up,
},
Migration {
version: 4,
description: "add is_inputable to categories",
sql: "ALTER TABLE categories ADD COLUMN is_inputable INTEGER NOT NULL DEFAULT 1;",
kind: MigrationKind::Up,
},
Migration {
version: 5,
description: "create import_config_templates table",
sql: "CREATE TABLE IF NOT EXISTS import_config_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
delimiter TEXT NOT NULL DEFAULT ';',
encoding TEXT NOT NULL DEFAULT 'utf-8',
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
skip_lines INTEGER NOT NULL DEFAULT 0,
has_header INTEGER NOT NULL DEFAULT 1,
column_mapping TEXT NOT NULL,
amount_mode TEXT NOT NULL DEFAULT 'single',
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);",
kind: MigrationKind::Up,
},
Migration {
version: 6,
description: "change imported_files unique constraint from hash to filename",
sql: "CREATE TABLE imported_files_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL REFERENCES import_sources(id),
filename TEXT NOT NULL,
file_hash TEXT NOT NULL,
import_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
row_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'completed',
notes TEXT,
UNIQUE(source_id, filename)
);
INSERT INTO imported_files_new SELECT * FROM imported_files;
DROP TABLE imported_files;
ALTER TABLE imported_files_new RENAME TO imported_files;",
kind: MigrationKind::Up,
},
Migration {
version: 7,
description: "add level-3 insurance subcategories",
sql: "INSERT OR IGNORE INTO categories (id, name, parent_id, type, color, sort_order) VALUES (310, 'Assurance-auto', 31, 'expense', '#14b8a6', 1);
INSERT OR IGNORE INTO categories (id, name, parent_id, type, color, sort_order) VALUES (311, 'Assurance-habitation', 31, 'expense', '#0d9488', 2);
INSERT OR IGNORE INTO categories (id, name, parent_id, type, color, sort_order) VALUES (312, 'Assurance-vie', 31, 'expense', '#0f766e', 3);
UPDATE categories SET is_inputable = 0 WHERE id = 31;
UPDATE keywords SET category_id = 310 WHERE keyword = 'BELAIR' AND category_id = 31;
UPDATE keywords SET category_id = 311 WHERE keyword = 'PRYSM' AND category_id = 31;
UPDATE keywords SET category_id = 312 WHERE keyword = 'INS/ASS' AND category_id = 31;",
kind: MigrationKind::Up,
},
// Migration v8 — additive: tag existing profiles with the v2 categories
// taxonomy and add a nullable i18n_key column on categories so the v1
// IPC seed (applied only to brand-new profiles via consolidated_schema)
// can store translation keys. Existing v2 profiles are untouched: the
// column defaults to NULL (falling back to the category's `name`) and
// the preference is a no-op INSERT OR IGNORE so re-runs are safe.
Migration {
version: 8,
description: "add i18n_key to categories and categories_schema_version preference",
sql: "ALTER TABLE categories ADD COLUMN i18n_key TEXT;
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v2');",
kind: MigrationKind::Up,
},
// Migration v9 — Bilan (balance sheet) schema:
// 5 tables (balance_categories, balance_accounts, balance_snapshots,
// balance_snapshot_lines, balance_account_transfers) + 7 indexes +
// 7 seeded categories (5 simple + 2 priced).
// CHECK(currency = 'CAD') is hardcoded for the MVP (will be lifted in v2
// with a multi-currency rate table). FK transaction_id ON DELETE
// RESTRICT preserves reproducibility of Modified Dietz returns.
Migration {
version: 9,
description: "create balance schema",
sql: database::BALANCE_SCHEMA,
kind: MigrationKind::Up,
},
// Migration v10 — additive: a nullable `asset_type` column on
// balance_categories so the priced-kind UI can route between providers
// (best-effort Yahoo for stocks, exchange APIs for crypto). Symbol
// alone is ambiguous (e.g. ETH = Ethan Allen NYSE AND Ethereum crypto).
// ALTER first → adds NULL column, then UPDATE backfills the two
// priced seeds (`stock`, `crypto`) created by v9. Custom priced rows
// keep NULL until the user edits the category — SnapshotLineRow hides
// the fetch button while asset_type is NULL.
Migration {
version: 10,
description: "add asset_type to balance_categories",
sql: "ALTER TABLE balance_categories ADD COLUMN asset_type TEXT \
CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto')); \
UPDATE balance_categories SET asset_type = 'stock' \
WHERE key = 'stock' AND is_seed = 1; \
UPDATE balance_categories SET asset_type = 'crypto' \
WHERE key = 'crypto' AND is_seed = 1;",
kind: MigrationKind::Up,
},
// Migration v11 — cleanup orphan balance snapshots (#176). Before
// useSnapshotEditor.save was made atomic via BEGIN/COMMIT, a
// priced-line validation failure could leave the snapshot row
// inserted but with no lines, blocking subsequent saves at that
// date through the snapshot_date UNIQUE constraint. This deletes
// any such orphan rows from existing profiles. New orphans are
// no longer possible thanks to saveSnapshotAtomic.
Migration {
version: 11,
description: "cleanup orphan balance snapshots",
sql: "DELETE FROM balance_snapshots \
WHERE NOT EXISTS ( \
SELECT 1 FROM balance_snapshot_lines \
WHERE snapshot_id = balance_snapshots.id);",
kind: MigrationKind::Up,
},
// Migration v12 — Bilan axe véhicule (Étape 1), additive part. Adds
// `vehicle_type` (fiscal envelope / tax shelter — NOT an automobile) to
// balance_accounts and `custom_label` to balance_categories, then
// backfills the envelope attribute onto the accounts that used to live
// in the now-deprecated `tfsa` / `rrsp` seed categories. `cash` accounts
// stay NULL — a chequing account is not a "non-registered investment".
//
// The two trailing UPDATEs are a defensive backfill for bug I: before
// this work the category-rename path overwrote `i18n_key` with free
// text. Any seed row whose i18n_key no longer matches the canonical
// `balance.category.%` convention has that free text recovered into
// `custom_label`, then its i18n_key is restored from `key` so the
// renderCategoryLabel helper (custom_label || t(i18n_key)) keeps working.
// ALTER ADD COLUMN ... CHECK (mono-column) is valid on this stack (cf v10).
Migration {
version: 12,
description: "add vehicle_type to balance_accounts and custom_label to balance_categories",
sql: "ALTER TABLE balance_accounts ADD COLUMN vehicle_type TEXT \
CHECK(vehicle_type IS NULL OR vehicle_type IN \
('unregistered','tfsa','rrsp','rrif','fhsa','resp')); \
ALTER TABLE balance_categories ADD COLUMN custom_label TEXT; \
UPDATE balance_accounts SET vehicle_type = 'tfsa' \
WHERE balance_category_id = ( \
SELECT id FROM balance_categories WHERE key = 'tfsa'); \
UPDATE balance_accounts SET vehicle_type = 'rrsp' \
WHERE balance_category_id = ( \
SELECT id FROM balance_categories WHERE key = 'rrsp'); \
UPDATE balance_categories SET custom_label = i18n_key \
WHERE is_seed = 1 AND i18n_key NOT LIKE 'balance.category.%'; \
UPDATE balance_categories SET i18n_key = 'balance.category.' || key \
WHERE is_seed = 1 AND i18n_key NOT LIKE 'balance.category.%';",
kind: MigrationKind::Up,
},
// Migration v13 — Bilan axe véhicule (Étape 1), reclass part. Now that
// the envelope lives on the account (v12), the `tfsa` / `rrsp` seed
// categories become pure tax shelters with no asset-class meaning.
// Re-link their accounts to the `other` asset class ("Autres") and
// deactivate the two seeds so they vanish from the category dropdowns.
//
// Conditional + idempotent (no Down migration exists): the re-link only
// fires when an `other` seed category exists (EXISTS guard), and both
// statements scope to `is_seed = 1` so a user-created category that
// happens to reuse the key is never touched. v12 runs first (sqlx
// versioning guarantees the order), so vehicle_type is already stamped
// before balance_category_id moves. Re-running v13 is a no-op: the
// accounts are no longer in tfsa/rrsp and the seeds are already inactive.
Migration {
version: 13,
description: "reclass ex-tfsa/rrsp accounts to other and deactivate the envelope seeds",
sql: "UPDATE balance_accounts \
SET balance_category_id = ( \
SELECT id FROM balance_categories WHERE key = 'other' AND is_seed = 1) \
WHERE balance_category_id IN ( \
SELECT id FROM balance_categories WHERE key IN ('tfsa','rrsp') AND is_seed = 1) \
AND EXISTS ( \
SELECT 1 FROM balance_categories WHERE key = 'other' AND is_seed = 1); \
UPDATE balance_categories SET is_active = 0 \
WHERE key IN ('tfsa','rrsp') AND is_seed = 1;",
kind: MigrationKind::Up,
},
// Migration v14 — Bilan détail par titre (Étape 2), foundation part.
// Purely additive: two new tables that let a single account hold many
// securities at a snapshot date, instead of one denormalized value.
// - balance_securities: the catalogue of investable instruments
// (a stock or a crypto). `symbol` is the natural key, stored
// normalized (upper/trim by the service layer) with COLLATE NOCASE
// so 'aapl' and 'AAPL' can never coexist as duplicates (SEC/ARCH
// review caveat). `asset_type` mirrors balance_categories.asset_type
// so the price-fetch flow can route stock vs crypto per security.
// - balance_snapshot_holdings: one row per (snapshot line, security).
// The FK targets balance_snapshot_lines(id) — there is already
// exactly one line per (snapshot, account) via that table's
// UNIQUE(snapshot_id, account_id), so the line id uniquely pins the
// (snapshot, account) pair (ARCH review caveat — keep line FK, not
// snapshot+account). ON DELETE CASCADE wipes holdings when the line
// goes; ON DELETE RESTRICT on security_id blocks deleting a security
// still referenced by history. `value` is denormalized (= quantity *
// unit_price) so reports stay reproducible without re-fetching, same
// rationale as balance_snapshot_lines.value. No Down migration.
Migration {
version: 14,
description: "create balance_securities and balance_snapshot_holdings",
sql: database::BALANCE_HOLDINGS_SCHEMA,
kind: MigrationKind::Up,
},
// Migration v15 — Bilan détail par titre (Étape 2), account pivot part.
// Adds the `kind` discriminator that tells whether an account stores a
// single denormalized value ('simple') or a basket of per-security
// holdings ('detailed'), plus `detailed_since` — the authoritative pivot
// date from which detailed entry is expected (revision decision). The
// backfill stamps kind='detailed' on every account currently linked to a
// priced category, since those are exactly the ones that gain the
// per-title breakdown; `detailed_since` stays NULL until the conversion
// flow (#211) sets it. SQLite allows only one column per ADD COLUMN, so
// the two ALTERs are separate statements (same multi-ALTER idempotence
// shape as v12). Re-running is safe: the UPDATE is naturally idempotent.
Migration {
version: 15,
description: "add kind and detailed_since to balance_accounts",
sql: "ALTER TABLE balance_accounts ADD COLUMN kind TEXT NOT NULL DEFAULT 'simple' \
CHECK (kind IN ('simple','detailed')); \
ALTER TABLE balance_accounts ADD COLUMN detailed_since DATE; \
UPDATE balance_accounts SET kind = 'detailed' \
WHERE balance_category_id IN ( \
SELECT id FROM balance_categories WHERE kind = 'priced');",
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()
// Single-instance plugin MUST be registered first. With the `deep-link`
// feature, it forwards `simpl-resultat://` URLs to the running instance
// so the OAuth2 callback reaches the process that holds the PKCE verifier.
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
}
}))
.manage(commands::auth_commands::OAuthState {
code_verifier: Mutex::new(None),
})
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_deep_link::init())
.setup(|app| {
#[cfg(desktop)]
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
// Register the custom scheme at runtime on Linux (the .desktop file
// handles it in prod, but register_all is a no-op there and required
// for AppImage/dev builds).
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
{
let _ = app.deep_link().register_all();
}
// Canonical Tauri v2 pattern: on_open_url fires for both initial-launch
// URLs and subsequent URLs forwarded by tauri-plugin-single-instance
// (with the `deep-link` feature).
let handle = app.handle().clone();
app.deep_link().on_open_url(move |event| {
for url in event.urls() {
let url_str = url.as_str();
let h = handle.clone();
if let Some(code) = extract_auth_code(url_str) {
tauri::async_runtime::spawn(async move {
match commands::handle_auth_callback(h.clone(), code).await {
Ok(account) => {
let _ = h.emit("auth-callback-success", &account);
}
Err(err) => {
let _ = h.emit("auth-callback-error", &err);
}
}
});
} else {
// No `code` param — likely an OAuth error response. Surface
// it to the frontend instead of leaving the UI stuck in
// "loading" forever.
let err_msg = extract_auth_error(url_str)
.unwrap_or_else(|| "OAuth callback did not include a code".to_string());
let _ = h.emit("auth-callback-error", &err_msg);
}
}
});
Ok(())
})
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations("sqlite:simpl_resultat.db", migrations)
.build(),
)
.invoke_handler(tauri::generate_handler![
commands::scan_import_folder,
commands::read_file_content,
commands::hash_file,
commands::detect_encoding,
commands::get_file_preview,
commands::pick_folder,
commands::pick_save_file,
commands::pick_import_file,
commands::write_export_file,
commands::read_import_file,
commands::is_file_encrypted,
commands::load_profiles,
commands::save_profiles,
commands::delete_profile_db,
commands::get_new_profile_init_sql,
commands::hash_pin,
commands::verify_pin,
commands::repair_migrations,
commands::validate_license_key,
commands::store_license,
commands::store_activation_token,
commands::read_license,
commands::get_edition,
commands::get_machine_id,
commands::check_entitlement,
commands::activate_machine,
commands::deactivate_machine,
commands::list_activated_machines,
commands::get_activation_status,
commands::start_oauth,
commands::refresh_auth_token,
commands::get_account_info,
commands::check_subscription_status,
commands::logout,
commands::get_token_store_mode,
commands::send_feedback,
commands::get_feedback_user_agent,
commands::ensure_backup_dir,
commands::get_file_size,
commands::file_exists,
commands::compute_account_return,
commands::fetch_price,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
/// Extract the `code` query parameter from a deep-link callback URL.
/// e.g. "simpl-resultat://auth/callback?code=abc123&state=xyz" → Some("abc123")
fn extract_auth_code(url: &str) -> Option<String> {
extract_query_param(url, "code")
}
/// Extract an OAuth error description from a callback URL. Returns a
/// formatted string combining `error` and `error_description` when present.
fn extract_auth_error(url: &str) -> Option<String> {
let error = extract_query_param(url, "error")?;
match extract_query_param(url, "error_description") {
Some(desc) => Some(format!("{}: {}", error, desc)),
None => Some(error),
}
}
fn extract_query_param(url: &str, key: &str) -> Option<String> {
let url = url.trim();
if !url.starts_with("simpl-resultat://auth/callback") {
return None;
}
let query = url.split('?').nth(1)?;
for pair in query.split('&') {
let mut kv = pair.splitn(2, '=');
if kv.next()? == key {
return kv.next().map(|v| {
urlencoding::decode(v).map(|s| s.into_owned()).unwrap_or_else(|_| v.to_string())
});
}
}
None
}
// =============================================================================
// Tests for migration v9 — balance schema
// -----------------------------------------------------------------------------
// These tests apply `database::BALANCE_SCHEMA` (the SQL embedded in the v9
// migration) on a fresh in-memory SQLite database and assert that:
// - the schema applies cleanly (all 5 tables + 7 indexes created)
// - the 7 seed categories are present (5 simple + 2 priced) with is_seed = 1
// - CHECK constraints reject invalid kind / direction / currency / kind invariants
// - UNIQUE constraints enforce snapshot_date / (snapshot_id,account_id) /
// (transaction_id,account_id) / category key
// - FK ON DELETE policies behave as expected (CASCADE on snapshot, RESTRICT
// on transaction_id and on category with linked accounts)
//
// rusqlite (0.32, bundled) is already a runtime dependency — no extra dev-dep
// required. The migration v9 SQL is the source of truth; v1-v8 are not
// required here because v9 is additive and only references the existing
// `transactions` table for the FK on balance_account_transfers — we mirror
// that with a minimal `transactions` table for the integration scenarios.
#[cfg(test)]
mod tests {
use rusqlite::Connection;
/// Apply the v9 schema on a fresh in-memory DB. Includes a minimal
/// `transactions` table because balance_account_transfers references it.
fn fresh_db() -> Connection {
let conn = Connection::open_in_memory().expect("open in-memory db");
// FKs must be enabled per-connection in SQLite.
conn.execute("PRAGMA foreign_keys = ON;", [])
.expect("enable FKs");
// Minimal transactions table mirroring the relevant columns the FK
// references (id is the only column we need at the SQL level).
conn.execute_batch(
"CREATE TABLE transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
description TEXT NOT NULL,
amount REAL NOT NULL
);",
)
.expect("create stub transactions table");
conn.execute_batch(crate::database::BALANCE_SCHEMA)
.expect("apply BALANCE_SCHEMA");
conn
}
#[test]
fn migration_v9_applies_cleanly() {
let conn = fresh_db();
// 5 expected tables
let tables: Vec<String> = conn
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'balance_%' ORDER BY name",
)
.unwrap()
.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
assert_eq!(
tables,
vec![
"balance_account_transfers",
"balance_accounts",
"balance_categories",
"balance_snapshot_lines",
"balance_snapshots",
]
);
// 7 expected indexes
let index_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'idx_balance_%'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(index_count, 7);
}
#[test]
fn migration_v9_seeds_7_categories() {
let conn = fresh_db();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 7);
let simple_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_categories WHERE kind = 'simple' AND is_seed = 1",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(simple_count, 5);
let priced_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_categories WHERE kind = 'priced' AND is_seed = 1",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(priced_count, 2);
// Seeded keys are stable
let stock_kind: String = conn
.query_row(
"SELECT kind FROM balance_categories WHERE key = 'stock'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(stock_kind, "priced");
}
#[test]
fn migration_v9_rejects_non_cad_currency() {
let conn = fresh_db();
// 'cash' category exists from seed; try to insert a non-CAD account.
let result = conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name, currency)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'USD account', 'USD')",
[],
);
assert!(
result.is_err(),
"CHECK(currency='CAD') should reject 'USD'"
);
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.to_lowercase().contains("check"));
}
#[test]
fn migration_v9_accepts_default_cad_currency() {
let conn = fresh_db();
let inserted = conn
.execute(
"INSERT INTO balance_accounts (balance_category_id, name)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
[],
)
.expect("CAD default insert should succeed");
assert_eq!(inserted, 1);
}
#[test]
fn migration_v9_rejects_invalid_kind() {
let conn = fresh_db();
let result = conn.execute(
"INSERT INTO balance_categories (key, i18n_key, kind) VALUES ('bogus', 'x.bogus', 'unknown')",
[],
);
assert!(result.is_err(), "kind CHECK should reject 'unknown'");
}
#[test]
fn migration_v9_unique_snapshot_date() {
let conn = fresh_db();
conn.execute(
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
[],
)
.unwrap();
let result = conn.execute(
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
[],
);
assert!(result.is_err(), "UNIQUE(snapshot_date) should reject dup");
}
#[test]
fn migration_v9_kind_invariants_check() {
let conn = fresh_db();
// Setup: a snapshot + an account
conn.execute(
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
[],
)
.unwrap();
let snap_id: i64 = conn
.query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
.unwrap();
let acct_id: i64 = conn
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
.unwrap();
// OK: simple kind (quantity + unit_price both NULL)
conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
VALUES (?1, ?2, 1234.56)",
rusqlite::params![snap_id, acct_id],
)
.expect("simple kind row (qty/price both NULL) should be accepted");
// OK: priced kind (both set) — needs second account on a priced category
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name, symbol)
VALUES ((SELECT id FROM balance_categories WHERE key='stock'), 'AAPL', 'AAPL')",
[],
)
.unwrap();
let acct2_id: i64 = conn
.query_row(
"SELECT id FROM balance_accounts WHERE name='AAPL'",
[],
|r| r.get(0),
)
.unwrap();
conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, unit_price, value)
VALUES (?1, ?2, 10.0, 200.0, 2000.0)",
rusqlite::params![snap_id, acct2_id],
)
.expect("priced kind row (both set) should be accepted");
// KO: only quantity set, unit_price NULL → CHECK violation
let bad = conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, value)
VALUES (?1, ?2, 10.0, 0.0)",
rusqlite::params![snap_id, acct_id],
);
assert!(
bad.is_err(),
"kind invariants CHECK should reject (qty set, price NULL)"
);
// KO: only unit_price set, quantity NULL → CHECK violation
let bad2 = conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, unit_price, value)
VALUES (?1, ?2, 200.0, 0.0)",
rusqlite::params![snap_id, acct_id],
);
assert!(
bad2.is_err(),
"kind invariants CHECK should reject (price set, qty NULL)"
);
}
#[test]
fn migration_v9_unique_snapshot_account_pair() {
let conn = fresh_db();
conn.execute(
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
[],
)
.unwrap();
let snap_id: i64 = conn
.query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
.unwrap();
let acct_id: i64 = conn
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
VALUES (?1, ?2, 100.0)",
rusqlite::params![snap_id, acct_id],
)
.unwrap();
let dup = conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
VALUES (?1, ?2, 200.0)",
rusqlite::params![snap_id, acct_id],
);
assert!(dup.is_err(), "UNIQUE(snapshot_id, account_id) should reject dup");
}
#[test]
fn migration_v9_fk_cascade_on_snapshot_delete() {
let conn = fresh_db();
conn.execute(
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
[],
)
.unwrap();
let snap_id: i64 = conn
.query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
.unwrap();
let acct_id: i64 = conn
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
VALUES (?1, ?2, 100.0)",
rusqlite::params![snap_id, acct_id],
)
.unwrap();
conn.execute(
"DELETE FROM balance_snapshots WHERE id = ?1",
rusqlite::params![snap_id],
)
.expect("delete snapshot should cascade");
let remaining: i64 = conn
.query_row("SELECT COUNT(*) FROM balance_snapshot_lines", [], |r| r.get(0))
.unwrap();
assert_eq!(remaining, 0, "snapshot delete should cascade lines");
}
#[test]
fn migration_v9_fk_restrict_on_transaction_delete() {
let conn = fresh_db();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO transactions (date, description, amount) VALUES ('2026-04-25', 'Deposit', 1000.0)",
[],
)
.unwrap();
let acct_id: i64 = conn
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
.unwrap();
let tx_id: i64 = conn
.query_row("SELECT id FROM transactions LIMIT 1", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
VALUES (?1, ?2, 'in')",
rusqlite::params![acct_id, tx_id],
)
.unwrap();
// Attempting to delete the linked transaction must be rejected (RESTRICT)
let result = conn.execute("DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id]);
assert!(
result.is_err(),
"FK RESTRICT should block deleting linked transaction"
);
// Once the transfer is removed, deletion is allowed again
conn.execute(
"DELETE FROM balance_account_transfers WHERE transaction_id = ?1",
rusqlite::params![tx_id],
)
.unwrap();
conn.execute("DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id])
.expect("after unlink, transaction can be deleted");
}
#[test]
fn migration_v9_fk_restrict_on_category_with_accounts() {
let conn = fresh_db();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
[],
)
.unwrap();
// Try to delete the seeded 'cash' category while an account references it
let result = conn.execute(
"DELETE FROM balance_categories WHERE key = 'cash'",
[],
);
assert!(
result.is_err(),
"FK RESTRICT should block deleting category with linked accounts"
);
}
#[test]
fn migration_v9_unique_transaction_account_transfer() {
let conn = fresh_db();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO transactions (date, description, amount) VALUES ('2026-04-25', 'Deposit', 1000.0)",
[],
)
.unwrap();
let acct_id: i64 = conn
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
.unwrap();
let tx_id: i64 = conn
.query_row("SELECT id FROM transactions LIMIT 1", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
VALUES (?1, ?2, 'in')",
rusqlite::params![acct_id, tx_id],
)
.unwrap();
let dup = conn.execute(
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
VALUES (?1, ?2, 'out')",
rusqlite::params![acct_id, tx_id],
);
assert!(
dup.is_err(),
"UNIQUE(transaction_id, account_id) should reject dup"
);
}
#[test]
fn migration_v9_seed_idempotent_on_replay() {
// The migration uses `INSERT OR IGNORE` keyed by `key`, so applying
// the schema twice on the same DB must not duplicate seeded rows.
let conn = fresh_db();
conn.execute_batch(crate::database::BALANCE_SCHEMA)
.expect("apply BALANCE_SCHEMA twice");
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 7, "seed must remain idempotent on replay");
}
// -------------------------------------------------------------------------
// Issue #144 (Bilan #6) — integration tests on a seeded DB
// -------------------------------------------------------------------------
//
// The previous tests apply BALANCE_SCHEMA on an empty DB. These tests
// simulate the realistic upgrade path: a profile DB with imported
// transactions already there gets the v9 migration applied on top, and
// we verify:
// - existing transactions are not affected by the migration (no row
// loss, no schema collision),
// - link / unlink transfer round-trips on real (non-stub) transaction
// ids,
// - the FK RESTRICT correctly chains: try to delete a linked
// transaction → blocked, unlink → delete succeeds.
/// Seed a DB with the *full app schema* (transactions + categories +
/// keywords + suppliers + adjustments + ...) then apply BALANCE_SCHEMA on
/// top — mirroring what migration v9 does on an existing user profile.
/// Returns the connection ready for assertions.
fn seeded_db_with_balance_schema() -> Connection {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute("PRAGMA foreign_keys = ON;", [])
.expect("enable FKs");
// Apply the full app schema (v1) — we only need the transactions
// table for the v9 FK, but applying the whole schema verifies that
// nothing in v9 collides with the existing tables.
conn.execute_batch(crate::database::SCHEMA)
.expect("apply v1 SCHEMA");
// Pre-seed a few transactions to mimic an existing profile (the user
// already had data when we shipped v9).
conn.execute_batch(
"INSERT INTO transactions (date, description, amount) VALUES
('2026-01-15', 'Salary deposit', 3500.0),
('2026-02-01', 'Wealthsimple contribution', -400.0),
('2026-03-15', 'Grocery store', -125.50),
('2026-04-01', 'Wealthsimple contribution', -400.0);",
)
.expect("seed transactions");
// Now apply v9 on top — same way the runtime would.
conn.execute_batch(crate::database::BALANCE_SCHEMA)
.expect("apply v9 BALANCE_SCHEMA on seeded DB");
conn
}
#[test]
fn migration_v9_preserves_existing_transactions_on_seeded_db() {
let conn = seeded_db_with_balance_schema();
// Existing transactions must be untouched by the migration.
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM transactions", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 4, "existing transactions must survive the migration");
// Spot-check one row's content (no silent data mutation).
let amount: f64 = conn
.query_row(
"SELECT amount FROM transactions WHERE description = 'Salary deposit'",
[],
|row| row.get(0),
)
.unwrap();
assert!(
(amount - 3500.0).abs() < f64::EPSILON,
"salary amount must be preserved verbatim"
);
// The seeded categories from BALANCE_SCHEMA must coexist with the
// pre-existing categories table from v1 (different name, no clash).
let bal_cat_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(bal_cat_count, 7);
}
#[test]
fn integration_link_unlink_transfer_roundtrip_on_seeded_db() {
let conn = seeded_db_with_balance_schema();
// Create a balance account on the seeded 'cash' category.
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Wealthsimple cash')",
[],
)
.unwrap();
let account_id: i64 = conn
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
.unwrap();
// Pick the Feb contribution (-$400) — a typical "in" transfer for the
// Wealthsimple account from the bank perspective.
let tx_id: i64 = conn
.query_row(
"SELECT id FROM transactions WHERE date = '2026-02-01'",
[],
|r| r.get(0),
)
.unwrap();
// 1. Link
let inserted = conn
.execute(
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction, notes)
VALUES (?1, ?2, 'in', 'monthly contribution')",
rusqlite::params![account_id, tx_id],
)
.expect("link succeeds with real transaction id");
assert_eq!(inserted, 1);
// 2. Verify the row is queryable through the joined view used by
// `listAccountTransfers` in TS.
let (joined_amount, direction): (f64, String) = conn
.query_row(
"SELECT t.amount, bat.direction
FROM balance_account_transfers bat
JOIN transactions t ON t.id = bat.transaction_id
WHERE bat.account_id = ?1",
rusqlite::params![account_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.expect("joined view must read");
assert!((joined_amount - (-400.0)).abs() < f64::EPSILON);
assert_eq!(direction, "in");
// 3. Try to delete the linked transaction — must be blocked (RESTRICT).
let blocked = conn.execute(
"DELETE FROM transactions WHERE id = ?1",
rusqlite::params![tx_id],
);
assert!(
blocked.is_err(),
"linked transaction deletion must be blocked by FK RESTRICT"
);
// 4. Unlink
let unlinked = conn
.execute(
"DELETE FROM balance_account_transfers
WHERE account_id = ?1 AND transaction_id = ?2",
rusqlite::params![account_id, tx_id],
)
.expect("unlink succeeds");
assert_eq!(unlinked, 1);
// 5. After unlink, deleting the transaction must succeed.
let allowed = conn
.execute(
"DELETE FROM transactions WHERE id = ?1",
rusqlite::params![tx_id],
)
.expect("after unlink, transaction can be deleted");
assert_eq!(allowed, 1);
// 6. Sanity: no orphan transfer rows survived.
let remaining_links: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_account_transfers WHERE transaction_id = ?1",
rusqlite::params![tx_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(remaining_links, 0);
}
#[test]
fn integration_modified_dietz_inputs_read_back_correctly_on_seeded_db() {
// Reads back the snapshot endpoints + cash flows the way
// `compute_account_return` does, on a DB that has both v1 transactions
// and v9 balance tables. Asserts the SQL queries used by
// `balance_commands.rs::read_value_at_or_before` and `read_cash_flows`
// return the expected shapes.
let conn = seeded_db_with_balance_schema();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Wealthsimple cash')",
[],
)
.unwrap();
let account_id: i64 = conn
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
.unwrap();
// Two snapshot endpoints (V_start, V_end) and one mid-period contribution.
conn.execute(
"INSERT INTO balance_snapshots (snapshot_date) VALUES
('2026-01-01'),
('2026-04-01')",
[],
)
.unwrap();
let s_start: i64 = conn
.query_row(
"SELECT id FROM balance_snapshots WHERE snapshot_date='2026-01-01'",
[],
|r| r.get(0),
)
.unwrap();
let s_end: i64 = conn
.query_row(
"SELECT id FROM balance_snapshots WHERE snapshot_date='2026-04-01'",
[],
|r| r.get(0),
)
.unwrap();
conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
VALUES (?1, ?2, 1000.0)",
rusqlite::params![s_start, account_id],
)
.unwrap();
conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
VALUES (?1, ?2, 1500.0)",
rusqlite::params![s_end, account_id],
)
.unwrap();
// Link the Feb 1 contribution as an `in` transfer.
let tx_id: i64 = conn
.query_row(
"SELECT id FROM transactions WHERE date='2026-02-01'",
[],
|r| r.get(0),
)
.unwrap();
conn.execute(
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
VALUES (?1, ?2, 'in')",
rusqlite::params![account_id, tx_id],
)
.unwrap();
// Mirror `read_value_at_or_before` for V_start — exact SQL used in
// `balance_commands.rs`.
let v_start: Option<f64> = conn
.query_row(
"SELECT l.value
FROM balance_snapshot_lines l
JOIN balance_snapshots s ON s.id = l.snapshot_id
WHERE l.account_id = ?1
AND s.snapshot_date <= ?2
ORDER BY s.snapshot_date DESC
LIMIT 1",
rusqlite::params![account_id, "2026-01-01"],
|r| r.get(0),
)
.ok();
assert_eq!(v_start, Some(1000.0));
// V_end at 2026-04-01 — picks up the second snapshot.
let v_end: Option<f64> = conn
.query_row(
"SELECT l.value
FROM balance_snapshot_lines l
JOIN balance_snapshots s ON s.id = l.snapshot_id
WHERE l.account_id = ?1
AND s.snapshot_date <= ?2
ORDER BY s.snapshot_date DESC
LIMIT 1",
rusqlite::params![account_id, "2026-04-01"],
|r| r.get(0),
)
.ok();
assert_eq!(v_end, Some(1500.0));
// Cash flows in [2026-01-01, 2026-04-01] — exactly one (-400 abs amount → +400 in).
let mut stmt = conn
.prepare(
"SELECT t.date, ABS(t.amount), bat.direction
FROM balance_account_transfers bat
JOIN transactions t ON t.id = bat.transaction_id
WHERE bat.account_id = ?1
AND t.date BETWEEN ?2 AND ?3
ORDER BY t.date",
)
.unwrap();
let flows: Vec<(String, f64, String)> = stmt
.query_map(
rusqlite::params![account_id, "2026-01-01", "2026-04-01"],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap()
.map(|r| r.unwrap())
.collect();
assert_eq!(flows.len(), 1);
assert_eq!(flows[0].0, "2026-02-01");
assert!((flows[0].1 - 400.0).abs() < f64::EPSILON);
assert_eq!(flows[0].2, "in");
}
#[test]
fn integration_v9_preserves_v1_categories_and_keywords() {
// Defensive: v9 introduces `balance_categories` while v1 already has
// `categories`. Make sure neither is mistaken for the other and that
// the v1 seeds (when present) survive the migration cleanly.
let conn = seeded_db_with_balance_schema();
// Insert a v1 category + keyword (mimicking v1 seed data already present).
conn.execute(
"INSERT INTO categories (id, name, type, color, sort_order)
VALUES (50, 'Épicerie', 'expense', '#10b981', 50)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO keywords (keyword, category_id, priority, is_active)
VALUES ('IGA', 50, 100, 1)",
[],
)
.unwrap();
// Now insert a v9 category with the SAME numeric id (should be allowed
// — different table, different namespace).
conn.execute(
"INSERT INTO balance_categories (id, key, i18n_key, kind, sort_order)
VALUES (50, 'mortgage', 'balance.category.mortgage', 'simple', 100)",
[],
)
.expect(
"balance_categories.id namespace must be independent from categories.id",
);
// The v1 row is untouched.
let v1_name: String = conn
.query_row(
"SELECT name FROM categories WHERE id = 50",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(v1_name, "Épicerie");
// The v9 row is queryable on its own table.
let v9_key: String = conn
.query_row(
"SELECT key FROM balance_categories WHERE id = 50",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(v9_key, "mortgage");
}
// =========================================================================
// Migration v10 — add asset_type to balance_categories
// -------------------------------------------------------------------------
// The v10 SQL applies on top of v9 (column add + backfill of the two priced
// seeds). These tests are statement-equivalent to the production migration:
// they execute the same SQL string used in `lib.rs`'s Migration array.
// =========================================================================
/// Production v10 SQL — kept in sync with the Migration { version: 10 }
/// entry above. Tests apply this on top of fresh_db() (which already ran v9).
const V10_SQL: &str = "ALTER TABLE balance_categories ADD COLUMN asset_type TEXT \
CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto')); \
UPDATE balance_categories SET asset_type = 'stock' \
WHERE key = 'stock' AND is_seed = 1; \
UPDATE balance_categories SET asset_type = 'crypto' \
WHERE key = 'crypto' AND is_seed = 1;";
#[test]
fn migration_v10_adds_asset_type_column() {
let conn = fresh_db();
conn.execute_batch(V10_SQL).expect("apply v10");
// pragma_table_info should now list `asset_type` for balance_categories.
let cols: Vec<String> = conn
.prepare("SELECT name FROM pragma_table_info('balance_categories')")
.unwrap()
.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
assert!(
cols.contains(&"asset_type".to_string()),
"asset_type column should exist after v10, got {:?}",
cols
);
}
#[test]
fn migration_v10_backfills_priced_seeds() {
let conn = fresh_db();
conn.execute_batch(V10_SQL).expect("apply v10");
let stock: String = conn
.query_row(
"SELECT asset_type FROM balance_categories WHERE key = 'stock'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(stock, "stock");
let crypto: String = conn
.query_row(
"SELECT asset_type FROM balance_categories WHERE key = 'crypto'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(crypto, "crypto");
// The 5 simple seeds must remain NULL.
for key in &["cash", "tfsa", "rrsp", "fund", "other"] {
let v: Option<String> = conn
.query_row(
"SELECT asset_type FROM balance_categories WHERE key = ?1",
[key],
|r| r.get(0),
)
.unwrap();
assert!(v.is_none(), "simple seed {} should have NULL asset_type", key);
}
}
#[test]
fn migration_v10_leaves_custom_priced_rows_null() {
let conn = fresh_db();
// Insert a custom priced category BEFORE running v10 — the column
// doesn't exist yet, so it has no asset_type cell to populate.
conn.execute(
"INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) \
VALUES ('mining_etf', 'x.mining', 'priced', 100, 0)",
[],
)
.expect("insert custom priced row");
conn.execute_batch(V10_SQL).expect("apply v10");
let v: Option<String> = conn
.query_row(
"SELECT asset_type FROM balance_categories WHERE key = 'mining_etf'",
[],
|r| r.get(0),
)
.unwrap();
assert!(
v.is_none(),
"legacy custom priced rows must stay NULL post-migration (no fetch button until user edits)"
);
}
#[test]
fn migration_v10_check_rejects_invalid_asset_type() {
let conn = fresh_db();
conn.execute_batch(V10_SQL).expect("apply v10");
let res = conn.execute(
"INSERT INTO balance_categories (key, i18n_key, kind, asset_type) \
VALUES ('bogus', 'x', 'priced', 'gold')",
[],
);
assert!(
res.is_err(),
"CHECK should reject asset_type values outside ('stock','crypto')"
);
}
// =========================================================================
// Migration v11 — cleanup orphan balance snapshots (#176)
// -------------------------------------------------------------------------
// Validates that the v11 SQL deletes snapshot rows that have no associated
// lines (left behind by the pre-#176 race) while preserving rows that have
// at least one line. Statement-equivalent to the production migration.
// =========================================================================
/// Production v11 SQL — kept in sync with the Migration { version: 11 }
/// entry above.
const V11_SQL: &str = "DELETE FROM balance_snapshots \
WHERE NOT EXISTS ( \
SELECT 1 FROM balance_snapshot_lines \
WHERE snapshot_id = balance_snapshots.id);";
#[test]
fn migration_v11_deletes_orphan_snapshots() {
let conn = fresh_db();
conn.execute_batch(V10_SQL).expect("apply v10");
// Seed an orphan: snapshot with NO lines.
conn.execute(
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-01-15')",
[],
)
.unwrap();
let orphan_id: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
// Seed a healthy snapshot with one line — needs an account first.
// Use the seeded `cash` simple category from v9.
let cash_cat_id: 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, 'Test')",
[cash_cat_id],
)
.unwrap();
let acc_id: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-02-15')",
[],
)
.unwrap();
let healthy_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, value, price_source) \
VALUES (?1, ?2, 100.0, 'manual')",
[healthy_id, acc_id],
)
.unwrap();
// Pre-conditions.
let pre_count: i64 = conn
.query_row("SELECT COUNT(*) FROM balance_snapshots", [], |r| r.get(0))
.unwrap();
assert_eq!(pre_count, 2);
// Apply v11.
conn.execute_batch(V11_SQL).expect("apply v11");
// Orphan gone, healthy preserved.
let post_count: i64 = conn
.query_row("SELECT COUNT(*) FROM balance_snapshots", [], |r| r.get(0))
.unwrap();
assert_eq!(post_count, 1, "v11 should delete only the orphan");
let surviving_id: i64 = conn
.query_row("SELECT id FROM balance_snapshots", [], |r| r.get(0))
.unwrap();
assert_eq!(surviving_id, healthy_id);
// And ensure the orphan id is gone.
let still_orphan: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_snapshots WHERE id = ?1",
[orphan_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(still_orphan, 0);
}
#[test]
fn migration_v11_is_idempotent_on_clean_db() {
let conn = fresh_db();
conn.execute_batch(V10_SQL).expect("apply v10");
// Empty balance_snapshots — running v11 should be a no-op.
conn.execute_batch(V11_SQL).expect("apply v11");
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM balance_snapshots", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
}
// =========================================================================
// Migrations v12 + v13 — Bilan axe véhicule (Étape 1) — issue #202
// -------------------------------------------------------------------------
// v12 (additive): adds `vehicle_type` (fiscal envelope — NOT an automobile)
// to balance_accounts and `custom_label` to balance_categories, backfills
// the envelope onto ex-tfsa/rrsp accounts (cash stays NULL), and recovers
// any seed i18n_key that bug I had overwritten with free text.
// v13 (reclass): re-links the ex-tfsa/rrsp accounts to the `other` asset
// class and deactivates the two now-deprecated envelope seeds. Conditional
// + idempotent (no Down migration exists).
//
// Both constants are statement-equivalent to the Migration { version: 12/13 }
// entries in `lib.rs` above (kept in sync by hand, same pattern as V10/V11).
// =========================================================================
/// Production v12 SQL — kept in sync with the Migration { version: 12 } entry.
const V12_SQL: &str = "ALTER TABLE balance_accounts ADD COLUMN vehicle_type TEXT \
CHECK(vehicle_type IS NULL OR vehicle_type IN \
('unregistered','tfsa','rrsp','rrif','fhsa','resp')); \
ALTER TABLE balance_categories ADD COLUMN custom_label TEXT; \
UPDATE balance_accounts SET vehicle_type = 'tfsa' \
WHERE balance_category_id = ( \
SELECT id FROM balance_categories WHERE key = 'tfsa'); \
UPDATE balance_accounts SET vehicle_type = 'rrsp' \
WHERE balance_category_id = ( \
SELECT id FROM balance_categories WHERE key = 'rrsp'); \
UPDATE balance_categories SET custom_label = i18n_key \
WHERE is_seed = 1 AND i18n_key NOT LIKE 'balance.category.%'; \
UPDATE balance_categories SET i18n_key = 'balance.category.' || key \
WHERE is_seed = 1 AND i18n_key NOT LIKE 'balance.category.%';";
/// Production v13 SQL — kept in sync with the Migration { version: 13 } entry.
const V13_SQL: &str = "UPDATE balance_accounts \
SET balance_category_id = ( \
SELECT id FROM balance_categories WHERE key = 'other' AND is_seed = 1) \
WHERE balance_category_id IN ( \
SELECT id FROM balance_categories WHERE key IN ('tfsa','rrsp') AND is_seed = 1) \
AND EXISTS ( \
SELECT 1 FROM balance_categories WHERE key = 'other' AND is_seed = 1); \
UPDATE balance_categories SET is_active = 0 \
WHERE key IN ('tfsa','rrsp') AND is_seed = 1;";
/// Apply the full v9→v11 chain on a fresh DB so v12/v13 tests start from a
/// realistic post-v11 state (v9 seeds + v10 asset_type + v11 cleanup).
fn db_through_v11() -> Connection {
let conn = fresh_db(); // already applied BALANCE_SCHEMA (v9)
conn.execute_batch(V10_SQL).expect("apply v10");
conn.execute_batch(V11_SQL).expect("apply v11");
conn
}
#[test]
fn migration_v12_adds_columns_and_backfills_vehicle() {
let conn = db_through_v11();
// Seed accounts across the relevant seed categories BEFORE v12 so the
// backfill has something to act on.
for key in &["cash", "tfsa", "rrsp", "other"] {
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name) \
VALUES ((SELECT id FROM balance_categories WHERE key = ?1), ?2)",
rusqlite::params![key, format!("acct-{key}")],
)
.unwrap();
}
conn.execute_batch(V12_SQL).expect("apply v12");
// New columns exist.
let acct_cols: Vec<String> = conn
.prepare("SELECT name FROM pragma_table_info('balance_accounts')")
.unwrap()
.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
assert!(acct_cols.contains(&"vehicle_type".to_string()));
let cat_cols: Vec<String> = conn
.prepare("SELECT name FROM pragma_table_info('balance_categories')")
.unwrap()
.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
assert!(cat_cols.contains(&"custom_label".to_string()));
// tfsa / rrsp accounts get the envelope; cash + other stay NULL.
let read_vehicle = |name: &str| -> Option<String> {
conn.query_row(
"SELECT vehicle_type FROM balance_accounts WHERE name = ?1",
[name],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(read_vehicle("acct-tfsa").as_deref(), Some("tfsa"));
assert_eq!(read_vehicle("acct-rrsp").as_deref(), Some("rrsp"));
assert!(read_vehicle("acct-cash").is_none(), "cash must stay NULL");
assert!(read_vehicle("acct-other").is_none(), "other must stay NULL");
}
#[test]
fn migration_v12_defensive_backfill_recovers_overwritten_i18n_key() {
// Simulate bug I: a seed category whose i18n_key was clobbered by a
// user-typed free-text label ("Mon CELI perso") instead of the
// canonical balance.category.tfsa key.
let conn = db_through_v11();
conn.execute(
"UPDATE balance_categories SET i18n_key = 'Mon CELI perso' WHERE key = 'tfsa'",
[],
)
.unwrap();
conn.execute_batch(V12_SQL).expect("apply v12");
// The free text is recovered into custom_label, the i18n_key restored.
let (i18n, custom): (String, Option<String>) = conn
.query_row(
"SELECT i18n_key, custom_label FROM balance_categories WHERE key = 'tfsa'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(i18n, "balance.category.tfsa");
assert_eq!(custom.as_deref(), Some("Mon CELI perso"));
// A seed whose i18n_key was already canonical keeps NULL custom_label.
let cash_custom: Option<String> = conn
.query_row(
"SELECT custom_label FROM balance_categories WHERE key = 'cash'",
[],
|r| r.get(0),
)
.unwrap();
assert!(cash_custom.is_none());
}
#[test]
fn migration_v12_check_rejects_invalid_vehicle_type() {
let conn = db_through_v11();
conn.execute_batch(V12_SQL).expect("apply v12");
let res = conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name, vehicle_type) \
VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'bad', 'car')",
[],
);
assert!(
res.is_err(),
"CHECK should reject a vehicle_type outside the fiscal enum"
);
}
#[test]
fn migration_v13_reclasses_accounts_and_deactivates_seeds() {
let conn = db_through_v11();
// Two ex-envelope accounts + an archived one, plus a cash account.
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name) \
VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI actif')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name) \
VALUES ((SELECT id FROM balance_categories WHERE key = 'rrsp'), 'REER actif')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name, is_active, archived_at) \
VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI archivé', 0, CURRENT_TIMESTAMP)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name) \
VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'Chèque')",
[],
)
.unwrap();
conn.execute_batch(V12_SQL).expect("apply v12");
conn.execute_batch(V13_SQL).expect("apply v13");
let other_id: i64 = conn
.query_row(
"SELECT id FROM balance_categories WHERE key = 'other' AND is_seed = 1",
[],
|r| r.get(0),
)
.unwrap();
// Every ex-tfsa/rrsp account (active OR archived) now points to `other`,
// with the envelope preserved on vehicle_type.
for (name, vehicle) in &[
("CELI actif", "tfsa"),
("REER actif", "rrsp"),
("CELI archivé", "tfsa"),
] {
let (cat_id, vt): (i64, Option<String>) = conn
.query_row(
"SELECT balance_category_id, vehicle_type FROM balance_accounts WHERE name = ?1",
[name],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(cat_id, other_id, "{name} should be re-linked to other");
assert_eq!(vt.as_deref(), Some(*vehicle), "{name} envelope preserved");
}
// The cash account is untouched.
let cash_cat: i64 = conn
.query_row(
"SELECT balance_category_id FROM balance_accounts WHERE name = 'Chèque'",
[],
|r| r.get(0),
)
.unwrap();
let cash_seed: i64 = conn
.query_row(
"SELECT id FROM balance_categories WHERE key = 'cash'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(cash_cat, cash_seed);
// The tfsa/rrsp seeds are deactivated; the asset-class seeds stay active.
let inactive: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_categories \
WHERE key IN ('tfsa','rrsp') AND is_seed = 1 AND is_active = 0",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(inactive, 2, "both envelope seeds must be deactivated");
let active: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_categories WHERE is_active = 1 AND is_seed = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(active, 5, "5 asset-class seeds remain active");
}
#[test]
fn migration_v9_to_v13_chain_preserves_snapshot_lines_and_transfers() {
// The headline regression test: run the WHOLE v9→v13 chain on a seeded
// profile with snapshots + a transfer on an ex-CELI account, and assert
// the financial history is byte-for-byte preserved across the reclass.
let conn = seeded_db_with_balance_schema(); // v9 applied + transactions
conn.execute_batch(V10_SQL).expect("apply v10");
conn.execute_batch(V11_SQL).expect("apply v11");
// A CELI account (ex-envelope) with two snapshot endpoints + a linked
// contribution transfer.
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name) \
VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'Wealthsimple CELI')",
[],
)
.unwrap();
let acct_id: i64 = conn
.query_row(
"SELECT id FROM balance_accounts WHERE name = 'Wealthsimple CELI'",
[],
|r| r.get(0),
)
.unwrap();
conn.execute(
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-01-01'), ('2026-04-01')",
[],
)
.unwrap();
let s_start: i64 = conn
.query_row(
"SELECT id FROM balance_snapshots WHERE snapshot_date = '2026-01-01'",
[],
|r| r.get(0),
)
.unwrap();
let s_end: i64 = conn
.query_row(
"SELECT id FROM balance_snapshots WHERE snapshot_date = '2026-04-01'",
[],
|r| r.get(0),
)
.unwrap();
conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) VALUES (?1, ?2, 1000.0)",
rusqlite::params![s_start, acct_id],
)
.unwrap();
conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) VALUES (?1, ?2, 1500.0)",
rusqlite::params![s_end, acct_id],
)
.unwrap();
let tx_id: i64 = conn
.query_row(
"SELECT id FROM transactions WHERE date = '2026-02-01'",
[],
|r| r.get(0),
)
.unwrap();
conn.execute(
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction) \
VALUES (?1, ?2, 'in')",
rusqlite::params![acct_id, tx_id],
)
.unwrap();
// Capture the snapshot_lines fingerprint BEFORE the reclass.
let lines_before: Vec<(i64, i64, f64)> = conn
.prepare("SELECT snapshot_id, account_id, value FROM balance_snapshot_lines ORDER BY id")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.unwrap()
.map(|r| r.unwrap())
.collect();
// Run the reclass chain.
conn.execute_batch(V12_SQL).expect("apply v12");
conn.execute_batch(V13_SQL).expect("apply v13");
// snapshot_lines are byte-for-byte identical (same ids, same values).
let lines_after: Vec<(i64, i64, f64)> = conn
.prepare("SELECT snapshot_id, account_id, value FROM balance_snapshot_lines ORDER BY id")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.unwrap()
.map(|r| r.unwrap())
.collect();
assert_eq!(
lines_before, lines_after,
"snapshot_lines must be identical before/after the reclass"
);
// The account moved to `other` and kept its CELI envelope.
let (cat_key, vt): (String, Option<String>) = conn
.query_row(
"SELECT c.key, a.vehicle_type \
FROM balance_accounts a JOIN balance_categories c ON c.id = a.balance_category_id \
WHERE a.id = ?1",
[acct_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(cat_key, "other");
assert_eq!(vt.as_deref(), Some("tfsa"));
// The transfer link is intact.
let transfer_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_account_transfers WHERE account_id = ?1 AND transaction_id = ?2",
rusqlite::params![acct_id, tx_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(transfer_count, 1, "transfer must survive the reclass");
}
#[test]
fn migration_v13_is_idempotent_on_replay() {
// Running v13 twice must not change anything the second time.
let conn = db_through_v11();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name) \
VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI')",
[],
)
.unwrap();
conn.execute_batch(V12_SQL).expect("apply v12");
conn.execute_batch(V13_SQL).expect("apply v13 #1");
let other_id: i64 = conn
.query_row(
"SELECT id FROM balance_categories WHERE key = 'other' AND is_seed = 1",
[],
|r| r.get(0),
)
.unwrap();
// Second run: no-op.
conn.execute_batch(V13_SQL).expect("apply v13 #2");
let cat_id: i64 = conn
.query_row(
"SELECT balance_category_id FROM balance_accounts WHERE name = 'CELI'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(cat_id, other_id, "account stays on other after replay");
}
#[test]
fn migration_v13_guard_noop_when_other_seed_missing() {
// If the `other` seed is absent (e.g. hard-deleted), v13 must NOT move
// accounts to a NULL category — the EXISTS guard makes it a clean no-op.
let conn = db_through_v11();
// Add a CELI account, then remove the `other` seed (no FK refs to it).
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name) \
VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI')",
[],
)
.unwrap();
let tfsa_id: i64 = conn
.query_row(
"SELECT id FROM balance_categories WHERE key = 'tfsa'",
[],
|r| r.get(0),
)
.unwrap();
conn.execute("DELETE FROM balance_categories WHERE key = 'other'", [])
.unwrap();
conn.execute_batch(V12_SQL).expect("apply v12");
conn.execute_batch(V13_SQL).expect("apply v13");
// The account stays on tfsa (no NULL category, no broken FK).
let cat_id: Option<i64> = conn
.query_row(
"SELECT balance_category_id FROM balance_accounts WHERE name = 'CELI'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
cat_id,
Some(tfsa_id),
"without `other`, the account must not be moved to NULL"
);
}
// =========================================================================
// Migrations v14 + v15 — Bilan détail par titre (Étape 2) — issue #210
// -------------------------------------------------------------------------
// v14 (additive): creates balance_securities (instrument catalogue, symbol
// normalized + COLLATE NOCASE UNIQUE) and balance_snapshot_holdings (per-
// security breakdown of a snapshot line; CASCADE on line, RESTRICT on
// security) + 2 indexes.
// v15 (account pivot): adds `kind` (simple|detailed) + `detailed_since` to
// balance_accounts and backfills kind='detailed' on accounts under a priced
// category. Purely additive; no Down migration exists.
//
// V14_SQL is the BALANCE_HOLDINGS_SCHEMA constant (applied verbatim by the
// Migration { version: 14 } entry). V15_SQL is statement-equivalent to the
// Migration { version: 15 } entry, kept in sync by hand (same pattern as
// V10..V13 above).
// =========================================================================
/// Production v14 SQL — applied verbatim by Migration { version: 14 }.
const V14_SQL: &str = crate::database::BALANCE_HOLDINGS_SCHEMA;
/// Production v15 SQL — kept in sync with the Migration { version: 15 } entry.
const V15_SQL: &str = "ALTER TABLE balance_accounts ADD COLUMN kind TEXT NOT NULL DEFAULT 'simple' \
CHECK (kind IN ('simple','detailed')); \
ALTER TABLE balance_accounts ADD COLUMN detailed_since DATE; \
UPDATE balance_accounts SET kind = 'detailed' \
WHERE balance_category_id IN ( \
SELECT id FROM balance_categories WHERE kind = 'priced');";
/// Apply the full v10→v13 chain on a fresh DB so v14/v15 tests start from a
/// realistic post-Étape-1 state.
fn db_through_v13() -> Connection {
let conn = fresh_db(); // v9 BALANCE_SCHEMA
conn.execute_batch(V10_SQL).expect("apply v10");
conn.execute_batch(V11_SQL).expect("apply v11");
conn.execute_batch(V12_SQL).expect("apply v12");
conn.execute_batch(V13_SQL).expect("apply v13");
conn
}
#[test]
fn migration_v14_creates_securities_and_holdings_tables() {
let conn = db_through_v13();
conn.execute_batch(V14_SQL).expect("apply v14");
// Both tables exist.
for table in &["balance_securities", "balance_snapshot_holdings"] {
let n: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table],
|r| r.get(0),
)
.unwrap();
assert_eq!(n, 1, "{table} must be created by v14");
}
// Both indexes exist.
for idx in &[
"idx_balance_snapshot_holdings_line",
"idx_balance_snapshot_holdings_security",
] {
let n: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?1",
[idx],
|r| r.get(0),
)
.unwrap();
assert_eq!(n, 1, "{idx} must be created by v14");
}
}
#[test]
fn migration_v14_symbol_is_case_insensitive_unique() {
let conn = db_through_v13();
conn.execute_batch(V14_SQL).expect("apply v14");
conn.execute(
"INSERT INTO balance_securities (symbol, asset_type) VALUES ('AAPL', 'stock')",
[],
)
.expect("first insert");
// A case-variant of the same symbol must collide on the NOCASE UNIQUE.
let res = conn.execute(
"INSERT INTO balance_securities (symbol, asset_type) VALUES ('aapl', 'stock')",
[],
);
assert!(
res.is_err(),
"COLLATE NOCASE UNIQUE must reject 'aapl' after 'AAPL'"
);
// currency defaults to CAD.
let currency: String = conn
.query_row(
"SELECT currency FROM balance_securities WHERE symbol = 'AAPL'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(currency, "CAD");
}
#[test]
fn migration_v14_check_rejects_invalid_asset_type() {
let conn = db_through_v13();
conn.execute_batch(V14_SQL).expect("apply v14");
let res = conn.execute(
"INSERT INTO balance_securities (symbol, asset_type) VALUES ('GLD', 'gold')",
[],
);
assert!(
res.is_err(),
"CHECK should reject asset_type outside ('stock','crypto')"
);
}
#[test]
fn migration_v14_holdings_fk_policies_cascade_and_restrict() {
let conn = db_through_v13();
conn.execute_batch(V14_SQL).expect("apply v14");
// Build a snapshot line on a (priced) account so a holding can attach.
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) VALUES (?1, 'Courtage')",
[stock_cat],
)
.unwrap();
let acc_id: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
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) \
VALUES (?1, ?2, 10.0, 50.0, 500.0)",
rusqlite::params![snap_id, acc_id],
)
.unwrap();
let line_id: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO balance_securities (symbol, asset_type) VALUES ('AAPL', 'stock')",
[],
)
.unwrap();
let sec_id: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO balance_snapshot_holdings \
(snapshot_line_id, security_id, quantity, unit_price, value) \
VALUES (?1, ?2, 10.0, 50.0, 500.0)",
rusqlite::params![line_id, sec_id],
)
.unwrap();
// RESTRICT: deleting a referenced security must fail.
let del_sec = conn.execute(
"DELETE FROM balance_securities WHERE id = ?1",
[sec_id],
);
assert!(
del_sec.is_err(),
"ON DELETE RESTRICT must block deleting a referenced security"
);
// CASCADE: deleting the snapshot line removes its holdings.
conn.execute("DELETE FROM balance_snapshot_lines WHERE id = ?1", [line_id])
.unwrap();
let holdings_left: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1",
[line_id],
|r| r.get(0),
)
.unwrap();
assert_eq!(holdings_left, 0, "CASCADE must remove holdings with the line");
}
#[test]
fn migration_v14_holdings_unique_line_security() {
let conn = db_through_v13();
conn.execute_batch(V14_SQL).expect("apply v14");
// Minimal line + security.
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) VALUES (?1, 'C')",
[stock_cat],
)
.unwrap();
let acc_id: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-06-02')",
[],
)
.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) \
VALUES (?1, ?2, 1.0, 1.0, 1.0)",
rusqlite::params![snap_id, acc_id],
)
.unwrap();
let line_id: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO balance_securities (symbol, asset_type) VALUES ('BTC', 'crypto')",
[],
)
.unwrap();
let sec_id: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO balance_snapshot_holdings (snapshot_line_id, security_id, quantity, unit_price, value) \
VALUES (?1, ?2, 1.0, 1.0, 1.0)",
rusqlite::params![line_id, sec_id],
)
.unwrap();
// Second holding of the same security on the same line must collide.
let dup = conn.execute(
"INSERT INTO balance_snapshot_holdings (snapshot_line_id, security_id, quantity, unit_price, value) \
VALUES (?1, ?2, 2.0, 2.0, 4.0)",
rusqlite::params![line_id, sec_id],
);
assert!(
dup.is_err(),
"UNIQUE(snapshot_line_id, security_id) must reject a duplicate holding"
);
}
#[test]
fn migration_v15_adds_columns_and_backfills_priced_accounts() {
let conn = db_through_v13();
conn.execute_batch(V14_SQL).expect("apply v14");
// A priced (stock) account and a simple (cash) account, both pre-v15.
let stock_cat: i64 = conn
.query_row(
"SELECT id FROM balance_categories WHERE key = 'stock'",
[],
|r| r.get(0),
)
.unwrap();
let cash_cat: 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, 'Courtage')",
[stock_cat],
)
.unwrap();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'Chèque')",
[cash_cat],
)
.unwrap();
conn.execute_batch(V15_SQL).expect("apply v15");
// New columns exist.
let cols: Vec<String> = conn
.prepare("SELECT name FROM pragma_table_info('balance_accounts')")
.unwrap()
.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
assert!(cols.contains(&"kind".to_string()));
assert!(cols.contains(&"detailed_since".to_string()));
// Backfill: priced account → detailed; simple account stays simple.
let read_kind = |name: &str| -> String {
conn.query_row(
"SELECT kind FROM balance_accounts WHERE name = ?1",
[name],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(read_kind("Courtage"), "detailed", "priced account → detailed");
assert_eq!(read_kind("Chèque"), "simple", "cash account stays simple");
// detailed_since stays NULL for both (pivot set later by #211).
let ds: Option<String> = conn
.query_row(
"SELECT detailed_since FROM balance_accounts WHERE name = 'Courtage'",
[],
|r| r.get(0),
)
.unwrap();
assert!(ds.is_none(), "detailed_since must be NULL until conversion");
}
#[test]
fn migration_v15_check_rejects_invalid_kind() {
let conn = db_through_v13();
conn.execute_batch(V14_SQL).expect("apply v14");
conn.execute_batch(V15_SQL).expect("apply v15");
let res = conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name, kind) \
VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'bad', 'partial')",
[],
);
assert!(
res.is_err(),
"CHECK should reject a kind outside ('simple','detailed')"
);
}
#[test]
fn migration_v15_default_kind_is_simple() {
let conn = db_through_v13();
conn.execute_batch(V14_SQL).expect("apply v14");
conn.execute_batch(V15_SQL).expect("apply v15");
// An account inserted post-v15 without specifying kind defaults to simple.
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name) \
VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'New cash')",
[],
)
.unwrap();
let kind: String = conn
.query_row(
"SELECT kind FROM balance_accounts WHERE name = 'New cash'",
[],
|r| r.get(0),
)
.unwrap();
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
// -------------------------------------------------------------------------
// The consolidated schema must initialize a brand-new profile cleanly with
// the post-Étape-1 shape: 5 asset-class categories, 4 starter accounts (no
// NULL category_id), and the CELI/REER starters carrying vehicle_type.
/// Apply the full consolidated schema on a fresh in-memory DB, the same way
/// `get_new_profile_init_sql` does for a brand-new profile.
fn consolidated_db() -> Connection {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute("PRAGMA foreign_keys = ON;", [])
.expect("enable FKs");
conn.execute_batch(crate::database::CONSOLIDATED_SCHEMA)
.expect("apply consolidated schema");
conn
}
#[test]
fn consolidated_schema_inits_cleanly_with_5_categories_and_4_starters() {
let conn = consolidated_db();
// Exactly 5 active asset-class seeds, no tfsa/rrsp.
let active_seeds: Vec<String> = conn
.prepare(
"SELECT key FROM balance_categories WHERE is_seed = 1 AND is_active = 1 ORDER BY sort_order",
)
.unwrap()
.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
assert_eq!(
active_seeds,
vec!["cash", "fund", "other", "stock", "crypto"],
"new profiles ship exactly 5 asset-class seeds (no envelope categories)"
);
let envelope_seeds: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_categories WHERE key IN ('tfsa','rrsp')",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(envelope_seeds, 0, "no tfsa/rrsp category in a new profile");
// 4 starter accounts, none with a NULL category (#1 review risk).
let starter_count: i64 = conn
.query_row("SELECT COUNT(*) FROM balance_accounts", [], |r| r.get(0))
.unwrap();
assert_eq!(starter_count, 4);
let null_cat: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_accounts WHERE balance_category_id IS NULL",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(null_cat, 0, "no starter may have a NULL balance_category_id");
// The CELI / REER starters live in `other` and carry their envelope.
let other_id: i64 = conn
.query_row(
"SELECT id FROM balance_categories WHERE key = 'other'",
[],
|r| r.get(0),
)
.unwrap();
for (name, vehicle) in &[("CELI", "tfsa"), ("REER", "rrsp")] {
let (cat_id, vt): (i64, Option<String>) = conn
.query_row(
"SELECT balance_category_id, vehicle_type FROM balance_accounts WHERE name = ?1",
[name],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(cat_id, other_id, "{name} starter must attach to other");
assert_eq!(vt.as_deref(), Some(*vehicle), "{name} carries its envelope");
}
// The plain starters have a NULL envelope.
for name in &["Compte chèque", "Compte non-enregistré"] {
let vt: Option<String> = conn
.query_row(
"SELECT vehicle_type FROM balance_accounts WHERE name = ?1",
[name],
|r| r.get(0),
)
.unwrap();
assert!(vt.is_none(), "{name} must have a NULL vehicle_type");
}
}
#[test]
fn consolidated_schema_check_rejects_invalid_vehicle_type() {
let conn = consolidated_db();
let res = conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name, vehicle_type) \
VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'bad', 'truck')",
[],
);
assert!(
res.is_err(),
"consolidated CHECK should reject a vehicle_type outside the fiscal enum"
);
}
#[test]
fn consolidated_schema_has_holdings_tables_and_kind_at_parity() {
// The consolidated schema (new profiles) must ship the v14 tables and
// the v15 account columns from the start — parity with the migrations.
let conn = consolidated_db();
for table in &["balance_securities", "balance_snapshot_holdings"] {
let n: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table],
|r| r.get(0),
)
.unwrap();
assert_eq!(n, 1, "new profiles must ship {table}");
}
for idx in &[
"idx_balance_snapshot_holdings_line",
"idx_balance_snapshot_holdings_security",
] {
let n: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?1",
[idx],
|r| r.get(0),
)
.unwrap();
assert_eq!(n, 1, "new profiles must ship {idx}");
}
// The kind/detailed_since columns exist on balance_accounts.
let cols: Vec<String> = conn
.prepare("SELECT name FROM pragma_table_info('balance_accounts')")
.unwrap()
.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
assert!(cols.contains(&"kind".to_string()));
assert!(cols.contains(&"detailed_since".to_string()));
// All 4 starters sit under simple asset classes (cash/other), so the
// v15 backfill is a no-op here — every starter is 'simple'.
let detailed: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_accounts WHERE kind = 'detailed'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(detailed, 0, "no starter account is under a priced category");
// The NOCASE UNIQUE on securities is enforced in the consolidated path too.
conn.execute(
"INSERT INTO balance_securities (symbol, asset_type) VALUES ('SHOP', 'stock')",
[],
)
.unwrap();
let dup = conn.execute(
"INSERT INTO balance_securities (symbol, asset_type) VALUES ('shop', 'stock')",
[],
);
assert!(dup.is_err(), "consolidated securities must reject case-dupes");
}
}