import { describe, it, expect, vi, beforeEach, afterEach } 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, getBalanceAccount, createBalanceAccount, updateBalanceAccount, archiveBalanceAccount, unarchiveBalanceAccount, listSnapshots, getSnapshotByDate, createSnapshot, updateSnapshot, deleteSnapshot, listLinesBySnapshot, upsertSnapshotLines, saveSnapshotAtomic, 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"); // is_seed hardcoded to 0; asset_type = $5, custom_label = $6 (#202). expect(sql).toContain("0, $5, $6)"); // simple kind → asset_type coerced to NULL; custom_label NULL when omitted. expect(params).toEqual([ "ferr", "balance.category.ferr", "simple", 35, null, null, ]); }); it("rejects priced category without asset_type (#169)", async () => { await expect( createBalanceCategory({ key: "mining_etf", i18n_key: "x.mining", kind: "priced", }) ).rejects.toMatchObject({ name: "BalanceServiceError", code: "asset_type_required", }); expect(mockExecute).not.toHaveBeenCalled(); }); it("rejects an invalid asset_type value (#169)", async () => { await expect( createBalanceCategory({ key: "mining_etf", i18n_key: "x.mining", kind: "priced", // @ts-expect-error testing runtime guard asset_type: "gold", }) ).rejects.toMatchObject({ name: "BalanceServiceError", code: "asset_type_invalid", }); expect(mockExecute).not.toHaveBeenCalled(); }); it("inserts a priced category with asset_type='stock' (#169)", async () => { mockExecute.mockResolvedValueOnce({ lastInsertId: 99, rowsAffected: 1 }); const id = await createBalanceCategory({ key: "tsx", i18n_key: "x.tsx", kind: "priced", asset_type: "stock", }); expect(id).toBe(99); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[2]).toBe("priced"); expect(params[4]).toBe("stock"); }); it("inserts a priced category with asset_type='crypto' (#169)", async () => { mockExecute.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }); await createBalanceCategory({ key: "alts", i18n_key: "x.alts", kind: "priced", asset_type: "crypto", }); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[4]).toBe("crypto"); }); it("forces asset_type to NULL on simple kind even if provided (#169)", async () => { mockExecute.mockResolvedValueOnce({ lastInsertId: 1, rowsAffected: 1 }); await createBalanceCategory({ key: "savings", i18n_key: "x.savings", kind: "simple", // Service coerces simple kind → asset_type=null regardless of caller. asset_type: "stock", }); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[4]).toBeNull(); }); }); 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", }); }); }); // ----------------------------------------------------------------------------- // custom_label (Bilan axe véhicule, Étape 1 — issue #202) // ----------------------------------------------------------------------------- describe("balance categories — custom_label (#202)", () => { it("listBalanceCategories selects custom_label and filters is_active when asked", async () => { mockSelect.mockResolvedValueOnce([]); await listBalanceCategories({ includeInactive: false }); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("custom_label"); expect(sql).toContain("WHERE is_active = 1"); }); it("listBalanceCategories includes inactive categories by default (#202 behavior-neutral)", async () => { mockSelect.mockResolvedValueOnce([]); await listBalanceCategories(); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("custom_label"); expect(sql).not.toContain("WHERE is_active = 1"); }); it("createBalanceCategory trims custom_label and stores it as the 6th param", async () => { mockExecute.mockResolvedValueOnce({ lastInsertId: 5, rowsAffected: 1 }); await createBalanceCategory({ key: "savings", i18n_key: "balance.category.savings", kind: "simple", custom_label: " Mon épargne ", }); const sql = mockExecute.mock.calls[0][0] as string; expect(sql).toContain("custom_label"); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[5]).toBe("Mon épargne"); }); it("createBalanceCategory normalizes a blank custom_label to null", async () => { mockExecute.mockResolvedValueOnce({ lastInsertId: 6, rowsAffected: 1 }); await createBalanceCategory({ key: "x", i18n_key: "balance.category.x", kind: "simple", custom_label: " ", }); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[5]).toBeNull(); }); it("updateBalanceCategory sets custom_label without touching i18n_key (fixes bug I)", async () => { mockSelect.mockResolvedValueOnce([ { id: 1, key: "cash", i18n_key: "balance.category.cash", kind: "simple", sort_order: 10, is_active: 1, is_seed: 1, asset_type: null, custom_label: null, }, ]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await updateBalanceCategory(1, { custom_label: "Comptes courants" }); const sql = mockExecute.mock.calls[0][0] as string; expect(sql).toContain("custom_label = $5"); const params = mockExecute.mock.calls[0][1] as unknown[]; // i18n_key (param 0) preserved verbatim; custom_label (param 4) is the rename. expect(params[0]).toBe("balance.category.cash"); expect(params[4]).toBe("Comptes courants"); }); it("updateBalanceCategory clears custom_label on explicit null", async () => { mockSelect.mockResolvedValueOnce([ { id: 1, key: "cash", i18n_key: "balance.category.cash", kind: "simple", sort_order: 10, is_active: 1, is_seed: 1, asset_type: null, custom_label: "Old label", }, ]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await updateBalanceCategory(1, { custom_label: null }); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[4]).toBeNull(); }); it("updateBalanceCategory preserves existing custom_label when omitted", async () => { mockSelect.mockResolvedValueOnce([ { id: 1, key: "cash", i18n_key: "balance.category.cash", kind: "simple", sort_order: 10, is_active: 1, is_seed: 1, asset_type: null, custom_label: "Kept label", }, ]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await updateBalanceCategory(1, { sort_order: 99 }); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[4]).toBe("Kept label"); }); }); // ----------------------------------------------------------------------------- // 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"); }); it("threads category_asset_type from the join (#169)", async () => { mockSelect.mockResolvedValueOnce([]); await listBalanceAccounts(); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("c.asset_type AS category_asset_type"); }); }); 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[]; // 6th param is vehicle_type (NULL when not provided) — #202. expect(params).toEqual([1, "Encaisse Wealthsimple", null, "CAD", null, null]); }); it("allows a priced-category account WITHOUT a symbol (Issue #199)", async () => { // Symbol is optional even for priced categories — manual valuation // (quantity × unit price) never needs it; only the price-fetch button does. mockSelect.mockResolvedValueOnce([ { id: 3, key: "stock", i18n_key: "balance.category.stock", kind: "priced", sort_order: 50, is_active: 1, is_seed: 1, asset_type: "stock", }, ]); mockExecute.mockResolvedValueOnce({ lastInsertId: 9, rowsAffected: 1 }); const id = await createBalanceAccount({ balance_category_id: 3, name: "Portefeuille Wealthsimple", // no symbol provided }); expect(id).toBe(9); const params = mockExecute.mock.calls[0][1] as unknown[]; // symbol param (3rd) is null — insert succeeds, no validation thrown. expect(params[2]).toBeNull(); }); }); // ----------------------------------------------------------------------------- // vehicle_type (Bilan axe véhicule, Étape 1 — issue #202) // ----------------------------------------------------------------------------- describe("balance accounts — vehicle_type (#202)", () => { const cashCategoryRow = { id: 1, key: "cash", i18n_key: "balance.category.cash", kind: "simple", sort_order: 10, is_active: 1, is_seed: 1, asset_type: null, custom_label: null, }; it("createBalanceAccount stores a valid vehicle_type as the 6th param", async () => { mockSelect.mockResolvedValueOnce([cashCategoryRow]); mockExecute.mockResolvedValueOnce({ lastInsertId: 8, rowsAffected: 1 }); await createBalanceAccount({ balance_category_id: 1, name: "Mon CELI", vehicle_type: "tfsa", }); const sql = mockExecute.mock.calls[0][0] as string; expect(sql).toContain("vehicle_type"); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[5]).toBe("tfsa"); }); it("createBalanceAccount accepts every enum value", async () => { for (const v of ["unregistered", "tfsa", "rrsp", "rrif", "fhsa", "resp"] as const) { mockSelect.mockReset(); mockExecute.mockReset(); mockSelect.mockResolvedValueOnce([cashCategoryRow]); mockExecute.mockResolvedValueOnce({ lastInsertId: 1, rowsAffected: 1 }); await createBalanceAccount({ balance_category_id: 1, name: `acct-${v}`, vehicle_type: v, }); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[5]).toBe(v); } }); it("createBalanceAccount rejects an out-of-enum vehicle_type", async () => { mockSelect.mockResolvedValueOnce([cashCategoryRow]); await expect( createBalanceAccount({ balance_category_id: 1, name: "Bad", // @ts-expect-error testing runtime guard — not an automobile type vehicle_type: "car", }) ).rejects.toMatchObject({ code: "vehicle_type_invalid" }); expect(mockExecute).not.toHaveBeenCalled(); }); it("createBalanceAccount stores NULL when vehicle_type is omitted", async () => { mockSelect.mockResolvedValueOnce([cashCategoryRow]); mockExecute.mockResolvedValueOnce({ lastInsertId: 8, rowsAffected: 1 }); await createBalanceAccount({ balance_category_id: 1, name: "Encaisse" }); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[5]).toBeNull(); }); it("getBalanceAccount selects vehicle_type", async () => { mockSelect.mockResolvedValueOnce([ { id: 7, balance_category_id: 1, name: "Encaisse", symbol: null, currency: "CAD", notes: null, is_active: 1, archived_at: null, vehicle_type: "tfsa", created_at: "", updated_at: "", }, ]); const acct = await getBalanceAccount(7); expect(acct?.vehicle_type).toBe("tfsa"); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("vehicle_type"); }); it("listBalanceAccounts threads vehicle_type and category_custom_label from the join", async () => { mockSelect.mockResolvedValueOnce([]); await listBalanceAccounts(); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("a.vehicle_type"); expect(sql).toContain("c.custom_label AS category_custom_label"); }); it("updateBalanceAccount preserves the existing vehicle_type when omitted", async () => { mockSelect.mockResolvedValueOnce([ { id: 7, balance_category_id: 1, name: "Mon CELI", symbol: null, currency: "CAD", notes: null, is_active: 1, archived_at: null, vehicle_type: "tfsa", created_at: "", updated_at: "", }, ]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await updateBalanceAccount(7, { name: "CELI renommé" }); const sql = mockExecute.mock.calls[0][0] as string; expect(sql).toContain("vehicle_type = $6"); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[5]).toBe("tfsa"); // preserved }); it("updateBalanceAccount sets a new vehicle_type when provided", async () => { mockSelect.mockResolvedValueOnce([ { id: 7, balance_category_id: 1, name: "Compte", symbol: null, currency: "CAD", notes: null, is_active: 1, archived_at: null, vehicle_type: null, created_at: "", updated_at: "", }, ]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await updateBalanceAccount(7, { vehicle_type: "rrsp" }); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[5]).toBe("rrsp"); }); it("updateBalanceAccount clears vehicle_type on explicit null", async () => { mockSelect.mockResolvedValueOnce([ { id: 7, balance_category_id: 1, name: "Compte", symbol: null, currency: "CAD", notes: null, is_active: 1, archived_at: null, vehicle_type: "tfsa", created_at: "", updated_at: "", }, ]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await updateBalanceAccount(7, { vehicle_type: null }); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[5]).toBeNull(); }); it("updateBalanceAccount rejects an out-of-enum vehicle_type", async () => { mockSelect.mockResolvedValueOnce([ { id: 7, balance_category_id: 1, name: "Compte", symbol: null, currency: "CAD", notes: null, is_active: 1, archived_at: null, vehicle_type: null, created_at: "", updated_at: "", }, ]); await expect( // @ts-expect-error testing runtime guard updateBalanceAccount(7, { vehicle_type: "truck" }) ).rejects.toMatchObject({ code: "vehicle_type_invalid" }); expect(mockExecute).not.toHaveBeenCalled(); }); it("getAccountsLatestSnapshot threads category_custom_label", async () => { mockSelect.mockResolvedValueOnce([]); await getAccountsLatestSnapshot(); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("c.custom_label AS category_custom_label"); }); }); 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]); }); }); // ----------------------------------------------------------------------------- // saveSnapshotAtomic (#176) — atomic BEGIN/COMMIT/ROLLBACK orchestration // ----------------------------------------------------------------------------- describe("saveSnapshotAtomic — new mode", () => { it("issues BEGIN before any write and COMMIT once everything succeeds", async () => { // Order: SELECT dup-check → INSERT snapshot → DELETE lines → INSERT line → UPDATE → COMMIT mockSelect.mockResolvedValueOnce([]); // no duplicate mockExecute .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN .mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // INSERT line .mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT const res = await saveSnapshotAtomic({ existingSnapshotId: null, snapshot_date: "2026-04-30", lines: [{ account_id: 1, value: 1000 }], }); expect(res.snapshotId).toBe(42); // First execute is BEGIN expect(mockExecute.mock.calls[0][0]).toBe("BEGIN"); // INSERT snapshot is second expect(mockExecute.mock.calls[1][0]).toContain( "INSERT INTO balance_snapshots" ); // DELETE lines, INSERT line, UPDATE updated_at all happen between BEGIN and COMMIT expect(mockExecute.mock.calls[2][0]).toContain( "DELETE FROM balance_snapshot_lines" ); expect(mockExecute.mock.calls[3][0]).toContain( "INSERT INTO balance_snapshot_lines" ); expect(mockExecute.mock.calls[4][0]).toContain("UPDATE balance_snapshots"); // Last execute is COMMIT expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe( "COMMIT" ); // No ROLLBACK on success expect( mockExecute.mock.calls.some((c: unknown[]) => c[0] === "ROLLBACK") ).toBe(false); }); it("rejects when a snapshot already exists at this date (snapshot_date_taken) and ROLLBACKs", async () => { mockSelect.mockResolvedValueOnce([{ id: 7 }]); // duplicate found mockExecute .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN .mockResolvedValueOnce({ rowsAffected: 0 }); // ROLLBACK await expect( saveSnapshotAtomic({ existingSnapshotId: null, snapshot_date: "2026-04-30", lines: [{ account_id: 1, value: 1000 }], }) ).rejects.toMatchObject({ code: "snapshot_date_taken" }); // BEGIN ran, then ROLLBACK because the duplicate threw mid-transaction. expect(mockExecute.mock.calls[0][0]).toBe("BEGIN"); expect(mockExecute.mock.calls[1][0]).toBe("ROLLBACK"); // No INSERT INTO balance_snapshots happened. expect( mockExecute.mock.calls.some((c: unknown[]) => String(c[0]).includes("INSERT INTO balance_snapshots") ) ).toBe(false); }); it("ROLLBACKs and re-throws when a line INSERT fails (no orphan snapshot persists)", async () => { mockSelect.mockResolvedValueOnce([]); // no duplicate mockExecute .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN .mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines .mockRejectedValueOnce(new Error("simulated FK violation")) // INSERT line fails .mockResolvedValueOnce({ rowsAffected: 0 }); // ROLLBACK await expect( saveSnapshotAtomic({ existingSnapshotId: null, snapshot_date: "2026-04-30", lines: [{ account_id: 999, value: 1000 }], }) ).rejects.toThrow("simulated FK violation"); // BEGIN happened, ROLLBACK was the last call — no COMMIT. expect(mockExecute.mock.calls[0][0]).toBe("BEGIN"); expect( mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0] ).toBe("ROLLBACK"); expect( mockExecute.mock.calls.some((c: unknown[]) => c[0] === "COMMIT") ).toBe(false); }); it("rejects validation failures BEFORE BEGIN — no transaction is opened", async () => { await expect( saveSnapshotAtomic({ existingSnapshotId: null, snapshot_date: "2026-04-30", // Priced line missing quantity should fail validation before any DB write. lines: [ { account_id: 1, value: 100, account_kind: "priced", unit_price: 10 }, ], }) ).rejects.toMatchObject({ code: "snapshot_priced_quantity_required" }); // Pre-DB validation: no BEGIN, no SELECT, no execute at all. expect(mockExecute).not.toHaveBeenCalled(); expect(mockSelect).not.toHaveBeenCalled(); }); }); describe("saveSnapshotAtomic — edit mode", () => { it("skips INSERT INTO balance_snapshots when existingSnapshotId is provided", async () => { mockExecute .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // INSERT line .mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT const res = await saveSnapshotAtomic({ existingSnapshotId: 5, snapshot_date: "2026-04-30", lines: [{ account_id: 1, value: 1000 }], }); expect(res.snapshotId).toBe(5); // No SELECT (no duplicate check in edit mode), no INSERT INTO balance_snapshots. expect(mockSelect).not.toHaveBeenCalled(); expect( mockExecute.mock.calls.some((c: unknown[]) => String(c[0]).includes("INSERT INTO balance_snapshots") ) ).toBe(false); // BEGIN / DELETE / INSERT line / UPDATE / COMMIT expect(mockExecute.mock.calls[0][0]).toBe("BEGIN"); expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe( "COMMIT" ); }); it("moves the snapshot date in-txn and preserves the existing lines (Issue #200)", async () => { // In edit mode with moveToDate set: BEGIN → collision SELECT (free) → // UPDATE date → DELETE lines → INSERT line → UPDATE updated_at → COMMIT. mockSelect.mockResolvedValueOnce([]); // collision check → target date free mockExecute .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN .mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE snapshot_date .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // INSERT line (preserved) .mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT const res = await saveSnapshotAtomic({ existingSnapshotId: 5, snapshot_date: "2026-04-15", moveToDate: "2026-05-20", lines: [{ account_id: 1, value: 1234 }], }); expect(res.snapshotId).toBe(5); // Collision SELECT excludes the moved snapshot's own id. const clashParams = mockSelect.mock.calls[0][1] as unknown[]; expect(clashParams).toEqual(["2026-05-20", 5]); // First execute is BEGIN, then the date UPDATE happens before the lines. expect(mockExecute.mock.calls[0][0]).toBe("BEGIN"); expect(mockExecute.mock.calls[1][0]).toContain("SET snapshot_date = $1"); expect(mockExecute.mock.calls[1][1]).toEqual(["2026-05-20", 5]); // Lines are still rewritten (preserved): DELETE then INSERT. expect(mockExecute.mock.calls[2][0]).toContain( "DELETE FROM balance_snapshot_lines" ); expect(mockExecute.mock.calls[3][0]).toContain( "INSERT INTO balance_snapshot_lines" ); // Commits, never rolls back. expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe( "COMMIT" ); expect( mockExecute.mock.calls.some((c: unknown[]) => c[0] === "ROLLBACK") ).toBe(false); }); it("rolls back and throws snapshot_date_exists when moveToDate collides with another snapshot (Issue #200)", async () => { mockSelect.mockResolvedValueOnce([{ id: 42 }]); // collision: another snapshot at the target mockExecute .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN .mockResolvedValueOnce({ rowsAffected: 0 }); // ROLLBACK await expect( saveSnapshotAtomic({ existingSnapshotId: 5, snapshot_date: "2026-04-15", moveToDate: "2026-05-20", lines: [{ account_id: 1, value: 1234 }], }) ).rejects.toMatchObject({ code: "snapshot_date_exists" }); // BEGIN ran, then ROLLBACK — no date UPDATE, no line writes committed. expect(mockExecute.mock.calls[0][0]).toBe("BEGIN"); expect(mockExecute.mock.calls[1][0]).toBe("ROLLBACK"); expect( mockExecute.mock.calls.some((c: unknown[]) => String(c[0]).includes("SET snapshot_date") ) ).toBe(false); expect( mockExecute.mock.calls.some((c: unknown[]) => c[0] === "COMMIT") ).toBe(false); }); }); // ----------------------------------------------------------------------------- // 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; // Window function: ROW_NUMBER partitioned by account_id, earliest first. expect(sql).toContain("ROW_NUMBER()"); expect(sql).toContain("PARTITION BY l.account_id"); expect(sql).toContain("ORDER BY s.snapshot_date ASC"); expect(sql).toContain("WHERE rn = 1"); // Old aggregate-in-WHERE pattern must be gone (regression guard, #175). expect(sql).not.toContain("MIN(s.snapshot_date)"); expect(sql).not.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/); }); it("returns earliest snapshot per account within range", async () => { // Multiple accounts, each with multiple snapshots in the window. // The DB returns one row per account (the rn = 1 row), so the mocked // result mirrors that contract. mockSelect.mockResolvedValueOnce([ { account_id: 1, anchor_snapshot_date: "2026-02-29", anchor_value: 1500 }, { account_id: 2, anchor_snapshot_date: "2026-03-31", anchor_value: 2700 }, ]); const rows = await getAccountsPeriodAnchor({ from: "2026-02-01", to: "2026-06-30", }); expect(rows).toEqual([ { account_id: 1, anchor_snapshot_date: "2026-02-29", anchor_value: 1500 }, { account_id: 2, anchor_snapshot_date: "2026-03-31", anchor_value: 2700 }, ]); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("ROW_NUMBER()"); expect(sql).toContain("PARTITION BY l.account_id"); expect(sql).toContain("ORDER BY s.snapshot_date ASC"); expect(sql).toContain("WHERE rn = 1"); expect(mockSelect.mock.calls[0][1]).toEqual(["2026-02-01", "2026-06-30"]); }); it("returns [] for an empty window (no snapshots in range)", async () => { mockSelect.mockResolvedValueOnce([]); const rows = await getAccountsPeriodAnchor({ from: "2099-01-01", to: "2099-12-31", }); expect(rows).toEqual([]); }); // Regression: /balance load (issue #175) used to throw "misuse of aggregate // function MIN()" because MIN was used inside the WHERE of a scalar // subquery. With ROW_NUMBER() the query is plain SQLite — assert the // service forwards rows from db.select without throwing. it("regression #175: loads without SQLite aggregate misuse error", async () => { mockSelect.mockResolvedValueOnce([ { account_id: 1, anchor_snapshot_date: "2026-01-15", anchor_value: 500 }, ]); await expect( getAccountsPeriodAnchor({ from: "2026-01-01", to: "2026-12-31" }) ).resolves.toEqual([ { account_id: 1, anchor_snapshot_date: "2026-01-15", anchor_value: 500 }, ]); const sql = mockSelect.mock.calls[0][0] as string; // The exact pattern that triggered the SQLite error must not reappear. expect(sql).not.toMatch(/=\s*MIN\(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); }); }); // ----------------------------------------------------------------------------- // prices namespace (Issue #156 / Bilan #5) // ----------------------------------------------------------------------------- import { prices } from "./balance.service"; const FAKE_PRICE_RESPONSE = { symbol: "AAPL", date: "2026-04-25", price: 173.45, currency: "USD", source: "yahoo", cached: false, actual_date: null, fetched_at: "2026-04-25T14:32:11Z", }; describe("balance.service.prices", () => { beforeEach(() => { vi.useFakeTimers(); vi.mocked(invoke).mockReset(); prices.__resetForTests(); }); afterEach(() => { vi.useRealTimers(); }); // 1. Happy path 200 it("fetchPrice happy path returns ok:true with price fields", async () => { vi.mocked(invoke).mockResolvedValueOnce(FAKE_PRICE_RESPONSE); const result = await prices.fetchPrice("AAPL", "2026-04-25"); expect(result.ok).toBe(true); if (result.ok) { expect(result.symbol).toBe("AAPL"); expect(result.date).toBe("2026-04-25"); expect(result.price).toBe(173.45); expect(result.currency).toBe("USD"); expect(result.source).toBe("yahoo"); expect(result.cached).toBe(false); } expect(invoke).toHaveBeenCalledTimes(1); expect(invoke).toHaveBeenCalledWith("fetch_price", { symbol: "AAPL", date: "2026-04-25", }); }); // 2. 401 (auth) — no retry it("fetchPrice auth error returns ok:false code:auth with 1 invoke call", async () => { vi.mocked(invoke).mockRejectedValueOnce('{"code":"auth"}'); const promise = prices.fetchPrice("AAPL", "2026-04-25"); await vi.runAllTimersAsync(); const result = await promise; expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe("auth"); expect(result.error.i18nKey).toBe( "balance.priceFetching.errors.authFailed" ); } expect(invoke).toHaveBeenCalledTimes(1); }); // 3. 403 premium_required — no retry it("fetchPrice premium_required returns immediately without retry", async () => { vi.mocked(invoke).mockRejectedValueOnce('{"code":"premium_required"}'); const promise = prices.fetchPrice("AAPL", "2026-04-25"); await vi.runAllTimersAsync(); const result = await promise; expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe("premium_required"); expect(result.error.i18nKey).toBe( "balance.priceFetching.errors.premiumRequired" ); } expect(invoke).toHaveBeenCalledTimes(1); }); // 4. 404 symbol_not_found — no retry it("fetchPrice symbol_not_found returns immediately without retry", async () => { vi.mocked(invoke).mockRejectedValueOnce('{"code":"symbol_not_found"}'); const promise = prices.fetchPrice("AAPL", "2026-04-25"); await vi.runAllTimersAsync(); const result = await promise; expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe("symbol_not_found"); expect(result.error.i18nKey).toBe( "balance.priceFetching.errors.symbolNotFound" ); } expect(invoke).toHaveBeenCalledTimes(1); }); // 5. 429 rate_limit — no retry, carries retry_after_s it("fetchPrice rate_limit 429 returns ok:false with retry_after_s, no retry", async () => { vi.mocked(invoke).mockRejectedValueOnce( '{"code":"rate_limit","retry_after_s":30}' ); const promise = prices.fetchPrice("AAPL", "2026-04-25"); await vi.runAllTimersAsync(); const result = await promise; expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe("rate_limit"); if (result.error.code === "rate_limit") { expect(result.error.retry_after_s).toBe(30); expect(result.error.i18nKey).toBe( "balance.priceFetching.errors.rateLimit" ); } } expect(invoke).toHaveBeenCalledTimes(1); }); // 6. 5xx provider_unavailable — 3 retries with 2/4/8s backoff (4 total calls) it("fetchPrice provider_unavailable retries 3 times with 2/4/8s backoff", async () => { vi.mocked(invoke).mockRejectedValue('{"code":"provider_unavailable"}'); const promise = prices.fetchPrice("AAPL", "2026-04-25"); // Advance through all retry delays: 2s + 4s + 8s = 14s total await vi.advanceTimersByTimeAsync(2000); // retry 1 fires after 2s await vi.advanceTimersByTimeAsync(4000); // retry 2 fires after 4s await vi.advanceTimersByTimeAsync(8000); // retry 3 fires after 8s await vi.runAllTimersAsync(); const result = await promise; expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe("provider_unavailable"); expect(result.error.i18nKey).toBe( "balance.priceFetching.errors.serverUnavailable" ); } // 1 initial + 3 retries = 4 total invoke calls expect(invoke).toHaveBeenCalledTimes(4); }); // 7. In-flight deduplication it("fetchPrice dedup: two parallel calls with same key → only one invoke", async () => { vi.mocked(invoke).mockResolvedValueOnce(FAKE_PRICE_RESPONSE); const p1 = prices.fetchPrice("AAPL", "2026-04-25"); const p2 = prices.fetchPrice("AAPL", "2026-04-25"); await vi.runAllTimersAsync(); const [r1, r2] = await Promise.all([p1, p2]); expect(invoke).toHaveBeenCalledTimes(1); expect(r1.ok).toBe(true); expect(r2.ok).toBe(true); if (r1.ok && r2.ok) { expect(r1.price).toBe(r2.price); } }); // 8. Rate-limit pacing: calls are serialized through _enforceRateLimit, // so 3 concurrent calls result in 3 sequential invoke calls, each separated // by at least MIN_INTERVAL_MS (2s). We verify that the setTimeout inside // _enforceRateLimit is actually called with the correct delay. it("fetchPrice rate-limit pacing: each call waits at least 2s after the previous", async () => { const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); vi.mocked(invoke).mockResolvedValue(FAKE_PRICE_RESPONSE); // Start 3 calls for different symbols (no dedup). Only the first fires // immediately; the others queue up behind the rate-limit. const p1 = prices.fetchPrice("AAPL", "2026-01-01"); const p2 = prices.fetchPrice("MSFT", "2026-01-01"); const p3 = prices.fetchPrice("TSLA", "2026-01-01"); // Advance enough time for all 3 to complete (3 × 2s = 6s). await vi.advanceTimersByTimeAsync(6000); await vi.runAllTimersAsync(); await Promise.all([p1, p2, p3]); // All 3 invoke calls must have been made. expect(invoke).toHaveBeenCalledTimes(3); // At least 2 setTimeout calls for the rate-limit waits (p2 and p3 must wait). // The actual delay argument should be ~2000ms (or close to it, as the // timer fires slightly early in fake-timer environments). const rateLimitTimers = setTimeoutSpy.mock.calls.filter( ([, delay]) => typeof delay === "number" && delay > 0 && delay <= 2000 ); expect(rateLimitTimers.length).toBeGreaterThanOrEqual(2); setTimeoutSpy.mockRestore(); }); // 9. Session cap: 101st call returns session_cap_reached without calling invoke it("fetchPrice session cap: 101st call returns session_cap_reached", async () => { // Set up invoke to always resolve successfully vi.mocked(invoke).mockResolvedValue(FAKE_PRICE_RESPONSE); // Fire 100 successful calls to fill the session cap. // We bypass the rate-limit by advancing time enough between each. for (let i = 0; i < 100; i++) { const p = prices.fetchPrice(`SYM${i}`, "2026-01-01"); await vi.advanceTimersByTimeAsync(2000); await p; } // The 101st call should immediately return session_cap_reached. vi.mocked(invoke).mockClear(); // reset call counter const result = await prices.fetchPrice("EXTRA", "2026-01-01"); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe("session_cap_reached"); expect(result.error.i18nKey).toBe( "balance.priceFetching.errors.sessionCapReached" ); } // invoke must NOT have been called for the 101st request expect(invoke).not.toHaveBeenCalled(); }); });