test(balance): cross-cutting integration tests (#144) #152

Merged
maximus merged 4 commits from issue-144-bilan-6 into main 2026-04-26 13:25:38 +00:00
Showing only changes of commit 50fe0ab1ac - Show all commits

View file

@ -692,5 +692,353 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(count, 7, "seed must remain idempotent on replay"); 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");
}
} }