import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("./db", () => ({ getDb: vi.fn(), })); vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), })); vi.mock("./profileService", () => ({ loadProfiles: vi.fn(), })); import { getDb } from "./db"; import { invoke } from "@tauri-apps/api/core"; import { loadProfiles } from "./profileService"; import { listBalanceCategories, createBalanceCategory, updateBalanceCategory, deleteBalanceCategory, listBalanceAccounts, createBalanceAccount, updateBalanceAccount, archiveBalanceAccount, unarchiveBalanceAccount, listSnapshots, getSnapshotByDate, createSnapshot, updateSnapshot, deleteSnapshot, listLinesBySnapshot, upsertSnapshotLines, getPreviousSnapshot, validateLineKindInvariants, PRICED_VALUE_TOLERANCE, BalanceServiceError, getSnapshotTotalsByDate, getSnapshotTotalsByCategoryAndDate, getAccountsLatestSnapshot, getAccountsPeriodAnchor, computeAccountReturn, linkTransfer, unlinkTransfer, listAccountTransfers, listLinkedTransactionIds, listAllLinkedTransfersForTooltip, isLinkedTransactionFkError, suggestTransferDirection, } from "./balance.service"; const mockSelect = vi.fn(); const mockExecute = vi.fn(); const mockDb = { select: mockSelect, execute: mockExecute }; beforeEach(() => { vi.mocked(getDb).mockResolvedValue(mockDb as never); mockSelect.mockReset(); mockExecute.mockReset(); }); // ----------------------------------------------------------------------------- // Categories // ----------------------------------------------------------------------------- describe("listBalanceCategories", () => { it("orders by sort_order then key", async () => { mockSelect.mockResolvedValueOnce([]); await listBalanceCategories(); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("FROM balance_categories"); expect(sql).toContain("ORDER BY sort_order, key"); }); }); describe("createBalanceCategory", () => { it("rejects an empty key", async () => { await expect( createBalanceCategory({ key: " ", i18n_key: "x", kind: "simple" }) ).rejects.toBeInstanceOf(BalanceServiceError); expect(mockExecute).not.toHaveBeenCalled(); }); it("rejects an invalid kind", async () => { await expect( createBalanceCategory({ key: "custom", i18n_key: "balance.category.custom", // @ts-expect-error testing runtime guard kind: "weird", }) ).rejects.toBeInstanceOf(BalanceServiceError); expect(mockExecute).not.toHaveBeenCalled(); }); it("inserts with is_seed = 0 and returns lastInsertId", async () => { mockExecute.mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }); const id = await createBalanceCategory({ key: "ferr", i18n_key: "balance.category.ferr", kind: "simple", sort_order: 35, }); expect(id).toBe(42); const sql = mockExecute.mock.calls[0][0] as string; const params = mockExecute.mock.calls[0][1] as unknown[]; expect(sql).toContain("INSERT INTO balance_categories"); expect(sql).toContain("is_seed"); expect(sql).toMatch(/0\)$/); // is_seed hardcoded to 0 expect(params).toEqual(["ferr", "balance.category.ferr", "simple", 35]); }); }); describe("deleteBalanceCategory", () => { it("refuses to delete a seeded category", async () => { mockSelect.mockResolvedValueOnce([ { id: 1, key: "cash", i18n_key: "balance.category.cash", kind: "simple", sort_order: 10, is_active: 1, is_seed: 1, }, ]); await expect(deleteBalanceCategory(1)).rejects.toMatchObject({ code: "category_seed_protected", }); expect(mockExecute).not.toHaveBeenCalled(); }); it("refuses to delete a category with linked accounts", async () => { // 1st select = getBalanceCategory; 2nd select = COUNT(*) accounts linked mockSelect .mockResolvedValueOnce([ { id: 8, key: "ferr", i18n_key: "balance.category.ferr", kind: "simple", sort_order: 35, is_active: 1, is_seed: 0, }, ]) .mockResolvedValueOnce([{ count: 2 }]); await expect(deleteBalanceCategory(8)).rejects.toMatchObject({ code: "category_has_accounts", }); expect(mockExecute).not.toHaveBeenCalled(); }); it("deletes a user-created category with no linked accounts", async () => { mockSelect .mockResolvedValueOnce([ { id: 8, key: "ferr", i18n_key: "balance.category.ferr", kind: "simple", sort_order: 35, is_active: 1, is_seed: 0, }, ]) .mockResolvedValueOnce([{ count: 0 }]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await deleteBalanceCategory(8); expect(mockExecute).toHaveBeenCalledWith( "DELETE FROM balance_categories WHERE id = $1", [8] ); }); }); describe("updateBalanceCategory", () => { it("renames a seeded category (allowed)", async () => { mockSelect.mockResolvedValueOnce([ { id: 1, key: "cash", i18n_key: "balance.category.cash", kind: "simple", sort_order: 10, is_active: 1, is_seed: 1, }, ]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await updateBalanceCategory(1, { i18n_key: "balance.category.cash_renamed" }); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[0]).toBe("balance.category.cash_renamed"); }); it("rejects update on missing category", async () => { mockSelect.mockResolvedValueOnce([]); await expect(updateBalanceCategory(999, { sort_order: 5 })).rejects.toMatchObject({ code: "category_not_found", }); }); }); // ----------------------------------------------------------------------------- // Accounts // ----------------------------------------------------------------------------- describe("listBalanceAccounts", () => { it("excludes archived accounts by default", async () => { mockSelect.mockResolvedValueOnce([]); await listBalanceAccounts(); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("a.is_active = 1"); expect(sql).toContain("a.archived_at IS NULL"); }); it("includes archived accounts when requested", async () => { mockSelect.mockResolvedValueOnce([]); await listBalanceAccounts({ includeArchived: true }); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).not.toContain("archived_at IS NULL"); }); }); describe("createBalanceAccount", () => { it("rejects empty name", async () => { await expect( createBalanceAccount({ balance_category_id: 1, name: " " }) ).rejects.toMatchObject({ code: "name_required" }); }); it("rejects non-CAD currency at the MVP", async () => { await expect( createBalanceAccount({ balance_category_id: 1, name: "USD account", currency: "USD", }) ).rejects.toMatchObject({ code: "currency_unsupported" }); expect(mockExecute).not.toHaveBeenCalled(); }); it("rejects when the category does not exist", async () => { mockSelect.mockResolvedValueOnce([]); // getBalanceCategory returns null await expect( createBalanceAccount({ balance_category_id: 999, name: "Mystery" }) ).rejects.toMatchObject({ code: "category_not_found" }); }); it("inserts with default CAD currency", async () => { mockSelect.mockResolvedValueOnce([ { id: 1, key: "cash", i18n_key: "balance.category.cash", kind: "simple", sort_order: 10, is_active: 1, is_seed: 1, }, ]); mockExecute.mockResolvedValueOnce({ lastInsertId: 7, rowsAffected: 1 }); const id = await createBalanceAccount({ balance_category_id: 1, name: "Encaisse Wealthsimple", }); expect(id).toBe(7); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params).toEqual([1, "Encaisse Wealthsimple", null, "CAD", null]); }); }); describe("updateBalanceAccount", () => { it("rejects when account does not exist", async () => { mockSelect.mockResolvedValueOnce([]); await expect(updateBalanceAccount(42, { name: "x" })).rejects.toMatchObject({ code: "account_not_found", }); }); it("normalizes empty symbol to null", async () => { mockSelect.mockResolvedValueOnce([ { id: 7, balance_category_id: 1, name: "Encaisse", symbol: "OLD", currency: "CAD", notes: null, is_active: 1, archived_at: null, created_at: "", updated_at: "", }, ]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await updateBalanceAccount(7, { symbol: " " }); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[2]).toBeNull(); // symbol }); }); describe("archiveBalanceAccount / unarchiveBalanceAccount", () => { it("archives an existing account", async () => { mockSelect.mockResolvedValueOnce([ { id: 7, balance_category_id: 1, name: "Encaisse", symbol: null, currency: "CAD", notes: null, is_active: 1, archived_at: null, created_at: "", updated_at: "", }, ]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await archiveBalanceAccount(7); const sql = mockExecute.mock.calls[0][0] as string; expect(sql).toContain("archived_at = CURRENT_TIMESTAMP"); expect(sql).toContain("is_active = 0"); }); it("unarchives an existing account", async () => { mockSelect.mockResolvedValueOnce([ { id: 7, balance_category_id: 1, name: "Encaisse", symbol: null, currency: "CAD", notes: null, is_active: 0, archived_at: "2026-04-25 10:00:00", created_at: "", updated_at: "", }, ]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await unarchiveBalanceAccount(7); const sql = mockExecute.mock.calls[0][0] as string; expect(sql).toContain("archived_at = NULL"); expect(sql).toContain("is_active = 1"); }); }); // ----------------------------------------------------------------------------- // Snapshots + lines (Issue #146 / Bilan #1b — simple kind only) // ----------------------------------------------------------------------------- const FAKE_SNAPSHOT = { id: 5, snapshot_date: "2026-04-15", notes: null, created_at: "", updated_at: "", }; describe("listSnapshots", () => { it("orders by snapshot_date DESC", async () => { mockSelect.mockResolvedValueOnce([]); await listSnapshots(); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("FROM balance_snapshots"); expect(sql).toContain("ORDER BY snapshot_date DESC"); }); }); describe("getSnapshotByDate", () => { it("rejects empty / invalid dates with snapshot_date_required", async () => { await expect(getSnapshotByDate("")).rejects.toMatchObject({ code: "snapshot_date_required", }); await expect(getSnapshotByDate("2026/04/15")).rejects.toMatchObject({ code: "snapshot_date_required", }); expect(mockSelect).not.toHaveBeenCalled(); }); it("returns the snapshot row when found", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); const got = await getSnapshotByDate("2026-04-15"); expect(got).toEqual(FAKE_SNAPSHOT); expect(mockSelect.mock.calls[0][1]).toEqual(["2026-04-15"]); }); it("returns null when no row matches", async () => { mockSelect.mockResolvedValueOnce([]); expect(await getSnapshotByDate("2026-04-15")).toBeNull(); }); }); describe("createSnapshot", () => { it("rejects an invalid date", async () => { await expect( createSnapshot({ snapshot_date: " " }) ).rejects.toMatchObject({ code: "snapshot_date_required" }); expect(mockExecute).not.toHaveBeenCalled(); }); it("rejects a duplicate snapshot date with snapshot_date_taken", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); // existing await expect( createSnapshot({ snapshot_date: "2026-04-15" }) ).rejects.toMatchObject({ code: "snapshot_date_taken" }); expect(mockExecute).not.toHaveBeenCalled(); }); it("inserts a new snapshot and returns its id", async () => { mockSelect.mockResolvedValueOnce([]); // no existing mockExecute.mockResolvedValueOnce({ lastInsertId: 12, rowsAffected: 1 }); const id = await createSnapshot({ snapshot_date: "2026-04-25", notes: " monthly check ", }); expect(id).toBe(12); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params).toEqual(["2026-04-25", "monthly check"]); }); }); describe("updateSnapshot", () => { it("rejects when snapshot does not exist", async () => { mockSelect.mockResolvedValueOnce([]); await expect( updateSnapshot(999, { notes: "x" }) ).rejects.toMatchObject({ code: "snapshot_not_found" }); }); it("normalizes empty notes to null", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await updateSnapshot(5, { notes: " " }); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[0]).toBeNull(); }); }); describe("deleteSnapshot", () => { it("rejects when snapshot does not exist", async () => { mockSelect.mockResolvedValueOnce([]); await expect(deleteSnapshot(999)).rejects.toMatchObject({ code: "snapshot_not_found", }); }); it("deletes when found (lines cascade via FK)", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await deleteSnapshot(5); expect(mockExecute).toHaveBeenCalledWith( "DELETE FROM balance_snapshots WHERE id = $1", [5] ); }); }); describe("listLinesBySnapshot", () => { it("orders by id and filters by snapshot_id", async () => { mockSelect.mockResolvedValueOnce([]); await listLinesBySnapshot(5); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("FROM balance_snapshot_lines"); expect(sql).toContain("WHERE snapshot_id = $1"); expect(sql).toContain("ORDER BY id"); expect(mockSelect.mock.calls[0][1]).toEqual([5]); }); }); describe("upsertSnapshotLines (simple kind)", () => { it("rejects when the parent snapshot is missing", async () => { mockSelect.mockResolvedValueOnce([]); await expect( upsertSnapshotLines(99, [{ account_id: 1, value: 1000 }]) ).rejects.toMatchObject({ code: "snapshot_not_found" }); expect(mockExecute).not.toHaveBeenCalled(); }); it("rejects non-finite values with snapshot_value_invalid", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); await expect( upsertSnapshotLines(5, [ { account_id: 1, value: 1000 }, // @ts-expect-error testing runtime guard { account_id: 2, value: "not a number" }, ]) ).rejects.toMatchObject({ code: "snapshot_value_invalid" }); // Validation happens up-front, before any mutation expect(mockExecute).not.toHaveBeenCalled(); }); it("rejects NaN and Infinity", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); await expect( upsertSnapshotLines(5, [{ account_id: 1, value: NaN }]) ).rejects.toMatchObject({ code: "snapshot_value_invalid" }); mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); await expect( upsertSnapshotLines(5, [{ account_id: 1, value: Infinity }]) ).rejects.toMatchObject({ code: "snapshot_value_invalid" }); }); it("clears existing lines, inserts each line with NULL quantity/unit_price, and bumps updated_at", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); mockExecute .mockResolvedValueOnce({ rowsAffected: 1 }) // delete .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert 1 .mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert 2 .mockResolvedValueOnce({ rowsAffected: 1 }); // update updated_at await upsertSnapshotLines(5, [ { account_id: 1, value: 1234.56 }, { account_id: 2, value: 0 }, ]); // 1st call = DELETE expect(mockExecute.mock.calls[0][0]).toContain( "DELETE FROM balance_snapshot_lines" ); // Inserts use literal NULL for quantity/unit_price (simple kind invariant) const insertSql = mockExecute.mock.calls[1][0] as string; expect(insertSql).toContain("INSERT INTO balance_snapshot_lines"); expect(insertSql).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/); expect(insertSql).toContain("'manual'"); // First insert params expect(mockExecute.mock.calls[1][1]).toEqual([5, 1, 1234.56]); // Second insert params (zero is allowed) expect(mockExecute.mock.calls[2][1]).toEqual([5, 2, 0]); // Final call = UPDATE updated_at on parent snapshot expect(mockExecute.mock.calls[3][0]).toContain( "UPDATE balance_snapshots" ); expect(mockExecute.mock.calls[3][0]).toContain("updated_at"); }); it("clears all lines when called with an empty array", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); mockExecute .mockResolvedValueOnce({ rowsAffected: 3 }) // delete only .mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at await upsertSnapshotLines(5, []); // Only DELETE + UPDATE updated_at — no INSERTs expect(mockExecute).toHaveBeenCalledTimes(2); }); }); describe("getPreviousSnapshot", () => { it("returns the most recent snapshot strictly before referenceDate", async () => { mockSelect.mockResolvedValueOnce([ { ...FAKE_SNAPSHOT, snapshot_date: "2026-03-15" }, ]); const got = await getPreviousSnapshot("2026-04-15"); expect(got?.snapshot_date).toBe("2026-03-15"); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("snapshot_date < $1"); expect(sql).toContain("ORDER BY snapshot_date DESC"); expect(sql).toContain("LIMIT 1"); }); it("returns null when no earlier snapshot exists", async () => { mockSelect.mockResolvedValueOnce([]); expect(await getPreviousSnapshot("2026-04-15")).toBeNull(); }); it("rejects an invalid reference date", async () => { await expect(getPreviousSnapshot("nope")).rejects.toMatchObject({ code: "snapshot_date_required", }); }); }); // ----------------------------------------------------------------------------- // Priced-kind validation (Issue #140 / Bilan #2) // ----------------------------------------------------------------------------- describe("validateLineKindInvariants — simple kind", () => { it("accepts a clean simple line", () => { expect(() => validateLineKindInvariants({ account_id: 1, value: 1234.56 }) ).not.toThrow(); }); it("accepts simple kind with explicit account_kind = simple", () => { expect(() => validateLineKindInvariants({ account_id: 1, value: 0, account_kind: "simple", }) ).not.toThrow(); }); it("rejects a simple line carrying a quantity", () => { expect(() => validateLineKindInvariants({ account_id: 1, value: 100, account_kind: "simple", quantity: 10, }) ).toThrowError(BalanceServiceError); }); it("rejects a simple line carrying a unit_price", () => { expect(() => validateLineKindInvariants({ account_id: 1, value: 100, account_kind: "simple", unit_price: 10, }) ).toThrowError(BalanceServiceError); }); it("rejects a non-finite value", () => { expect(() => validateLineKindInvariants({ account_id: 1, value: NaN }) ).toThrowError(BalanceServiceError); expect(() => validateLineKindInvariants({ account_id: 1, value: Infinity }) ).toThrowError(BalanceServiceError); }); }); describe("validateLineKindInvariants — priced kind", () => { const baseInput = { account_id: 7, account_kind: "priced" as const, }; it("accepts a clean priced line where value === qty * price", () => { expect(() => validateLineKindInvariants({ ...baseInput, quantity: 10, unit_price: 25.5, value: 255, }) ).not.toThrow(); }); it("rejects a priced line missing the quantity", () => { expect(() => validateLineKindInvariants({ ...baseInput, quantity: null, unit_price: 25.5, value: 255, }) ).toMatchObject; // sanity, real assertion below expect(() => validateLineKindInvariants({ ...baseInput, quantity: null, unit_price: 25.5, value: 255, }) ).toThrowError(BalanceServiceError); try { validateLineKindInvariants({ ...baseInput, quantity: null, unit_price: 25.5, value: 255, }); } catch (e) { expect((e as BalanceServiceError).code).toBe( "snapshot_priced_quantity_required" ); } }); it("rejects a priced line missing the unit_price", () => { try { validateLineKindInvariants({ ...baseInput, quantity: 10, unit_price: null, value: 255, }); } catch (e) { expect((e as BalanceServiceError).code).toBe( "snapshot_priced_unit_price_required" ); return; } throw new Error("expected throw"); }); it("rejects a priced line where value disagrees with qty × price", () => { try { validateLineKindInvariants({ ...baseInput, quantity: 10, unit_price: 25.5, // off by way more than tolerance — 255.0 expected, 999 saved value: 999, }); } catch (e) { expect((e as BalanceServiceError).code).toBe( "snapshot_priced_value_mismatch" ); return; } throw new Error("expected throw"); }); it("accepts a priced line within the tolerance ε", () => { // 12.34 × 1.07 = 13.2038 in math, but JS gives 13.2038000000000002. // The drift is well within ε = 0.01. const qty = 12.34; const price = 1.07; expect(() => validateLineKindInvariants({ ...baseInput, quantity: qty, unit_price: price, value: 13.2038, }) ).not.toThrow(); }); it("rejects a priced line just outside the tolerance ε", () => { // expected = 100, threshold ε = 0.01 → 100.011 fails, 100.005 passes. expect(() => validateLineKindInvariants({ ...baseInput, quantity: 10, unit_price: 10, value: 100 + PRICED_VALUE_TOLERANCE * 1.5, }) ).toThrowError(BalanceServiceError); expect(() => validateLineKindInvariants({ ...baseInput, quantity: 10, unit_price: 10, value: 100 + PRICED_VALUE_TOLERANCE * 0.5, }) ).not.toThrow(); }); it("rejects priced when quantity is non-finite", () => { expect(() => validateLineKindInvariants({ ...baseInput, quantity: NaN, unit_price: 10, value: 100, }) ).toThrowError(BalanceServiceError); }); }); describe("upsertSnapshotLines — priced kind", () => { it("rejects a priced line where qty × price drifts beyond ε", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); await expect( upsertSnapshotLines(5, [ { account_id: 7, account_kind: "priced", quantity: 10, unit_price: 25, value: 999, // wrong on purpose }, ]) ).rejects.toMatchObject({ code: "snapshot_priced_value_mismatch" }); // No DB mutation when validation fails up-front. expect(mockExecute).not.toHaveBeenCalled(); }); it("inserts a priced line with quantity + unit_price + value", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); mockExecute .mockResolvedValueOnce({ rowsAffected: 1 }) // delete .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert .mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at await upsertSnapshotLines(5, [ { account_id: 7, account_kind: "priced", quantity: 10, unit_price: 25.5, value: 255, }, ]); const insertSql = mockExecute.mock.calls[1][0] as string; expect(insertSql).toContain("INSERT INTO balance_snapshot_lines"); // Priced inserts use parameter placeholders for qty/price (not literal NULLs) expect(insertSql).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*\$3,\s*\$4,\s*\$5/); expect(mockExecute.mock.calls[1][1]).toEqual([5, 7, 10, 25.5, 255]); }); it("supports a mix of simple + priced lines in the same batch", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); mockExecute .mockResolvedValueOnce({ rowsAffected: 1 }) // delete .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert simple .mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert priced .mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at await upsertSnapshotLines(5, [ { account_id: 1, value: 1000 }, { account_id: 7, account_kind: "priced", quantity: 10, unit_price: 50, value: 500, }, ]); // Simple insert uses literal NULLs for qty/price expect(mockExecute.mock.calls[1][0] as string).toMatch( /VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/ ); expect(mockExecute.mock.calls[1][1]).toEqual([5, 1, 1000]); // Priced insert uses placeholders expect(mockExecute.mock.calls[2][0] as string).toMatch( /VALUES\s*\(\s*\$1,\s*\$2,\s*\$3,\s*\$4,\s*\$5/ ); expect(mockExecute.mock.calls[2][1]).toEqual([5, 7, 10, 50, 500]); }); }); // ----------------------------------------------------------------------------- // Time-series aggregators (Issue #141 / Bilan #3) // ----------------------------------------------------------------------------- describe("getSnapshotTotalsByDate", () => { it("returns an empty array on an empty DB", async () => { mockSelect.mockResolvedValueOnce([]); expect(await getSnapshotTotalsByDate()).toEqual([]); }); it("aggregates SUM(value) and orders ASC by snapshot_date", async () => { mockSelect.mockResolvedValueOnce([ { snapshot_date: "2026-01-31", total: 1000 }, { snapshot_date: "2026-02-28", total: 1100 }, { snapshot_date: "2026-03-31", total: 1250 }, ]); const out = await getSnapshotTotalsByDate(); expect(out).toEqual([ { snapshot_date: "2026-01-31", total: 1000 }, { snapshot_date: "2026-02-28", total: 1100 }, { snapshot_date: "2026-03-31", total: 1250 }, ]); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("FROM balance_snapshots"); expect(sql).toContain("LEFT JOIN balance_snapshot_lines"); expect(sql).toContain("GROUP BY s.snapshot_date"); expect(sql).toContain("ORDER BY s.snapshot_date ASC"); // Empty range → no WHERE clause + no params expect(sql).not.toContain("WHERE"); expect(mockSelect.mock.calls[0][1]).toEqual([]); }); it("applies an inclusive [from, to] date range filter", async () => { mockSelect.mockResolvedValueOnce([]); await getSnapshotTotalsByDate({ from: "2026-01-01", to: "2026-03-31" }); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("WHERE"); expect(sql).toContain("s.snapshot_date >="); expect(sql).toContain("s.snapshot_date <="); expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-03-31"]); }); it("supports an open-ended `from` only", async () => { mockSelect.mockResolvedValueOnce([]); await getSnapshotTotalsByDate({ from: "2026-01-01" }); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("s.snapshot_date >="); expect(sql).not.toContain("s.snapshot_date <="); expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]); }); }); describe("getSnapshotTotalsByCategoryAndDate", () => { it("returns [] on empty DB", async () => { mockSelect.mockResolvedValueOnce([]); expect(await getSnapshotTotalsByCategoryAndDate()).toEqual([]); }); it("buckets multiple category rows under the same snapshot_date", async () => { mockSelect.mockResolvedValueOnce([ { snapshot_date: "2026-01-31", category_key: "cash", total: 500 }, { snapshot_date: "2026-01-31", category_key: "tfsa", total: 1500 }, { snapshot_date: "2026-02-28", category_key: "cash", total: 700 }, { snapshot_date: "2026-02-28", category_key: "tfsa", total: 1700 }, ]); const out = await getSnapshotTotalsByCategoryAndDate(); expect(out).toEqual([ { snapshot_date: "2026-01-31", byCategory: { cash: 500, tfsa: 1500 }, }, { snapshot_date: "2026-02-28", byCategory: { cash: 700, tfsa: 1700 }, }, ]); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("INNER JOIN balance_snapshot_lines"); expect(sql).toContain("INNER JOIN balance_accounts"); expect(sql).toContain("INNER JOIN balance_categories"); expect(sql).toContain("GROUP BY s.snapshot_date, c.key"); }); it("applies date range params when supplied", async () => { mockSelect.mockResolvedValueOnce([]); await getSnapshotTotalsByCategoryAndDate({ from: "2026-01-01", to: "2026-12-31", }); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("WHERE"); expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-12-31"]); }); }); describe("getAccountsLatestSnapshot", () => { it("returns [] when there are no active accounts", async () => { mockSelect.mockResolvedValueOnce([]); expect(await getAccountsLatestSnapshot()).toEqual([]); }); it("returns one row per active account joined with category metadata", async () => { mockSelect.mockResolvedValueOnce([ { account_id: 1, account_name: "BMO chequing", symbol: null, balance_category_id: 10, category_key: "cash", category_i18n_key: "balance.category.cash", category_kind: "simple", latest_snapshot_date: "2026-03-31", latest_value: 1234.56, }, { account_id: 2, account_name: "Wealthsimple TFSA", symbol: null, balance_category_id: 11, category_key: "tfsa", category_i18n_key: "balance.category.tfsa", category_kind: "simple", latest_snapshot_date: null, latest_value: null, }, ]); const out = await getAccountsLatestSnapshot(); expect(out).toHaveLength(2); expect(out[0].latest_value).toBe(1234.56); expect(out[1].latest_value).toBeNull(); const sql = mockSelect.mock.calls[0][0] as string; // Filter: only active, non-archived accounts. expect(sql).toContain("a.is_active = 1"); expect(sql).toContain("a.archived_at IS NULL"); // LEFT JOIN-equivalent: scalar subquery so accounts with no lines still surface. expect(sql).toContain("ORDER BY s.snapshot_date DESC"); expect(sql).toContain("LIMIT 1"); }); }); describe("getAccountsPeriodAnchor", () => { it("queries with a from-only filter", async () => { mockSelect.mockResolvedValueOnce([ { account_id: 1, anchor_snapshot_date: "2026-01-31", anchor_value: 1000 }, ]); const rows = await getAccountsPeriodAnchor({ from: "2026-01-01" }); expect(rows).toHaveLength(1); expect(rows[0].anchor_value).toBe(1000); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("MIN(s.snapshot_date)"); expect(sql).toContain("GROUP BY l.account_id"); expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]); }); it("queries with both from and to", async () => { mockSelect.mockResolvedValueOnce([]); await getAccountsPeriodAnchor({ from: "2026-01-01", to: "2026-12-31" }); expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-12-31"]); }); it("works with an empty range (open-ended)", async () => { mockSelect.mockResolvedValueOnce([]); await getAccountsPeriodAnchor({}); const sql = mockSelect.mock.calls[0][0] as string; // No WHERE clause when neither bound is set. expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/); }); }); // ----------------------------------------------------------------------------- // Returns + transfers (Issue #142) // ----------------------------------------------------------------------------- describe("computeAccountReturn", () => { beforeEach(() => { vi.mocked(loadProfiles).mockReset(); vi.mocked(invoke).mockReset(); }); it("invokes the Tauri command with the active profile's db_filename", async () => { vi.mocked(loadProfiles).mockResolvedValueOnce({ active_profile_id: "p1", profiles: [ { id: "p1", name: "Max", color: "#fff", pin_hash: null, db_filename: "max.db", created_at: "0", }, ], }); const fakeReturn = { value_start: 1000, value_end: 1100, net_contributions: 0, return_pct: 0.1, annualized_pct: 0.42, is_partial: false, has_no_transfers_warning: true, }; vi.mocked(invoke).mockResolvedValueOnce(fakeReturn); const out = await computeAccountReturn(7, "2026-01-01", "2026-04-01"); expect(out).toEqual(fakeReturn); expect(invoke).toHaveBeenCalledWith("compute_account_return", { dbFilename: "max.db", accountId: 7, periodStart: "2026-01-01", periodEnd: "2026-04-01", }); }); it("rejects malformed period dates before invoking the command", async () => { vi.mocked(loadProfiles).mockResolvedValueOnce({ active_profile_id: "p1", profiles: [ { id: "p1", name: "Max", color: "#fff", pin_hash: null, db_filename: "max.db", created_at: "0", }, ], }); await expect( computeAccountReturn(1, "not-a-date", "2026-04-01") ).rejects.toBeInstanceOf(BalanceServiceError); expect(invoke).not.toHaveBeenCalled(); }); it("throws transfer_active_profile_unknown when no active profile resolves", async () => { vi.mocked(loadProfiles).mockResolvedValueOnce({ active_profile_id: "missing", profiles: [], }); await expect( computeAccountReturn(1, "2026-01-01", "2026-04-01") ).rejects.toMatchObject({ code: "transfer_active_profile_unknown" }); expect(invoke).not.toHaveBeenCalled(); }); }); describe("suggestTransferDirection", () => { it("maps negative bank amounts to 'in' (money left bank → arrived in account)", () => { expect(suggestTransferDirection(-100)).toBe("in"); }); it("maps positive bank amounts to 'out' (money came back from account)", () => { expect(suggestTransferDirection(50)).toBe("out"); }); it("treats zero as 'out' as a deterministic fallback", () => { expect(suggestTransferDirection(0)).toBe("out"); }); }); describe("linkTransfer", () => { it("rejects an invalid direction without touching the DB", async () => { await expect( // @ts-expect-error testing runtime guard linkTransfer(1, 2, "sideways") ).rejects.toBeInstanceOf(BalanceServiceError); expect(mockExecute).not.toHaveBeenCalled(); }); it("guards against duplicate links with a typed error", async () => { mockSelect.mockResolvedValueOnce([{ id: 5 }]); await expect(linkTransfer(1, 2, "in")).rejects.toMatchObject({ code: "transfer_already_linked", }); expect(mockExecute).not.toHaveBeenCalled(); }); it("inserts and returns the new transfer id", async () => { mockSelect.mockResolvedValueOnce([]); mockExecute.mockResolvedValueOnce({ lastInsertId: 99, rowsAffected: 1 }); const id = await linkTransfer(1, 2, "out", " manual "); expect(id).toBe(99); const sql = mockExecute.mock.calls[0][0] as string; expect(sql).toContain("INSERT INTO balance_account_transfers"); expect(mockExecute.mock.calls[0][1]).toEqual([1, 2, "out", "manual"]); }); it("normalizes empty notes to null", async () => { mockSelect.mockResolvedValueOnce([]); mockExecute.mockResolvedValueOnce({ lastInsertId: 1, rowsAffected: 1 }); await linkTransfer(1, 2, "in", " "); expect(mockExecute.mock.calls[0][1][3]).toBeNull(); }); }); describe("unlinkTransfer", () => { it("throws transfer_not_linked when no row was deleted", async () => { mockExecute.mockResolvedValueOnce({ lastInsertId: 0, rowsAffected: 0 }); await expect(unlinkTransfer(1, 2)).rejects.toMatchObject({ code: "transfer_not_linked", }); }); it("succeeds when one row is deleted", async () => { mockExecute.mockResolvedValueOnce({ lastInsertId: 0, rowsAffected: 1 }); await expect(unlinkTransfer(1, 2)).resolves.toBeUndefined(); expect(mockExecute.mock.calls[0][1]).toEqual([1, 2]); }); }); describe("listAccountTransfers", () => { it("filters by account_id only when no date range is supplied", async () => { mockSelect.mockResolvedValueOnce([]); await listAccountTransfers(7); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("FROM balance_account_transfers bat"); expect(sql).toContain("JOIN transactions t"); expect(sql).toContain("JOIN balance_accounts a"); expect(sql).toContain("WHERE bat.account_id = $1"); expect(sql).not.toContain("t.date >="); expect(mockSelect.mock.calls[0][1]).toEqual([7]); }); it("appends inclusive date bounds when supplied", async () => { mockSelect.mockResolvedValueOnce([]); await listAccountTransfers(7, { from: "2026-01-01", to: "2026-04-01" }); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("t.date >="); expect(sql).toContain("t.date <="); expect(mockSelect.mock.calls[0][1]).toEqual([7, "2026-01-01", "2026-04-01"]); }); }); describe("listLinkedTransactionIds", () => { it("returns a Set of transaction ids", async () => { mockSelect.mockResolvedValueOnce([ { transaction_id: 5 }, { transaction_id: 12 }, ]); const ids = await listLinkedTransactionIds(); expect(ids).toBeInstanceOf(Set); expect(ids.has(5)).toBe(true); expect(ids.has(12)).toBe(true); expect(ids.size).toBe(2); }); }); describe("listAllLinkedTransfersForTooltip", () => { it("groups multiple links per transaction id", async () => { mockSelect.mockResolvedValueOnce([ { transaction_id: 1, account_id: 10, account_name: "TFSA", direction: "in" }, { transaction_id: 1, account_id: 20, account_name: "RRSP", direction: "out" }, { transaction_id: 2, account_id: 10, account_name: "TFSA", direction: "in" }, ]); const map = await listAllLinkedTransfersForTooltip(); expect(map.get(1)).toHaveLength(2); expect(map.get(2)).toHaveLength(1); expect(map.get(1)?.[0].account_name).toBe("TFSA"); }); }); describe("isLinkedTransactionFkError", () => { it("matches the canonical SQLite FK error text", () => { expect( isLinkedTransactionFkError(new Error("FOREIGN KEY constraint failed")) ).toBe(true); }); it("matches the wrapped tauri-plugin-sql variant", () => { expect( isLinkedTransactionFkError( new Error("code: 787, message: FOREIGN KEY constraint failed") ) ).toBe(true); }); it("does not match unrelated errors", () => { expect(isLinkedTransactionFkError(new Error("something else"))).toBe(false); expect(isLinkedTransactionFkError(undefined)).toBe(false); }); });