Three new Rust integration tests applied at the bottom of `lib.rs`'s `#[cfg(test)] mod tests`. They exercise the realistic upgrade path: a v1 profile DB with imported transactions + categories already there gets the v9 migration applied on top. `migration_v9_preserves_existing_transactions_on_seeded_db` asserts no row loss / data mutation after the migration runs. Spot-checks one amount preserved verbatim and that the v9 seeded categories coexist with the v1 categories table. `integration_link_unlink_transfer_roundtrip_on_seeded_db` walks link → joined-view read → blocked deletion (FK RESTRICT) → unlink → allowed deletion → orphan-row sanity check. Covers the FK chain end-to-end on real (non-stub) transaction ids. `integration_modified_dietz_inputs_read_back_correctly_on_seeded_db` mirrors the exact SQL used by `balance_commands.rs::read_value_at_or_before` and `read_cash_flows`, asserting the snapshot-endpoint lookups and the period-bounded JOINed cash flows return the expected shapes when run against a seeded v1+v9 DB. `integration_v9_preserves_v1_categories_and_keywords` verifies the `categories.id` and `balance_categories.id` namespaces are independent (same numeric id allowed on each table without collision). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1044 lines
40 KiB
Rust
1044 lines
40 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,
|
|
},
|
|
];
|
|
|
|
tauri::Builder::default()
|
|
// Single-instance plugin MUST be registered first. With the `deep-link`
|
|
// feature, it forwards `simpl-resultat://` URLs to the running instance
|
|
// so the OAuth2 callback reaches the process that holds the PKCE verifier.
|
|
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
let _ = window.set_focus();
|
|
}
|
|
}))
|
|
.manage(commands::auth_commands::OAuthState {
|
|
code_verifier: Mutex::new(None),
|
|
})
|
|
.plugin(tauri_plugin_opener::init())
|
|
.plugin(tauri_plugin_dialog::init())
|
|
.plugin(tauri_plugin_process::init())
|
|
.plugin(tauri_plugin_deep_link::init())
|
|
.setup(|app| {
|
|
#[cfg(desktop)]
|
|
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
|
|
|
// Register the custom scheme at runtime on Linux (the .desktop file
|
|
// handles it in prod, but register_all is a no-op there and required
|
|
// for AppImage/dev builds).
|
|
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
|
|
{
|
|
let _ = app.deep_link().register_all();
|
|
}
|
|
|
|
// Canonical Tauri v2 pattern: on_open_url fires for both initial-launch
|
|
// URLs and subsequent URLs forwarded by tauri-plugin-single-instance
|
|
// (with the `deep-link` feature).
|
|
let handle = app.handle().clone();
|
|
app.deep_link().on_open_url(move |event| {
|
|
for url in event.urls() {
|
|
let url_str = url.as_str();
|
|
let h = handle.clone();
|
|
if let Some(code) = extract_auth_code(url_str) {
|
|
tauri::async_runtime::spawn(async move {
|
|
match commands::handle_auth_callback(h.clone(), code).await {
|
|
Ok(account) => {
|
|
let _ = h.emit("auth-callback-success", &account);
|
|
}
|
|
Err(err) => {
|
|
let _ = h.emit("auth-callback-error", &err);
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
// No `code` param — likely an OAuth error response. Surface
|
|
// it to the frontend instead of leaving the UI stuck in
|
|
// "loading" forever.
|
|
let err_msg = extract_auth_error(url_str)
|
|
.unwrap_or_else(|| "OAuth callback did not include a code".to_string());
|
|
let _ = h.emit("auth-callback-error", &err_msg);
|
|
}
|
|
}
|
|
});
|
|
|
|
Ok(())
|
|
})
|
|
.plugin(
|
|
tauri_plugin_sql::Builder::default()
|
|
.add_migrations("sqlite:simpl_resultat.db", migrations)
|
|
.build(),
|
|
)
|
|
.invoke_handler(tauri::generate_handler![
|
|
commands::scan_import_folder,
|
|
commands::read_file_content,
|
|
commands::hash_file,
|
|
commands::detect_encoding,
|
|
commands::get_file_preview,
|
|
commands::pick_folder,
|
|
commands::pick_save_file,
|
|
commands::pick_import_file,
|
|
commands::write_export_file,
|
|
commands::read_import_file,
|
|
commands::is_file_encrypted,
|
|
commands::load_profiles,
|
|
commands::save_profiles,
|
|
commands::delete_profile_db,
|
|
commands::get_new_profile_init_sql,
|
|
commands::hash_pin,
|
|
commands::verify_pin,
|
|
commands::repair_migrations,
|
|
commands::validate_license_key,
|
|
commands::store_license,
|
|
commands::store_activation_token,
|
|
commands::read_license,
|
|
commands::get_edition,
|
|
commands::get_machine_id,
|
|
commands::check_entitlement,
|
|
commands::activate_machine,
|
|
commands::deactivate_machine,
|
|
commands::list_activated_machines,
|
|
commands::get_activation_status,
|
|
commands::start_oauth,
|
|
commands::refresh_auth_token,
|
|
commands::get_account_info,
|
|
commands::check_subscription_status,
|
|
commands::logout,
|
|
commands::get_token_store_mode,
|
|
commands::send_feedback,
|
|
commands::get_feedback_user_agent,
|
|
commands::ensure_backup_dir,
|
|
commands::get_file_size,
|
|
commands::file_exists,
|
|
commands::compute_account_return,
|
|
])
|
|
.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");
|
|
}
|
|
}
|
|
|