test(balance): cross-cutting integration tests (#144) #152
1 changed files with 348 additions and 0 deletions
|
|
@ -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<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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue