From 50fe0ab1ac04cc3f67e4426dcfc8c7ec37da802c Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:53:50 -0400 Subject: [PATCH] test(balance): add migration v9 integration on seeded DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src-tauri/src/lib.rs | 348 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 348 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3b50b03..58c144f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -692,5 +692,353 @@ mod tests { .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 = 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 = 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"); + } }