/** * 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, saveSnapshotAtomic, deleteSnapshot, getSnapshotTotalsByDate, 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, vehicle_type] (#202) expect(insertCall!.params).toEqual([1, "Encaisse", null, "CAD", null, 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(); }); }); // --------------------------------------------------------------------------- // 5. Per-security detail (Étape 2, #217) — detailed snapshot save, rollback, // date-move with holdings, and the golden-value aggregation invariant. // --------------------------------------------------------------------------- // // The unit-level holdings/securities mechanics are covered exhaustively in // balance.service.test.ts (#212). Here we assert the END-TO-END service // behaviour at the integration layer and — critically — the REGRESSION // invariant that makes detailed accounts safe: a detailed account whose // holdings sum to V writes an aggregated line worth exactly V, so every // SUM(value) aggregator sees it identically to a simple account worth V. // // (The real-SQLite proof of that equality lives in lib.rs // `regression_detailed_account_totals_equal_simple_account_totals`, where // SUM() actually runs. Here we prove the TS half: the stored line value is // the rounded-cent SUM, which is the only number the aggregator reads.) describe("integration — detailed snapshot save end-to-end (#217)", () => { it("writes the aggregated line + securities + holdings in ONE BEGIN/COMMIT", async () => { // New snapshot, one detailed account: AAPL (2×50=100) + MSFT (1×200=200) // ⇒ aggregated line value 300, two holdings, all atomic. queueSelects([]); // dup-check: date free // findOrCreateSecurity AAPL then MSFT lookups (post-upsert SELECT). queueSelects([ { id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, ]); queueSelects([ { id: 12, symbol: "MSFT", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, ]); queueExecutes( { rowsAffected: 0 }, // BEGIN { lastInsertId: 42, rowsAffected: 1 }, // INSERT snapshot { rowsAffected: 0 }, // DELETE lines { lastInsertId: 500, rowsAffected: 1 }, // INSERT aggregated line { rowsAffected: 0 }, // DELETE holdings for line 500 { rowsAffected: 1 }, // UPSERT security AAPL { rowsAffected: 1 }, // INSERT holding AAPL { rowsAffected: 1 }, // UPSERT security MSFT { rowsAffected: 1 }, // INSERT holding MSFT { rowsAffected: 1 }, // UPDATE updated_at { rowsAffected: 0 } // COMMIT ); const res = await saveSnapshotAtomic({ existingSnapshotId: null, snapshot_date: "2026-05-30", lines: [ { account_id: 7, value: 300, holdings: [ { symbol: "aapl", asset_type: "stock", quantity: 2, unit_price: 50, value: 100, book_cost: 80 }, { symbol: "msft", asset_type: "stock", quantity: 1, unit_price: 200, value: 200, book_cost: 150 }, ], }, ], }); expect(res.snapshotId).toBe(42); const execCalls = fake.calls.filter( (c) => typeof c.sql === "string" && !c.sql.includes("SELECT") // executes only ); // First write is BEGIN, last is COMMIT, no ROLLBACK anywhere. expect(execCalls[0].sql).toBe("BEGIN"); expect(execCalls[execCalls.length - 1].sql).toBe("COMMIT"); expect(execCalls.some((c) => c.sql === "ROLLBACK")).toBe(false); // CRITICAL invariant: the aggregated line is stored with NULL qty/price and // value = rounded-cent SUM(holdings) = 300 — the exact number the // aggregators read. This is the TS-side golden-value guarantee. const lineInsert = fake.calls.find( (c) => typeof c.sql === "string" && c.sql.includes("INSERT INTO balance_snapshot_lines") ); expect(lineInsert!.params).toEqual([42, 7, 300]); // Both holdings reference the captured line id (500). const holdingInserts = fake.calls.filter( (c) => typeof c.sql === "string" && c.sql.includes("INSERT INTO balance_snapshot_holdings") ); expect(holdingInserts).toHaveLength(2); expect(holdingInserts[0].params?.[0]).toBe(500); expect(holdingInserts[1].params?.[0]).toBe(500); }); it("ROLLS BACK the whole save when a holding INSERT fails (no partial line/holdings)", async () => { queueSelects([]); // dup-check queueSelects([ { id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, ]); // BEGIN, INSERT snapshot, DELETE lines, INSERT line, DELETE holdings, // UPSERT security, then the holding INSERT REJECTS, then ROLLBACK. fake.executeQueue.push({ rowsAffected: 0 }); // BEGIN fake.executeQueue.push({ lastInsertId: 42, rowsAffected: 1 }); // INSERT snapshot fake.executeQueue.push({ rowsAffected: 0 }); // DELETE lines fake.executeQueue.push({ lastInsertId: 500, rowsAffected: 1 }); // INSERT line fake.executeQueue.push({ rowsAffected: 0 }); // DELETE holdings fake.executeQueue.push({ rowsAffected: 1 }); // UPSERT security // Make the NEXT execute (the holding INSERT) reject, then allow ROLLBACK. let failed = false; fake.execute.mockImplementation(async (sql: string, params?: unknown[]) => { fake.calls.push({ sql, params }); if (!failed && sql.includes("INSERT INTO balance_snapshot_holdings")) { failed = true; throw new Error("holding FK violation"); } if (fake.executeQueue.length === 0) { return { rowsAffected: 1, lastInsertId: fake.calls.length }; } return fake.executeQueue.shift()!; }); await expect( saveSnapshotAtomic({ existingSnapshotId: null, snapshot_date: "2026-05-30", lines: [ { account_id: 7, value: 100, holdings: [ { symbol: "AAPL", asset_type: "stock", quantity: 2, unit_price: 50, value: 100 }, ], }, ], }) ).rejects.toThrow("holding FK violation"); // ROLLBACK was the last write; COMMIT never happened ⇒ no partial state. const execCalls = fake.calls.filter( (c) => typeof c.sql === "string" && !c.sql.includes("SELECT") ); expect(execCalls[execCalls.length - 1].sql).toBe("ROLLBACK"); expect(execCalls.some((c) => c.sql === "COMMIT")).toBe(false); }); it("moves a detailed snapshot's date — line AND holdings move together (#200)", async () => { // Edit-mode move: the date UPDATE + the line/holdings rewrite happen in the // SAME transaction, so the holdings follow their line to the new date. queueSelects([]); // collision check: target date free queueSelects([ { id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, ]); queueExecutes( { rowsAffected: 0 }, // BEGIN { rowsAffected: 1 }, // UPDATE snapshot_date { rowsAffected: 0 }, // DELETE lines (cascades old holdings) { lastInsertId: 800, rowsAffected: 1 }, // INSERT aggregated line at new date { rowsAffected: 0 }, // DELETE holdings for line 800 { rowsAffected: 1 }, // UPSERT security AAPL { rowsAffected: 1 }, // INSERT holding AAPL { rowsAffected: 1 }, // UPDATE updated_at { rowsAffected: 0 } // COMMIT ); const res = await saveSnapshotAtomic({ existingSnapshotId: 5, snapshot_date: "2026-04-15", moveToDate: "2026-05-20", lines: [ { account_id: 7, value: 100, holdings: [ { symbol: "AAPL", asset_type: "stock", quantity: 1, unit_price: 100, value: 100, book_cost: 90 }, ], }, ], }); expect(res.snapshotId).toBe(5); // The date UPDATE precedes the line rewrite (so a collision rolls back both). const dateUpdate = fake.calls.find( (c) => typeof c.sql === "string" && c.sql.includes("SET snapshot_date = $1") ); expect(dateUpdate!.params).toEqual(["2026-05-20", 5]); // The holding is re-inserted alongside the moved line (referencing line 800). const holdingInsert = fake.calls.find( (c) => typeof c.sql === "string" && c.sql.includes("INSERT INTO balance_snapshot_holdings") ); expect(holdingInsert).toBeDefined(); expect(holdingInsert!.params?.[0]).toBe(800); // Whole thing commits; the move + holdings are one unit. const execCalls = fake.calls.filter( (c) => typeof c.sql === "string" && !c.sql.includes("SELECT") ); expect(execCalls[execCalls.length - 1].sql).toBe("COMMIT"); expect(execCalls.some((c) => c.sql === "ROLLBACK")).toBe(false); }); it("rolls back the move (date + holdings) when the target date collides (#200)", async () => { queueSelects([{ id: 99 }]); // collision: another snapshot already at target queueExecutes( { rowsAffected: 0 }, // BEGIN { rowsAffected: 0 } // ROLLBACK ); await expect( saveSnapshotAtomic({ existingSnapshotId: 5, snapshot_date: "2026-04-15", moveToDate: "2026-05-20", lines: [ { account_id: 7, value: 100, holdings: [ { symbol: "AAPL", asset_type: "stock", quantity: 1, unit_price: 100, value: 100 }, ], }, ], }) ).rejects.toMatchObject({ code: "snapshot_date_exists" }); // No date UPDATE, no holding INSERT — the collision aborted before any write. expect( fake.calls.some( (c) => typeof c.sql === "string" && c.sql.includes("SET snapshot_date") ) ).toBe(false); expect( fake.calls.some( (c) => typeof c.sql === "string" && c.sql.includes("INSERT INTO balance_snapshot_holdings") ) ).toBe(false); const execCalls = fake.calls.filter( (c) => typeof c.sql === "string" && !c.sql.includes("SELECT") ); expect(execCalls[execCalls.length - 1].sql).toBe("ROLLBACK"); }); }); describe("integration — golden-value invariant: detailed line feeds aggregators like a simple line (#217)", () => { // The regression contract: whatever the holdings are, the aggregated line // value the service writes equals the value a simple account would carry — // so getSnapshotTotalsByDate (which only ever reads l.value) is unchanged. // We prove BOTH halves drive identical aggregator output by feeding the // aggregator the canned total in each case and asserting equality. it("a detailed account worth ΣV stores the same line value as a simple account worth V", async () => { // --- Detailed path: holdings 100 + 200 ⇒ line value must be 300. --- queueSelects([]); // dup-check queueSelects([ { id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, ]); queueSelects([ { id: 12, symbol: "MSFT", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, ]); queueExecutes( { rowsAffected: 0 }, // BEGIN { lastInsertId: 42, rowsAffected: 1 }, // INSERT snapshot { rowsAffected: 0 }, // DELETE lines { lastInsertId: 500, rowsAffected: 1 }, // INSERT aggregated line { rowsAffected: 0 }, // DELETE holdings { rowsAffected: 1 }, // UPSERT AAPL { rowsAffected: 1 }, // INSERT holding AAPL { rowsAffected: 1 }, // UPSERT MSFT { rowsAffected: 1 }, // INSERT holding MSFT { rowsAffected: 1 }, // UPDATE updated_at { rowsAffected: 0 } // COMMIT ); await saveSnapshotAtomic({ existingSnapshotId: null, snapshot_date: "2026-05-30", lines: [ { account_id: 7, value: 300, holdings: [ { symbol: "aapl", asset_type: "stock", quantity: 2, unit_price: 50, value: 100 }, { symbol: "msft", asset_type: "stock", quantity: 1, unit_price: 200, value: 200 }, ], }, ], }); const detailedLineInsert = fake.calls.find( (c) => typeof c.sql === "string" && c.sql.includes("INSERT INTO balance_snapshot_lines") ); const detailedStoredValue = detailedLineInsert!.params![2]; expect(detailedStoredValue).toBe(300); // --- Simple path: a simple account worth 300 stores the same value. --- fake = makeFakeDbLocal(); vi.mocked(getDb).mockResolvedValue( { select: fake.select, execute: fake.execute } as never ); fake.selectQueue.push([ { id: 5, snapshot_date: "2026-05-30", notes: null, created_at: "", updated_at: "" }, ]); // getSnapshotById queueExecutes( { rowsAffected: 0 }, // BEGIN { rowsAffected: 0 }, // DELETE lines { lastInsertId: 700, rowsAffected: 1 }, // INSERT simple line { rowsAffected: 1 }, // UPDATE updated_at { rowsAffected: 0 } // COMMIT ); await upsertSnapshotLines(5, [{ account_id: 7, value: 300 }]); const simpleLineInsert = fake.calls.find( (c) => typeof c.sql === "string" && c.sql.includes("INSERT INTO balance_snapshot_lines") ); const simpleStoredValue = simpleLineInsert!.params![2]; // The frozen golden number: BOTH paths persist value = 300. expect(detailedStoredValue).toBe(simpleStoredValue); expect(simpleStoredValue).toBe(300); // And the aggregator (which reads only l.value) returns the same total in // both worlds — proven by feeding it the canned per-date SUM each path // produces. Detailed world: fake = makeFakeDbLocal(); vi.mocked(getDb).mockResolvedValue( { select: fake.select, execute: fake.execute } as never ); fake.selectQueue.push([{ snapshot_date: "2026-05-30", total: 300 }]); const detailedTotals = await getSnapshotTotalsByDate(); // Simple world: identical canned SUM ⇒ identical aggregator output. fake = makeFakeDbLocal(); vi.mocked(getDb).mockResolvedValue( { select: fake.select, execute: fake.execute } as never ); fake.selectQueue.push([{ snapshot_date: "2026-05-30", total: 300 }]); const simpleTotals = await getSnapshotTotalsByDate(); expect(detailedTotals).toEqual(simpleTotals); expect(detailedTotals).toEqual([{ snapshot_date: "2026-05-30", total: 300 }]); }); }); describe("integration — snapshot deletion cascades to holdings at the service boundary (#217)", () => { it("deleteSnapshot issues the DELETE that the DB cascades to lines + holdings", async () => { // The two-hop CASCADE is a DB-FK behaviour (proven in lib.rs // regression_snapshot_delete_cascades_to_holdings). At the service layer we // assert the single DELETE is emitted on the right row — the FK does the // rest. Guard against a regression that would start manually deleting // holdings (a sign the CASCADE was dropped) or skip the parent delete. fake.selectQueue.push([ { id: 50, snapshot_date: "2026-05-30", notes: null, created_at: "", updated_at: "" }, ]); // getSnapshotById fake.executeQueue.push({ rowsAffected: 1 }); await deleteSnapshot(50); const deletes = fake.calls.filter( (c) => typeof c.sql === "string" && c.sql.startsWith("DELETE") ); // Exactly one DELETE — on balance_snapshots. No manual holdings/line wipes // (the FK CASCADE handles those; a manual delete would be the regression). expect(deletes).toHaveLength(1); expect(deletes[0].sql).toContain("DELETE FROM balance_snapshots WHERE id = $1"); expect(deletes[0].params).toEqual([50]); }); }); // A standalone FakeDb factory for the multi-DB golden-value test, which swaps // the active db handle mid-test. Mirrors makeFakeDb but is reusable inline. function makeFakeDbLocal(): 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) { return { rowsAffected: 1, lastInsertId: db.calls.length }; } return db.executeQueue.shift(); }); return db; }