diff --git a/src/__integration__/balance-flow.test.ts b/src/__integration__/balance-flow.test.ts new file mode 100644 index 0000000..e7489cf --- /dev/null +++ b/src/__integration__/balance-flow.test.ts @@ -0,0 +1,574 @@ +/** + * 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, + }); + 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(); + }); +});