/** * Integration tests for the Bilan (balance sheet) feature — Issue #144. * * Cross-cutting tests that exercise the *whole* TS surface in one go: * * account → priced category → priced snapshot → linked transfer → return * * Like `category-migration.test.ts` we cannot spin up real `tauri-plugin-sql` * (the bridge only lives inside the Tauri WebView). Instead we drive every * service against an in-memory FakeDb that: * - records every executed SQL, * - returns hand-tuned `select` results to mimic the real schema, * - simulates `lastInsertId` / `rowsAffected` for INSERT/DELETE. * * The Tauri `invoke` is mocked — `computeAccountReturn` lives on the Rust * side (`compute_account_return`), so we assert the request payload and * have the mock return a stable `AccountReturn` shape. The Rust math itself * is covered by `return_calculator.rs`'s `#[cfg(test)] mod tests`. * * Scope (from spec-plan-bilan.md, Issue #144): * 1. End-to-end happy path * 2. Currency-lock (CHECK `currency = 'CAD'`) at the service level * 3. Migration v9 on a seeded DB — covered in Rust (lib.rs `mod tests`) * 4. TransactionsPage non-regression for the inlined transfer icon * 5. Coverage best-effort (deferred — see decisions-log.md) */ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("../services/db", () => ({ getDb: vi.fn(), })); vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), })); vi.mock("../services/profileService", () => ({ loadProfiles: vi.fn(), })); import { getDb } from "../services/db"; import { invoke } from "@tauri-apps/api/core"; import { loadProfiles } from "../services/profileService"; import { createBalanceCategory, createBalanceAccount, listBalanceAccounts, createSnapshot, upsertSnapshotLines, listLinesBySnapshot, linkTransfer, unlinkTransfer, listAccountTransfers, computeAccountReturn, BalanceServiceError, PRICED_VALUE_TOLERANCE, } from "../services/balance.service"; // --------------------------------------------------------------------------- // FakeDb harness: scripted select results, recorded execute calls. // --------------------------------------------------------------------------- interface FakeDb { calls: Array<{ sql: string; params?: unknown[] }>; selectQueue: Array; executeQueue: Array<{ lastInsertId?: number; rowsAffected?: number }>; select: ReturnType; execute: ReturnType; } function makeFakeDb(): FakeDb { const db: FakeDb = { calls: [], selectQueue: [], executeQueue: [], select: vi.fn(), execute: vi.fn(), }; db.select.mockImplementation(async (sql: string, params?: unknown[]) => { db.calls.push({ sql, params }); if (db.selectQueue.length === 0) { throw new Error(`Unscripted SELECT (no queued result): ${sql}`); } return db.selectQueue.shift(); }); db.execute.mockImplementation(async (sql: string, params?: unknown[]) => { db.calls.push({ sql, params }); if (db.executeQueue.length === 0) { // Default: 1 affected row, monotonically increasing lastInsertId return { rowsAffected: 1, lastInsertId: db.calls.length }; } return db.executeQueue.shift(); }); return db; } let fake: FakeDb; beforeEach(() => { fake = makeFakeDb(); vi.mocked(getDb).mockResolvedValue( { select: fake.select, execute: fake.execute } as never ); vi.mocked(invoke).mockReset(); vi.mocked(loadProfiles).mockReset(); }); // Helper: queue a sequence of SELECT results in FIFO order. function queueSelects(...rows: unknown[][]) { for (const r of rows) fake.selectQueue.push(r); } // Helper: queue a sequence of EXECUTE results in FIFO order. function queueExecutes( ...results: Array<{ lastInsertId?: number; rowsAffected?: number }> ) { for (const r of results) fake.executeQueue.push(r); } // --------------------------------------------------------------------------- // 1. End-to-end happy path // --------------------------------------------------------------------------- // // Walks the full Bilan flow as if the user just installed the app: // 1. Create a custom priced category ("etf-prov") // 2. Create an account on that category with a stock symbol // 3. Reload the joined accounts list and confirm the account is there // 4. Create a snapshot dated today // 5. Save a priced line for the new account (qty * price = value) // 6. Read the lines back and confirm what was persisted // 7. Link a transaction to the account as a +CAD deposit // 8. Compute the account's return → mock returns a stable shape, we // assert the wiring uses the active profile's db_filename and forwards // every parameter as ISO YYYY-MM-DD. // // Each step is asserted at the service-call level (params + queued SQL), // then we run cross-step sanity checks. describe("integration — Bilan end-to-end happy path", () => { it("walks account → priced category → snapshot → transfer → return cleanly", async () => { // ---- 1. Create a custom priced category ---- queueExecutes({ lastInsertId: 100 }); const categoryId = await createBalanceCategory({ key: "etf-prov", i18n_key: "balance.category.etf_prov", kind: "priced", sort_order: 80, asset_type: "stock", }); expect(categoryId).toBe(100); // ---- 2. Create the account on that category ---- // Service first SELECTs the category to validate it exists, then // INSERTs the account. queueSelects([ { id: 100, key: "etf-prov", i18n_key: "balance.category.etf_prov", kind: "priced", sort_order: 80, is_active: 1, is_seed: 0, }, ]); queueExecutes({ lastInsertId: 7 }); const accountId = await createBalanceAccount({ balance_category_id: categoryId, name: "VFV (Wealthsimple)", symbol: "VFV.TO", }); expect(accountId).toBe(7); // ---- 3. listBalanceAccounts: account joined with category ---- queueSelects([ { id: 7, balance_category_id: 100, name: "VFV (Wealthsimple)", symbol: "VFV.TO", currency: "CAD", notes: null, is_active: 1, archived_at: null, created_at: "", updated_at: "", category_key: "etf-prov", category_i18n_key: "balance.category.etf_prov", category_kind: "priced", }, ]); const accounts = await listBalanceAccounts(); expect(accounts).toHaveLength(1); expect(accounts[0].category_kind).toBe("priced"); expect(accounts[0].symbol).toBe("VFV.TO"); // ---- 4. Create a snapshot dated 2026-04-25 ---- // createSnapshot first SELECTs by date (must be empty) then INSERTs. queueSelects([]); // no existing snapshot queueExecutes({ lastInsertId: 50 }); const snapshotId = await createSnapshot({ snapshot_date: "2026-04-25" }); expect(snapshotId).toBe(50); // ---- 5. Save a priced line: 10 shares × $200 = $2000 ---- // upsertSnapshotLines: SELECT snapshot, then DELETE existing lines, then // one INSERT per line, then UPDATE updated_at. queueSelects([ { id: 50, snapshot_date: "2026-04-25", notes: null, created_at: "", updated_at: "", }, ]); queueExecutes( { rowsAffected: 0 }, // delete (no prior lines) { lastInsertId: 200 }, // insert priced line { rowsAffected: 1 } // bump updated_at ); await upsertSnapshotLines(50, [ { account_id: 7, account_kind: "priced", quantity: 10, unit_price: 200, value: 2000, }, ]); // The 2nd execute call should be the INSERT with the priced placeholders. const insertCall = fake.calls.find( (c) => typeof c.sql === "string" && c.sql.includes("INSERT INTO balance_snapshot_lines") ); expect(insertCall).toBeDefined(); expect(insertCall!.params).toEqual([50, 7, 10, 200, 2000]); // ---- 6. Read the lines back ---- queueSelects([ { id: 200, snapshot_id: 50, account_id: 7, quantity: 10, unit_price: 200, value: 2000, price_source: "manual", price_fetched_at: null, created_at: "", updated_at: "", }, ]); const lines = await listLinesBySnapshot(50); expect(lines).toHaveLength(1); expect(lines[0].value).toBe(2000); expect(lines[0].quantity).toBe(10); expect(lines[0].unit_price).toBe(200); // ---- 7. Link a transaction (id=42) as a +CAD deposit (in) ---- // linkTransfer: SELECT existing duplicate (none), then INSERT. queueSelects([]); // no existing duplicate queueExecutes({ lastInsertId: 9 }); const transferId = await linkTransfer(7, 42, "in", "monthly contribution"); expect(transferId).toBe(9); const linkCall = fake.calls.find( (c) => typeof c.sql === "string" && c.sql.includes("INSERT INTO balance_account_transfers") ); expect(linkCall).toBeDefined(); expect(linkCall!.params).toEqual([7, 42, "in", "monthly contribution"]); // ---- 8. Compute the account return ---- vi.mocked(loadProfiles).mockResolvedValueOnce({ active_profile_id: "max", profiles: [ { id: "max", name: "Max", color: "#3b82f6", pin_hash: null, db_filename: "max.db", created_at: "0", }, ], }); const fakeReturn = { value_start: 1500, value_end: 2000, net_contributions: 400, return_pct: 0.0667, // (2000 - 1500 - 400) / (1500 + W*400) ≈ 6.67% annualized_pct: 0.28, is_partial: false, has_no_transfers_warning: false, }; vi.mocked(invoke).mockResolvedValueOnce(fakeReturn); const ret = await computeAccountReturn(7, "2026-01-01", "2026-04-25"); expect(ret).toEqual(fakeReturn); // Wiring check: profile resolution + ISO date forwarding. expect(invoke).toHaveBeenCalledWith("compute_account_return", { dbFilename: "max.db", accountId: 7, periodStart: "2026-01-01", periodEnd: "2026-04-25", }); // ---- Cross-step sanity: every coherent value matches expectations. // The end snapshot value (2000) matches what we saved. expect(ret.value_end).toBe(2000); // The reported return is a finite, non-zero number on a non-trivial period. expect(ret.return_pct).not.toBeNull(); expect(Number.isFinite(ret.return_pct!)).toBe(true); // Net contributions match the 1 linked transfer (+400 in). expect(ret.net_contributions).toBeGreaterThan(0); }); it("supports unlink as the inverse of link", async () => { queueExecutes({ rowsAffected: 1 }); await expect(unlinkTransfer(7, 42)).resolves.toBeUndefined(); const unlinkCall = fake.calls.find( (c) => typeof c.sql === "string" && c.sql.includes("DELETE FROM balance_account_transfers") ); expect(unlinkCall!.params).toEqual([7, 42]); }); it("listAccountTransfers reads back what link wrote (joined view)", async () => { queueSelects([ { id: 9, account_id: 7, transaction_id: 42, direction: "in", notes: "monthly contribution", created_at: "2026-04-25 10:00:00", transaction_date: "2026-04-15", transaction_description: "Wealthsimple contrib", transaction_amount: -400, account_name: "VFV (Wealthsimple)", }, ]); const links = await listAccountTransfers(7); expect(links).toHaveLength(1); expect(links[0].direction).toBe("in"); expect(links[0].account_name).toBe("VFV (Wealthsimple)"); }); }); // --------------------------------------------------------------------------- // 2. Currency lock — CAD only at the MVP // --------------------------------------------------------------------------- // // The MVP locks accounts to CAD: the SQL CHECK is `currency = 'CAD'` and the // service rejects any other value with a typed `currency_unsupported` before // the SQL even fires. Asserts: // - USD is rejected with the typed code, // - the rejection happens BEFORE any SELECT/EXECUTE on the DB, // - the default (no `currency` field) flows through and lands as 'CAD', // - the SQL CHECK side is covered in Rust (lib.rs `migration_v9_*` tests). describe("integration — currency lock (CAD only)", () => { it("rejects USD at the service level with a typed error", async () => { await expect( createBalanceAccount({ balance_category_id: 1, name: "USD account", currency: "USD", }) ).rejects.toBeInstanceOf(BalanceServiceError); try { await createBalanceAccount({ balance_category_id: 1, name: "USD account", currency: "USD", }); } catch (e) { expect((e as BalanceServiceError).code).toBe("currency_unsupported"); } // CRITICAL: the rejection must happen up-front — no DB hit. expect(fake.calls.length).toBe(0); }); it("accepts the default and persists 'CAD' explicitly", async () => { queueSelects([ { id: 1, key: "cash", i18n_key: "balance.category.cash", kind: "simple", sort_order: 10, is_active: 1, is_seed: 1, }, ]); queueExecutes({ lastInsertId: 5 }); await createBalanceAccount({ balance_category_id: 1, name: "Encaisse", }); const insertCall = fake.calls.find( (c) => typeof c.sql === "string" && c.sql.includes("INSERT INTO balance_accounts") ); expect(insertCall).toBeDefined(); // [category_id, name, symbol, currency, notes] expect(insertCall!.params).toEqual([1, "Encaisse", null, "CAD", null]); }); it("rejects EUR / GBP / JPY too — not a CAD-only typo allowlist", async () => { for (const ccy of ["EUR", "GBP", "JPY", "AUD"]) { await expect( createBalanceAccount({ balance_category_id: 1, name: `Mystery ${ccy}`, currency: ccy, }) ).rejects.toMatchObject({ code: "currency_unsupported" }); } expect(fake.calls.length).toBe(0); }); }); // --------------------------------------------------------------------------- // 3. Priced-kind invariant — coherence of the qty × price = value chain // --------------------------------------------------------------------------- // // Tied to the priced-kind path, but at the integration layer: a snapshot // saved with a drifting (qty * price ≠ value) line must be rejected before // any DB mutation, so the SQL CHECK never has the chance to fire and we // don't accidentally clear pre-existing lines. describe("integration — priced invariant rejects out-of-tolerance saves", () => { it("does not run DELETE when one line is bad", async () => { queueSelects([ { id: 50, snapshot_date: "2026-04-25", notes: null, created_at: "", updated_at: "", }, ]); await expect( upsertSnapshotLines(50, [ { account_id: 1, value: 1000 }, { account_id: 7, account_kind: "priced", quantity: 10, unit_price: 25, // expected ≈ 250, way beyond ε value: 999, }, ]) ).rejects.toMatchObject({ code: "snapshot_priced_value_mismatch" }); // Critical safety: the DELETE must not have fired — otherwise the user // would lose all existing lines on a partial save. const deletes = fake.calls.filter( (c) => typeof c.sql === "string" && c.sql.includes("DELETE FROM balance_snapshot_lines") ); expect(deletes).toHaveLength(0); }); it("accepts a drift just within the tolerance", async () => { queueSelects([ { id: 50, snapshot_date: "2026-04-25", notes: null, created_at: "", updated_at: "", }, ]); queueExecutes( { rowsAffected: 0 }, { lastInsertId: 1 }, { rowsAffected: 1 } ); // 12.34 * 1.07 = 13.2038... — drift well within ε = 0.01 const drift = PRICED_VALUE_TOLERANCE * 0.5; await expect( upsertSnapshotLines(50, [ { account_id: 7, account_kind: "priced", quantity: 10, unit_price: 10, value: 100 + drift, }, ]) ).resolves.toBeUndefined(); }); }); // --------------------------------------------------------------------------- // 4. Returns: malformed period dates rejected before the Tauri invoke // --------------------------------------------------------------------------- describe("integration — computeAccountReturn validates dates client-side", () => { it("rejects non-ISO dates without invoking the Rust command", async () => { vi.mocked(loadProfiles).mockResolvedValueOnce({ active_profile_id: "max", profiles: [ { id: "max", name: "Max", color: "#000", pin_hash: null, db_filename: "max.db", created_at: "0", }, ], }); await expect( computeAccountReturn(7, "01/01/2026", "2026-04-25") ).rejects.toBeInstanceOf(BalanceServiceError); // The Tauri side must NOT have been hit — fail-fast on bad dates. expect(invoke).not.toHaveBeenCalled(); }); it("rejects when the active profile cannot be resolved", async () => { vi.mocked(loadProfiles).mockResolvedValueOnce({ active_profile_id: "ghost", profiles: [], }); await expect( computeAccountReturn(7, "2026-01-01", "2026-04-25") ).rejects.toMatchObject({ code: "transfer_active_profile_unknown" }); expect(invoke).not.toHaveBeenCalled(); }); it("forwards a partial-period AccountReturn shape unchanged", async () => { // When `is_partial = true` (no V_start), the Rust side returns a payload // with explicit nulls. The TS shim must not coerce them away. vi.mocked(loadProfiles).mockResolvedValueOnce({ active_profile_id: "max", profiles: [ { id: "max", name: "Max", color: "#000", pin_hash: null, db_filename: "max.db", created_at: "0", }, ], }); const partial = { value_start: null, value_end: 1500, net_contributions: 200, return_pct: null, annualized_pct: null, is_partial: true, has_no_transfers_warning: false, }; vi.mocked(invoke).mockResolvedValueOnce(partial); const out = await computeAccountReturn(7, "2026-01-01", "2026-04-25"); expect(out).toEqual(partial); expect(out.is_partial).toBe(true); expect(out.value_start).toBeNull(); }); });