Simpl-Resultat/src-tauri/src/lib.rs
le king fu e4a0f1042e
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
PR Check — Rust / rust (pull_request) Successful in 3m38s
feat(onboarding): create_sample_import_folders command, sample CSV templates and detection test (#354)
Add a Tauri command that writes two synthetic bank statements (Desjardins
chequing account, Tangerine credit card) into the import folder, one
sub-folder each, so a new user has sources to import on the first run.

The command takes the import folder and the webview's local date only
(chrono is compiled without `clock`), renders the `{{M1}}` / `{{M2}}`
tokens as the two complete months before that date, names each file
`releve-YYYY-MM.csv` after the most recent one, and refuses to run when
the folder is missing or when either sample folder already exists -
checked before anything is written. It returns the two folder names.

The templates live under `src/__fixtures__/samples/` so the Rust command
(`include_str!`) and vitest read the same bytes. The integration test
proves detection recognises each file as its bank with a signed single
amount and reads 40/40 rows, that the production `mapRow` parses every
row and sums to +740.00 / -368.46, and that the 180 seed keywords
categorise exactly 33 / 36 lines while leaving the hand-classified ones
untouched. The frozen detection corpus (`src/__fixtures__/csv`) is not
modified.

Five Rust tests cover the missing folder, the existing folder, the
success path, an unparseable date and the year boundary, each in its own
sandbox so they run in parallel.

CHANGELOG and architecture docs are centralised in #357.

Resolves #354

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 19:50:41 -04:00

3927 lines
165 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 \
JOIN balance_categories c ON c.id = a.balance_category_id \
WHERE a.symbol IS NOT NULL AND c.asset_type 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,
},
// Migration v17 — the full import format on the sources themselves (#323).
//
// Until now `import_sources` carried only the mechanical CSV settings
// (delimiter, encoding, date format, column mapping). The two fields
// that decide how an amount is READ — `amount_mode` and
// `sign_convention` — existed only on `import_config_templates`. That
// asymmetry is the root bug of this chantier: restoring a saved source
// re-inferred the mode from the mapping and hardcoded
// `signConvention: "negative_expense"` (`useImportWizard.ts:321-323`),
// so a source configured with positive expenses silently flipped back
// on its second import. After v17 both tables carry the same eight
// format fields and the asymmetry is gone structurally.
//
// All four columns are additive and either defaulted or nullable, so
// the ALTERs are safe on a populated v16 database:
// - `amount_mode` / `sign_convention` carry a CHECK, same pattern as
// the v15 `balance_accounts.kind`. `amount_mode` admits
// 'absolute_indicator' from the start: that third mode (absolute
// amount + a D/C indicator column) is out of scope today but must
// be implementable without another migration, and the constraint
// still refuses a corrupted value right now.
// - `header_signature` stores the normalized header labels seen at
// the last successful import, for drift detection. NULL until an
// import records one, and permanently NULL for headerless files.
// - `template_id` is a PROVENANCE TAG only, never re-read as format:
// the eight columns of the source are authoritative, so editing a
// template must not change any linked source. ON DELETE SET NULL
// keeps the source and its format intact when a template is deleted.
//
// The backfill reproduces EXACTLY the rule the wizard applies on the
// fly today (`useImportWizard.ts:321` — a mapping carrying
// `debitAmount` means debit/credit, anything else means single amount),
// so no existing source changes behaviour on migration. The test is
// `LIKE '%debitAmount%'` rather than `json_extract` so the migration
// depends on no JSON1 extension in the bundled SQLite. `sign_convention`
// is deliberately NOT backfilled: its DEFAULT restores precisely the
// value the code hardcoded, which is the only past convention that can
// be inferred — guessing anything else would silently rewrite meaning.
Migration {
version: 17,
description: "add amount_mode, sign_convention, header_signature and template_id to import_sources",
sql: "ALTER TABLE import_sources ADD COLUMN amount_mode TEXT NOT NULL DEFAULT 'single' \
CHECK (amount_mode IN ('single','debit_credit','absolute_indicator')); \
ALTER TABLE import_sources ADD COLUMN sign_convention TEXT NOT NULL DEFAULT 'negative_expense' \
CHECK (sign_convention IN ('negative_expense','positive_expense')); \
ALTER TABLE import_sources ADD COLUMN header_signature TEXT; \
ALTER TABLE import_sources ADD COLUMN template_id INTEGER \
REFERENCES import_config_templates(id) ON DELETE SET NULL; \
UPDATE import_sources SET amount_mode = 'debit_credit' \
WHERE column_mapping LIKE '%debitAmount%';",
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::create_sample_import_folders,
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::check_entitlement,
commands::activate_machine,
commands::deactivate_machine,
commands::list_activated_machines,
commands::get_activation_status,
commands::check_license_revocation,
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 \
JOIN balance_categories c ON c.id = a.balance_category_id \
WHERE a.symbol IS NOT NULL AND c.asset_type 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_leaves_simple_account_with_residual_symbol_intact() {
// Regression #228: the v16 abort guard keyed on `a.symbol IS NOT NULL`
// instead of convertibility. A SIMPLE-category account carrying a
// residual symbol (left by a priced→simple recategorization; AccountForm
// renders the symbol field unconditionally and updateBalanceAccount
// preserves it) has quantity-NULL lines by construction. Those satisfied
// the over-broad guard predicate (symbol NOT NULL AND qty NULL AND no
// holding) → guard inserts 0 → CHECK(ok = 1) fails → the whole v16
// migration aborts → the app no longer starts for that profile. The fix
// scopes the guard to convertible accounts (c.asset_type IS NOT NULL).
let conn = db_through_v13();
conn.execute_batch(V14_SQL).expect("apply v14");
conn.execute_batch(V15_SQL).expect("apply v15");
// Simple category: kind = 'simple', asset_type NULL by construction.
conn.execute(
"INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) \
VALUES ('custom_simple', 'custom', 'simple', 90, 0)",
[],
)
.unwrap();
let simple_cat: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
// Account under the simple category, carrying a residual symbol.
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name, symbol, kind) \
VALUES (?1, 'Compte residuel', 'RESID', 'simple')",
[simple_cat],
)
.unwrap();
let acc: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
// One snapshot line with quantity NULL (simple-kind line invariant).
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) \
VALUES (?1, ?2, NULL, NULL, 123.0, 'manual')",
rusqlite::params![snap_id, acc],
)
.unwrap();
let line: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
// v16 must apply cleanly. Pre-fix, the over-broad guard flagged this line
// (symbol NOT NULL, qty NULL, no holding) and aborted the whole migration.
conn.execute_batch(V16_SQL)
.expect("v16 must not abort on a simple account with a residual symbol");
// The residual symbol minted no security (simple category has no
// asset_type → excluded from the securities INSERT, step 1).
let resid_secs: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_securities WHERE symbol = 'RESID'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(resid_secs, 0, "no security for a simple account's residual symbol");
// No holding attached to the line.
let holdings: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1",
[line],
|r| r.get(0),
)
.unwrap();
assert_eq!(holdings, 0, "no holding minted for the simple account");
// The line is untouched (qty/unit_price stay NULL, value preserved).
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],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert!(l_qty.is_none(), "simple line quantity stays NULL");
assert!(l_price.is_none(), "simple line unit_price stays NULL");
assert_eq!(l_val, 123.0, "simple line value preserved");
// The account itself is NOT converted: kind stays 'simple', the residual
// symbol is preserved, and detailed_since stays NULL (criterion: intact).
let (a_kind, a_symbol, a_detailed_since): (String, Option<String>, Option<String>) = conn
.query_row(
"SELECT kind, symbol, detailed_since FROM balance_accounts WHERE id = ?1",
[acc],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(a_kind, "simple", "simple account must not be converted to detailed");
assert_eq!(a_symbol.as_deref(), Some("RESID"), "residual symbol preserved");
assert!(a_detailed_since.is_none(), "detailed_since stays NULL");
}
#[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.
// The guard carries the same narrowed predicate as production (#228): the
// abort is driven by the convertible line_a (AAPL, asset_type = 'stock'),
// NULLed without a holding; line_b (asset_type NULL) no longer trips the
// guard, but the convertible line alone is enough to prove abort+rollback.
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 \
JOIN balance_categories c ON c.id = a.balance_category_id \
WHERE a.symbol IS NOT NULL AND c.asset_type 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");
}
// =========================================================================
// Étape 2 — cross-cutting regression & integration on a populated DB (#217)
// -------------------------------------------------------------------------
// The headline guarantee of the per-security detail feature: a `detailed`
// account whose holdings sum to V must produce EXACTLY the same date /
// category / vehicle aggregates as a `simple` account worth V — the
// aggregated `balance_snapshot_lines.value` is the single source of truth
// for every SUM() in the service. These tests freeze concrete golden totals
// and prove the invariant where it actually runs: real SQLite aggregation.
//
// Unlike the TS aggregator tests (which drive a mock that returns canned
// rows and so can only assert SQL shape + bucketing), here `SUM(value)
// GROUP BY snapshot_date` is executed by SQLite itself — a regression in the
// aggregation path or in how a detailed line stores its value would surface
// as a divergence between the detailed-path total and the simple-path total.
// =========================================================================
/// Mirror of `getSnapshotTotalsByDate`'s SQL: per-date SUM of every line.
fn totals_by_date(conn: &Connection) -> Vec<(String, f64)> {
conn.prepare(
"SELECT s.snapshot_date, COALESCE(SUM(l.value), 0) AS total \
FROM balance_snapshots s \
LEFT JOIN balance_snapshot_lines l ON l.snapshot_id = s.id \
GROUP BY s.snapshot_date \
ORDER BY s.snapshot_date ASC",
)
.unwrap()
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, f64>(1)?)))
.unwrap()
.map(|r| r.unwrap())
.collect()
}
/// Mirror of `getSnapshotTotalsByCategoryAndDate`'s SQL.
fn totals_by_category(conn: &Connection) -> Vec<(String, String, f64)> {
conn.prepare(
"SELECT s.snapshot_date, c.key, COALESCE(SUM(l.value), 0) AS total \
FROM balance_snapshots s \
INNER JOIN balance_snapshot_lines l ON l.snapshot_id = s.id \
INNER JOIN balance_accounts a ON a.id = l.account_id \
INNER JOIN balance_categories c ON c.id = a.balance_category_id \
GROUP BY s.snapshot_date, c.key \
ORDER BY s.snapshot_date ASC, c.key ASC",
)
.unwrap()
.query_map([], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, f64>(2)?))
})
.unwrap()
.map(|r| r.unwrap())
.collect()
}
/// Mirror of `getSnapshotTotalsByVehicleAndDate`'s SQL (NULL → 'none').
fn totals_by_vehicle(conn: &Connection) -> Vec<(String, String, f64)> {
conn.prepare(
"SELECT s.snapshot_date, COALESCE(a.vehicle_type, 'none') AS vkey, \
COALESCE(SUM(l.value), 0) AS total \
FROM balance_snapshots s \
INNER JOIN balance_snapshot_lines l ON l.snapshot_id = s.id \
INNER JOIN balance_accounts a ON a.id = l.account_id \
GROUP BY s.snapshot_date, COALESCE(a.vehicle_type, 'none') \
ORDER BY s.snapshot_date ASC, vkey ASC",
)
.unwrap()
.query_map([], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, f64>(2)?))
})
.unwrap()
.map(|r| r.unwrap())
.collect()
}
fn new_account(
conn: &Connection,
cat_key: &str,
name: &str,
kind: &str,
vehicle: Option<&str>,
) -> i64 {
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name, kind, vehicle_type) \
VALUES ((SELECT id FROM balance_categories WHERE key = ?1), ?2, ?3, ?4)",
rusqlite::params![cat_key, name, kind, vehicle],
)
.unwrap();
conn.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap()
}
fn new_snapshot(conn: &Connection, date: &str) -> i64 {
conn.execute(
"INSERT INTO balance_snapshots (snapshot_date) VALUES (?1)",
[date],
)
.unwrap();
conn.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap()
}
/// Insert a SIMPLE aggregated line (qty/price NULL) and return its id.
fn new_simple_line(conn: &Connection, snap: i64, acc: i64, value: f64) -> i64 {
conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value, price_source) \
VALUES (?1, ?2, ?3, 'manual')",
rusqlite::params![snap, acc, value],
)
.unwrap();
conn.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap()
}
/// Insert a DETAILED line (aggregated row + N holdings) the way the service
/// does: the line value is the rounded-cent SUM of the holdings, qty/price on
/// the line are NULL, and each holding references a minted security. Returns
/// the line id.
fn new_detailed_line(
conn: &Connection,
snap: i64,
acc: i64,
holdings: &[(&str, f64, f64, f64, Option<f64>)], // symbol, qty, price, value, book_cost
) -> i64 {
let total: f64 = holdings.iter().map(|h| h.3).sum();
conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value, price_source) \
VALUES (?1, ?2, ?3, 'manual')",
rusqlite::params![snap, acc, total],
)
.unwrap();
let line: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
for (sym, qty, price, value, book) in holdings {
conn.execute(
"INSERT INTO balance_securities (symbol, asset_type) VALUES (?1, 'stock') \
ON CONFLICT(symbol) DO NOTHING",
[sym],
)
.unwrap();
let sec: i64 = conn
.query_row(
"SELECT id FROM balance_securities WHERE symbol = ?1",
[sym],
|r| r.get(0),
)
.unwrap();
conn.execute(
"INSERT INTO balance_snapshot_holdings \
(snapshot_line_id, security_id, quantity, unit_price, value, book_cost) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![line, sec, qty, price, value, book],
)
.unwrap();
}
line
}
#[test]
fn regression_detailed_account_totals_equal_simple_account_totals() {
// Two parallel worlds on the SAME schema. World A: a `simple` cash
// account + a `simple` brokerage account worth 300. World B: the same
// cash account + a `detailed` brokerage account whose two holdings
// (100 + 200) sum to 300. The date/category/vehicle aggregates MUST be
// byte-identical — that equality is the regression guarantee.
let conn = consolidated_db(); // ships v14/v15-parity schema + 5 classes
// Shared cash account (simple) in both worlds.
// World A.
let cash_a = new_account(&conn, "cash", "Cash A", "simple", None);
let broker_a = new_account(&conn, "stock", "Broker A simple", "simple", Some("tfsa"));
let snap_a = new_snapshot(&conn, "2026-01-31");
new_simple_line(&conn, snap_a, cash_a, 1000.0);
new_simple_line(&conn, snap_a, broker_a, 300.0);
let golden_date_a = totals_by_date(&conn);
let golden_cat_a = totals_by_category(&conn);
let golden_veh_a = totals_by_vehicle(&conn);
// FROZEN golden numbers (derived from the simple-account world).
assert_eq!(golden_date_a, vec![("2026-01-31".to_string(), 1300.0)]);
assert_eq!(
golden_cat_a,
vec![
("2026-01-31".to_string(), "cash".to_string(), 1000.0),
("2026-01-31".to_string(), "stock".to_string(), 300.0),
]
);
assert_eq!(
golden_veh_a,
vec![
("2026-01-31".to_string(), "none".to_string(), 1000.0),
("2026-01-31".to_string(), "tfsa".to_string(), 300.0),
]
);
// World B on a fresh connection: identical structure, detailed broker.
let conn2 = consolidated_db();
let cash_b = new_account(&conn2, "cash", "Cash B", "simple", None);
let broker_b =
new_account(&conn2, "stock", "Broker B detailed", "detailed", Some("tfsa"));
let snap_b = new_snapshot(&conn2, "2026-01-31");
new_simple_line(&conn2, snap_b, cash_b, 1000.0);
new_detailed_line(
&conn2,
snap_b,
broker_b,
&[
("AAPL", 2.0, 50.0, 100.0, Some(80.0)),
("MSFT", 1.0, 200.0, 200.0, Some(150.0)),
],
);
// The detailed world reproduces the frozen golden numbers EXACTLY.
assert_eq!(
totals_by_date(&conn2),
golden_date_a,
"per-date total must be identical: a detailed account worth ΣV \
aggregates exactly like a simple account worth V"
);
assert_eq!(
totals_by_category(&conn2),
golden_cat_a,
"per-category total must be identical across simple/detailed paths"
);
assert_eq!(
totals_by_vehicle(&conn2),
golden_veh_a,
"per-vehicle total must be identical across simple/detailed paths"
);
// Belt-and-braces: the detailed line's stored value equals Σ(holdings).
let line_val: f64 = conn2
.query_row(
"SELECT value FROM balance_snapshot_lines WHERE account_id = ?1",
[broker_b],
|r| r.get(0),
)
.unwrap();
let holdings_sum: f64 = conn2
.query_row(
"SELECT COALESCE(SUM(h.value), 0) FROM balance_snapshot_holdings h \
JOIN balance_snapshot_lines l ON l.id = h.snapshot_line_id \
WHERE l.account_id = ?1",
[broker_b],
|r| r.get(0),
)
.unwrap();
assert_eq!(line_val, 300.0);
assert_eq!(
line_val, holdings_sum,
"the aggregated line value IS the holdings SUM"
);
}
#[test]
fn regression_snapshot_delete_cascades_to_holdings() {
// Deleting a snapshot must remove its lines (CASCADE on snapshot_id) AND,
// transitively, the per-security holdings of each detailed line (CASCADE
// on snapshot_line_id). Two-hop cascade, proven end-to-end.
let conn = consolidated_db();
let broker = new_account(&conn, "stock", "Broker", "detailed", None);
let snap = new_snapshot(&conn, "2026-02-28");
let line = new_detailed_line(
&conn,
snap,
broker,
&[("AAPL", 1.0, 100.0, 100.0, Some(90.0))],
);
// Sanity: one line, one holding before the delete.
let h_before: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1",
[line],
|r| r.get(0),
)
.unwrap();
assert_eq!(h_before, 1);
conn.execute("DELETE FROM balance_snapshots WHERE id = ?1", [snap])
.expect("snapshot delete should cascade");
let lines_after: i64 = conn
.query_row("SELECT COUNT(*) FROM balance_snapshot_lines", [], |r| r.get(0))
.unwrap();
let holdings_after: i64 = conn
.query_row("SELECT COUNT(*) FROM balance_snapshot_holdings", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(lines_after, 0, "snapshot delete must cascade its lines");
assert_eq!(
holdings_after, 0,
"deleting the line must cascade its holdings (two-hop CASCADE)"
);
// The security catalogue row is NOT deleted — only the holding link is.
let secs: i64 = conn
.query_row("SELECT COUNT(*) FROM balance_securities", [], |r| r.get(0))
.unwrap();
assert_eq!(secs, 1, "the security itself survives (RESTRICT, not cascade)");
}
/// Build a richer pre-v16 DB than `db_pre_v16`: a SIMPLE cash account, a
/// CONVERTIBLE priced account (seed `stock`, asset_type set, with TWO
/// snapshots of history), and a NON-CONVERTIBLE priced account (custom
/// category, asset_type NULL). Returns
/// (conn, simple_line_s1, simple_line_s2, conv_line_s1, conv_line_s2, nonconv_line).
fn db_pre_v16_populated() -> (Connection, i64, i64, i64, i64, i64) {
let conn = db_through_v13();
conn.execute_batch(V14_SQL).expect("apply v14");
conn.execute_batch(V15_SQL).expect("apply v15");
// Simple cash account.
let cash: i64 = conn
.query_row(
"SELECT id FROM balance_categories WHERE key = 'cash'",
[],
|r| r.get(0),
)
.unwrap();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'Encaisse')",
[cash],
)
.unwrap();
let acc_cash: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
// Convertible priced account (v15 backfilled it to 'detailed').
let stock_cat: i64 = conn
.query_row(
"SELECT id FROM balance_categories WHERE key = 'stock'",
[],
|r| r.get(0),
)
.unwrap();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name, symbol) \
VALUES (?1, 'Courtage VEQT', ' veqt.to ')",
[stock_cat],
)
.unwrap();
let acc_conv: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
// Non-convertible: custom priced category, asset_type NULL.
conn.execute(
"INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) \
VALUES ('custom_priced', 'custom', 'priced', 80, 0)",
[],
)
.unwrap();
let custom_cat: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name, symbol) \
VALUES (?1, 'Fonds maison', 'XYZ')",
[custom_cat],
)
.unwrap();
let acc_nonconv: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
// Two snapshots of history (multi-snapshot requirement).
let s1 = new_snapshot(&conn, "2026-01-31");
let s2 = new_snapshot(&conn, "2026-02-28");
// Simple cash lines.
let scl1 = new_simple_line(&conn, s1, acc_cash, 1000.0);
let scl2 = new_simple_line(&conn, s2, acc_cash, 1100.0);
// Convertible priced lines (qty/price set — pre-v16 priced shape).
conn.execute(
"INSERT INTO balance_snapshot_lines \
(snapshot_id, account_id, quantity, unit_price, value, price_source) \
VALUES (?1, ?2, 10.0, 30.0, 300.0, 'maximus-api')",
rusqlite::params![s1, acc_conv],
)
.unwrap();
let conv1: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO balance_snapshot_lines \
(snapshot_id, account_id, quantity, unit_price, value, price_source) \
VALUES (?1, ?2, 12.0, 30.0, 360.0, 'maximus-api')",
rusqlite::params![s2, acc_conv],
)
.unwrap();
let conv2: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
// Non-convertible priced line (only at s2, qty/price set).
conn.execute(
"INSERT INTO balance_snapshot_lines \
(snapshot_id, account_id, quantity, unit_price, value, price_source) \
VALUES (?1, ?2, 5.0, 8.0, 40.0, 'manual')",
rusqlite::params![s2, acc_nonconv],
)
.unwrap();
let nonconv: i64 = conn
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
.unwrap();
let _ = (scl1, scl2);
(conn, scl1, scl2, conv1, conv2, nonconv)
}
#[test]
fn migration_v14_to_v16_on_populated_db_preserves_integrity_and_totals() {
// The realistic multi-type integrity case (#217): run v16 on a DB with a
// simple account, a convertible priced account (2 snapshots of history),
// and a non-convertible priced account. Freeze the per-snapshot totals
// BEFORE the migration and assert they're byte-identical AFTER — the
// conversion to per-security holdings must not move a single cent.
let (conn, _scl1, _scl2, conv1, conv2, nonconv) = db_pre_v16_populated();
// Golden totals captured on the pre-v16 DB.
let date_before = totals_by_date(&conn);
let cat_before = totals_by_category(&conn);
let veh_before = totals_by_vehicle(&conn);
// Concrete frozen numbers (cash 1000/1100, conv 300/360, nonconv 40@s2).
assert_eq!(
date_before,
vec![
("2026-01-31".to_string(), 1300.0), // 1000 + 300
("2026-02-28".to_string(), 1500.0), // 1100 + 360 + 40
]
);
// Apply the conversion.
conn.execute_batch(V16_SQL).expect("apply v16");
// 1) Aggregates are byte-for-byte identical — no value moved.
assert_eq!(
totals_by_date(&conn),
date_before,
"v16 must preserve every per-date total"
);
assert_eq!(
totals_by_category(&conn),
cat_before,
"v16 must preserve every per-category total"
);
assert_eq!(
totals_by_vehicle(&conn),
veh_before,
"v16 must preserve every per-vehicle total"
);
// 2) Both convertible lines are NULLed on qty/price but keep their value,
// and each mirrors exactly one holding.
for (line, qty, price, value) in
[(conv1, 10.0, 30.0, 300.0), (conv2, 12.0, 30.0, 360.0)]
{
let (lq, lp, lv): (Option<f64>, Option<f64>, f64) = conn
.query_row(
"SELECT quantity, unit_price, value FROM balance_snapshot_lines WHERE id = ?1",
[line],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert!(lq.is_none(), "converted line {line} qty must be NULL");
assert!(lp.is_none(), "converted line {line} price must be NULL");
assert_eq!(lv, value, "converted line {line} value preserved");
let (hq, hp, hv): (f64, f64, f64) = conn
.query_row(
"SELECT quantity, unit_price, value FROM balance_snapshot_holdings \
WHERE snapshot_line_id = ?1",
[line],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.expect("a holding must mirror each converted line");
assert_eq!((hq, hp, hv), (qty, price, value));
}
// 3) Exactly ONE security minted (VEQT.TO, normalized), shared across
// both snapshots' holdings — multi-snapshot history collapses to one
// catalogue row.
let veqt_secs: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_securities WHERE symbol = 'VEQT.TO'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(veqt_secs, 1, "one normalized security for the whole history");
let distinct_secs_on_conv: i64 = conn
.query_row(
"SELECT COUNT(DISTINCT h.security_id) FROM balance_snapshot_holdings h \
WHERE h.snapshot_line_id IN (?1, ?2)",
rusqlite::params![conv1, conv2],
|r| r.get(0),
)
.unwrap();
assert_eq!(distinct_secs_on_conv, 1, "both snapshots share one security");
// 4) The non-convertible line is FULLY intact — no holding, qty/price kept.
let nonconv_holdings: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1",
[nonconv],
|r| r.get(0),
)
.unwrap();
assert_eq!(nonconv_holdings, 0, "non-convertible line gets no holding");
let (nq, np, nv): (Option<f64>, Option<f64>, f64) = conn
.query_row(
"SELECT quantity, unit_price, value FROM balance_snapshot_lines WHERE id = ?1",
[nonconv],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!((nq, np, nv), (Some(5.0), Some(8.0), 40.0));
// 5) No security for the non-convertible symbol.
let xyz_secs: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_securities WHERE symbol = 'XYZ'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(xyz_secs, 0, "no security for a priced account without asset_type");
}
// =========================================================================
// Migration v17 — the full import format on import_sources (#323)
// -------------------------------------------------------------------------
// What these tests guarantee:
// - v17 applies on a POPULATED v16 database with zero loss: every existing
// source keeps its identity and its mechanical CSV settings, and the
// child rows that reference it survive the ALTERs.
// - the backfill reproduces EXACTLY the rule the wizard applied on the fly
// (`useImportWizard.ts:321`), so no source changes behaviour on
// migration — a mapping carrying `debitAmount` becomes 'debit_credit',
// everything else stays 'single', and `sign_convention` lands on the
// value the code used to hardcode.
// - the two CHECKs really bite (an unknown enum value is refused, and
// 'absolute_indicator' is already admitted so the third amount mode
// needs no further migration).
// - `template_id` is a provenance tag: deleting the template it points at
// leaves the source alive with a NULL tag, and editing a template
// changes no linked source's format.
// - consolidated_schema.sql and the v1→v17 chain agree column for column,
// DEFAULT for DEFAULT, CHECK for CHECK, FK for FK.
//
// "v1..v16 unchanged" is NOT asserted here: no checksum harness exists in
// this crate (V10_SQL..V17_SQL are hand-kept copies, and the real checksums
// only live at runtime in `_sqlx_migrations`). It is verified by the diff —
// v17 adds strictly new lines and touches no earlier migration string.
// =========================================================================
/// Production v3 SQL — kept in sync with the Migration { version: 3 } entry.
const V3_SQL: &str =
"ALTER TABLE import_sources ADD COLUMN has_header INTEGER NOT NULL DEFAULT 1;";
/// Production v5 SQL — kept in sync with the Migration { version: 5 } entry.
const V5_SQL: &str = "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
);";
/// Production v17 SQL — kept in sync with the Migration { version: 17 } entry.
const V17_SQL: &str = "ALTER TABLE import_sources ADD COLUMN amount_mode TEXT NOT NULL DEFAULT 'single' \
CHECK (amount_mode IN ('single','debit_credit','absolute_indicator')); \
ALTER TABLE import_sources ADD COLUMN sign_convention TEXT NOT NULL DEFAULT 'negative_expense' \
CHECK (sign_convention IN ('negative_expense','positive_expense')); \
ALTER TABLE import_sources ADD COLUMN header_signature TEXT; \
ALTER TABLE import_sources ADD COLUMN template_id INTEGER \
REFERENCES import_config_templates(id) ON DELETE SET NULL; \
UPDATE import_sources SET amount_mode = 'debit_credit' \
WHERE column_mapping LIKE '%debitAmount%';";
/// Build the pre-v17 state of the two tables v17 touches. `import_sources`
/// and `import_config_templates` are shaped by exactly three migrations —
/// v1 (creation), v3 (`has_header`) and v5 (the templates table). None of
/// v2, v4 and v6→v16 alters either table, so this IS their v16 shape; the
/// parity test below re-proves it by comparing the result against the
/// consolidated reference definition.
fn db_pre_v17() -> 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::SCHEMA)
.expect("apply v1 SCHEMA");
conn.execute_batch(V3_SQL).expect("apply v3");
conn.execute_batch(V5_SQL).expect("apply v5");
conn
}
/// Insert a source and return its id. Only `name` and `column_mapping` have
/// no default at v16, so everything else is left to the schema.
fn seed_source(conn: &Connection, name: &str, mapping: &str) -> i64 {
conn.execute(
"INSERT INTO import_sources (name, column_mapping) VALUES (?1, ?2)",
rusqlite::params![name, mapping],
)
.unwrap();
conn.last_insert_rowid()
}
/// (name, type, notnull, dflt_value) for every column of `table`, sorted by
/// name. Sorting drops physical column order on purpose: ALTER TABLE can
/// only append, while a CREATE TABLE places a column where it reads best —
/// the order is not part of the contract, the definitions are.
fn column_shape(conn: &Connection, table: &str) -> Vec<(String, String, i64, Option<String>)> {
let mut cols: Vec<(String, String, i64, Option<String>)> = conn
.prepare(&format!(
"SELECT name, type, \"notnull\", dflt_value FROM pragma_table_info('{table}')"
))
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
.unwrap()
.map(|r| r.unwrap())
.collect();
cols.sort();
cols
}
/// (referenced table, from column, to column, ON DELETE action) per FK.
fn fk_shape(conn: &Connection, table: &str) -> Vec<(String, String, String, String)> {
let mut fks: Vec<(String, String, String, String)> = conn
.prepare(&format!(
"SELECT \"table\", \"from\", \"to\", on_delete FROM pragma_foreign_key_list('{table}')"
))
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
.unwrap()
.map(|r| r.unwrap())
.collect();
fks.sort();
fks
}
#[test]
fn migration_v17_applies_on_a_populated_v16_db() {
let conn = db_pre_v17();
// A realistic populated v16 profile: a configured source, a template and
// an imported file hanging off the source by FK.
let src = seed_source(
&conn,
"Desjardins",
r#"{"date":"Date","description":"Description","amount":"Montant"}"#,
);
conn.execute(
"UPDATE import_sources SET delimiter = ',', encoding = 'windows-1252', \
date_format = '%Y-%m-%d', skip_lines = 3, has_header = 0 WHERE id = ?1",
[src],
)
.unwrap();
conn.execute(
"INSERT INTO import_config_templates (name, column_mapping) VALUES ('Modèle A', '{}')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO imported_files (source_id, filename, file_hash) \
VALUES (?1, 'janvier.csv', 'deadbeef')",
[src],
)
.unwrap();
conn.execute_batch(V17_SQL).expect("apply v17 on a v16 db");
// The four columns landed.
let cols: Vec<String> = column_shape(&conn, "import_sources")
.into_iter()
.map(|(n, _, _, _)| n)
.collect();
for expected in &[
"amount_mode",
"sign_convention",
"header_signature",
"template_id",
] {
assert!(
cols.contains(&expected.to_string()),
"v17 must add {expected} to import_sources"
);
}
// Nothing the source already carried was touched.
let (name, delim, enc, fmt, skip, header, mapping): (
String,
String,
String,
String,
i64,
i64,
String,
) = conn
.query_row(
"SELECT name, delimiter, encoding, date_format, skip_lines, has_header, \
column_mapping FROM import_sources WHERE id = ?1",
[src],
|r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
r.get(6)?,
))
},
)
.unwrap();
assert_eq!(name, "Desjardins");
assert_eq!(delim, ",");
assert_eq!(enc, "windows-1252");
assert_eq!(fmt, "%Y-%m-%d");
assert_eq!(skip, 3);
assert_eq!(header, 0);
assert_eq!(
mapping,
r#"{"date":"Date","description":"Description","amount":"Montant"}"#
);
// The new columns land on their defaults: a source that never carried a
// format now carries the exact one the code applied to it.
let (mode, sign, sig, tpl): (String, String, Option<String>, Option<i64>) = conn
.query_row(
"SELECT amount_mode, sign_convention, header_signature, template_id \
FROM import_sources WHERE id = ?1",
[src],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.unwrap();
assert_eq!(mode, "single");
assert_eq!(sign, "negative_expense");
assert!(sig.is_none(), "no header signature until an import records one");
assert!(tpl.is_none(), "an existing source has no known provenance");
// The child row and its FK survived the ALTERs.
let files: i64 = conn
.query_row(
"SELECT COUNT(*) FROM imported_files WHERE source_id = ?1",
[src],
|r| r.get(0),
)
.unwrap();
assert_eq!(files, 1, "imported_files must survive the v17 ALTERs");
let templates: i64 = conn
.query_row("SELECT COUNT(*) FROM import_config_templates", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(templates, 1, "templates are untouched by v17");
}
#[test]
fn migration_v17_backfill_reproduces_the_wizard_rule() {
let conn = db_pre_v17();
// The rule being frozen (`useImportWizard.ts:321`):
// mapping.debitAmount !== undefined ? "debit_credit" : "single"
let cases: &[(&str, &str, &str)] = &[
(
"Deux colonnes",
r#"{"date":"Date","description":"Libellé","debitAmount":"Débit","creditAmount":"Crédit"}"#,
"debit_credit",
),
(
"Débit seul",
r#"{"date":"Date","description":"Libellé","debitAmount":"Retrait"}"#,
"debit_credit",
),
(
"Montant unique",
r#"{"date":"Date","description":"Libellé","amount":"Montant"}"#,
"single",
),
(
"Crédit seul",
r#"{"date":"Date","description":"Libellé","creditAmount":"Dépôt"}"#,
"single",
),
(
"Mapping minimal",
r#"{"date":"Date","description":"Libellé"}"#,
"single",
),
];
for (name, mapping, _) in cases {
seed_source(&conn, name, mapping);
}
conn.execute_batch(V17_SQL).expect("apply v17");
for (name, _, expected_mode) in cases {
let (mode, sign): (String, String) = conn
.query_row(
"SELECT amount_mode, sign_convention FROM import_sources WHERE name = ?1",
[name],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(
mode, *expected_mode,
"{name}: the backfill must reproduce the wizard rule"
);
// The wizard hardcoded this on every restore, so every migrated
// source must land on it — that is what "no behaviour change" means.
assert_eq!(
sign, "negative_expense",
"{name}: sign_convention restores the previously hardcoded value"
);
}
}
#[test]
fn migration_v17_checks_reject_unknown_enum_values() {
let conn = db_pre_v17();
conn.execute_batch(V17_SQL).expect("apply v17");
let set = |col: &str, val: &str| {
conn.execute(
&format!("INSERT INTO import_sources (name, column_mapping, {col}) VALUES (?1, '{{}}', ?2)"),
rusqlite::params![format!("{col}-{val}"), val],
)
};
// The third amount mode is admitted from the start: implementing it later
// must not require another migration.
assert!(
set("amount_mode", "absolute_indicator").is_ok(),
"absolute_indicator must be accepted by the v17 CHECK"
);
assert!(set("amount_mode", "debit_credit").is_ok());
assert!(set("sign_convention", "positive_expense").is_ok());
// A corrupted value — e.g. restored from a hand-edited SREF backup — is
// refused by the database rather than silently mis-read at parse time.
assert!(
set("amount_mode", "montants_bizarres").is_err(),
"an unknown amount_mode must be refused"
);
assert!(
set("sign_convention", "whatever").is_err(),
"an unknown sign_convention must be refused"
);
}
#[test]
fn migration_v17_template_id_is_a_nullable_provenance_tag() {
let conn = db_pre_v17();
conn.execute_batch(V17_SQL).expect("apply v17");
conn.execute(
"INSERT INTO import_config_templates (name, column_mapping, amount_mode, sign_convention) \
VALUES ('Desjardins', '{}', 'single', 'positive_expense')",
[],
)
.unwrap();
let tpl = conn.last_insert_rowid();
let src = seed_source(&conn, "Compte chèque", "{}");
conn.execute(
"UPDATE import_sources SET template_id = ?1, sign_convention = 'positive_expense' \
WHERE id = ?2",
[tpl, src],
)
.unwrap();
// Editing the template changes NO linked source: the eight format columns
// on the source are authoritative, the tag only records provenance.
conn.execute(
"UPDATE import_config_templates SET amount_mode = 'debit_credit', \
sign_convention = 'negative_expense' WHERE id = ?1",
[tpl],
)
.unwrap();
let (mode, sign): (String, String) = conn
.query_row(
"SELECT amount_mode, sign_convention FROM import_sources WHERE id = ?1",
[src],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(mode, "single", "editing a template must not touch a source");
assert_eq!(sign, "positive_expense");
// Deleting the template keeps the source and its format — only the tag
// goes (ON DELETE SET NULL).
conn.execute("DELETE FROM import_config_templates WHERE id = ?1", [tpl])
.unwrap();
let (tag, sign_after): (Option<i64>, String) = conn
.query_row(
"SELECT template_id, sign_convention FROM import_sources WHERE id = ?1",
[src],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert!(tag.is_none(), "deleting a template nulls the provenance tag");
assert_eq!(
sign_after, "positive_expense",
"deleting a template must not alter the source format"
);
// A dangling tag is refused outright.
assert!(
conn.execute(
"UPDATE import_sources SET template_id = 9999 WHERE id = ?1",
[src]
)
.is_err(),
"template_id must point at a real template"
);
}
#[test]
fn consolidated_schema_matches_v17_chain_on_import_sources_at_parity() {
// The consolidated schema is the tested reference definition for new
// profiles. Without this test the DEFAULT or the CHECK of the four v17
// columns could drift between the two definitions unnoticed — the
// columns are inert on the production path (this script runs after every
// migration and only uses CREATE TABLE IF NOT EXISTS), so nothing else
// would surface the divergence.
let chain = db_pre_v17();
chain.execute_batch(V17_SQL).expect("apply v17");
let consolidated = consolidated_db();
// Same columns, same types, same NOT NULL flags, same DEFAULTs.
assert_eq!(
column_shape(&consolidated, "import_sources"),
column_shape(&chain, "import_sources"),
"consolidated import_sources must match the v1→v17 chain"
);
// Same FK, same ON DELETE action.
assert_eq!(
fk_shape(&consolidated, "import_sources"),
fk_shape(&chain, "import_sources"),
"consolidated import_sources must carry the same template_id FK"
);
assert_eq!(
fk_shape(&consolidated, "import_sources"),
vec![(
"import_config_templates".to_string(),
"template_id".to_string(),
"id".to_string(),
"SET NULL".to_string(),
)],
);
// The CHECKs are not exposed by pragma, so prove them behaviourally on
// BOTH definitions — the same values must be accepted and refused.
for conn in [&consolidated, &chain] {
for (col, val, ok) in [
("amount_mode", "absolute_indicator", true),
("amount_mode", "debit_credit", true),
("amount_mode", "nope", false),
("sign_convention", "positive_expense", true),
("sign_convention", "nope", false),
] {
let res = conn.execute(
&format!(
"INSERT INTO import_sources (name, column_mapping, {col}) \
VALUES (?1, '{{}}', ?2)"
),
rusqlite::params![format!("{col}-{val}"), val],
);
assert_eq!(
res.is_ok(),
ok,
"{col} = {val} must behave identically in both definitions"
);
}
}
}
}