Simpl-Resultat/src-tauri/src/lib.rs
le king fu 0381dd48bb feat(balance): add compute_account_return Tauri command
Issue #142 / Bilan #4 — server-side Modified Dietz wrapper.

- New `src-tauri/src/commands/balance_commands.rs` with single command
  `compute_account_return(db_filename, account_id, period_start, period_end)`:
  - Opens the active profile DB via `rusqlite::Connection::open(app_data_dir
    / db_filename)` — matches `repair_migrations` / `delete_profile_db`.
  - Reads `value_start` (latest snapshot ≤ period_start) + `value_end`
    (latest snapshot ≤ period_end) via correlated SELECT.
  - Reads cash flows via JOIN `balance_account_transfers` ⨝
    `transactions` filtered by `transaction_date BETWEEN`. Sign applied
    per direction (`in` → +, `out` → −).
  - Calls `return_calculator::modified_dietz`, returns typed
    `AccountReturn`.
- Registered in `commands/mod.rs` (pub use) and in `lib.rs`'
  `tauri::generate_handler!` array.

`cargo check` clean. `cargo test --lib` → 54 passed (including the 7
return_calculator + 7 migration_v9 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:23:14 -04:00

696 lines
27 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");
}
}