import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("./db", () => { const getDb = vi.fn(); return { getDb, // Tests don't exercise the real lock; withTransaction just runs the // callback with whatever db the test wired getDb to resolve, so every // existing BEGIN/COMMIT/ROLLBACK ordering assertion still holds (the // service issues them on the mock db inside the callback). withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())), }; }); 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, validateDetailedSnapshot, isDetailedLine, normalizeSecuritySymbol, roundToCent, findOrCreateSecurity, listSecurities, getSecurity, updateSecurity, listHoldingsBySnapshotLine, getHoldingsForLatestSnapshot, computeUnrealizedGain, getAccountLatentGainByLine, rollupLatentGain, type AccountUnrealizedGain, PRICED_VALUE_TOLERANCE, BalanceServiceError, getSnapshotTotalsByDate, getSnapshotTotalsByCategoryAndDate, getSnapshotTotalsByVehicleAndDate, 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", kind: "simple", detailed_since: null, 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, kind: "simple", detailed_since: 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", kind: "simple", detailed_since: null, 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"); }); it("getAccountsLatestSnapshot threads vehicle_type (#204)", async () => { mockSelect.mockResolvedValueOnce([]); await getAccountsLatestSnapshot(); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("a.vehicle_type AS vehicle_type"); }); }); 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, kind: "simple", detailed_since: 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]); // #212: the rewrite is now wrapped in BEGIN/COMMIT for holdings atomicity. mockExecute .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN .mockResolvedValueOnce({ rowsAffected: 1 }) // delete .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert 1 .mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert 2 .mockResolvedValueOnce({ rowsAffected: 1 }) // update updated_at .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT await upsertSnapshotLines(5, [ { account_id: 1, value: 1234.56 }, { account_id: 2, value: 0 }, ]); // 1st call = BEGIN, 2nd = DELETE expect(mockExecute.mock.calls[0][0]).toBe("BEGIN"); expect(mockExecute.mock.calls[1][0]).toContain( "DELETE FROM balance_snapshot_lines" ); // Inserts use literal NULL for quantity/unit_price (simple kind invariant) const insertSql = mockExecute.mock.calls[2][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[2][1]).toEqual([5, 1, 1234.56]); // Second insert params (zero is allowed) expect(mockExecute.mock.calls[3][1]).toEqual([5, 2, 0]); // UPDATE updated_at on parent snapshot, then COMMIT last. expect(mockExecute.mock.calls[4][0]).toContain("UPDATE balance_snapshots"); expect(mockExecute.mock.calls[4][0]).toContain("updated_at"); expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe( "COMMIT" ); }); it("clears all lines when called with an empty array", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); mockExecute .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN .mockResolvedValueOnce({ rowsAffected: 3 }) // delete only .mockResolvedValueOnce({ rowsAffected: 1 }) // bump updated_at .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT await upsertSnapshotLines(5, []); // BEGIN + DELETE + UPDATE updated_at + COMMIT — no INSERTs expect(mockExecute).toHaveBeenCalledTimes(4); expect(mockExecute.mock.calls[0][0]).toBe("BEGIN"); expect(mockExecute.mock.calls[3][0]).toBe("COMMIT"); }); }); 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: 0 }) // BEGIN .mockResolvedValueOnce({ rowsAffected: 1 }) // delete .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert .mockResolvedValueOnce({ rowsAffected: 1 }) // bump updated_at .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT await upsertSnapshotLines(5, [ { account_id: 7, account_kind: "priced", quantity: 10, unit_price: 25.5, value: 255, }, ]); const insertSql = mockExecute.mock.calls[2][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[2][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: 0 }) // BEGIN .mockResolvedValueOnce({ rowsAffected: 1 }) // delete .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert simple .mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert priced .mockResolvedValueOnce({ rowsAffected: 1 }) // bump updated_at .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT 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[2][0] as string).toMatch( /VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/ ); expect(mockExecute.mock.calls[2][1]).toEqual([5, 1, 1000]); // Priced insert uses placeholders expect(mockExecute.mock.calls[3][0] as string).toMatch( /VALUES\s*\(\s*\$1,\s*\$2,\s*\$3,\s*\$4,\s*\$5/ ); expect(mockExecute.mock.calls[3][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("getSnapshotTotalsByVehicleAndDate (#204)", () => { it("returns [] on empty DB", async () => { mockSelect.mockResolvedValueOnce([]); expect(await getSnapshotTotalsByVehicleAndDate()).toEqual([]); }); it("COALESCEs NULL envelopes into a single 'none' bucket", async () => { mockSelect.mockResolvedValueOnce([]); await getSnapshotTotalsByVehicleAndDate(); const sql = mockSelect.mock.calls[0][0] as string; // HIGH caveat: the GROUP BY must coalesce NULL → 'none' so null-envelope // accounts never produce a SQL NULL key. expect(sql).toContain("COALESCE(a.vehicle_type, 'none')"); expect(sql).toContain("GROUP BY s.snapshot_date, COALESCE(a.vehicle_type, 'none')"); expect(sql).toContain("INNER JOIN balance_accounts"); }); it("buckets a mix of envelopes plus the 'none' bucket per snapshot_date", async () => { mockSelect.mockResolvedValueOnce([ { snapshot_date: "2026-01-31", vehicle_key: "none", total: 500 }, { snapshot_date: "2026-01-31", vehicle_key: "tfsa", total: 1500 }, { snapshot_date: "2026-01-31", vehicle_key: "rrsp", total: 3000 }, { snapshot_date: "2026-02-28", vehicle_key: "none", total: 700 }, { snapshot_date: "2026-02-28", vehicle_key: "tfsa", total: 1700 }, ]); const out = await getSnapshotTotalsByVehicleAndDate(); expect(out).toEqual([ { snapshot_date: "2026-01-31", byVehicle: { none: 500, tfsa: 1500, rrsp: 3000 }, }, { snapshot_date: "2026-02-28", byVehicle: { none: 700, tfsa: 1700 }, }, ]); }); it("handles a snapshot composed solely of null-envelope accounts", async () => { mockSelect.mockResolvedValueOnce([ { snapshot_date: "2026-03-31", vehicle_key: "none", total: 4200 }, ]); const out = await getSnapshotTotalsByVehicleAndDate(); expect(out).toEqual([ { snapshot_date: "2026-03-31", byVehicle: { none: 4200 } }, ]); }); it("applies date range params when supplied", async () => { mockSelect.mockResolvedValueOnce([]); await getSnapshotTotalsByVehicleAndDate({ 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"); // #216: account kind + latest line id surfaced for the drill-down. expect(sql).toContain("a.kind AS kind"); expect(sql).toContain("AS latest_snapshot_line_id"); }); }); 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(); }); }); // ============================================================================= // Détail par titre (Issue #212) — securities CRUD, detailed save, holdings, // unrealized gain, detailed→simple guard. // ============================================================================= describe("normalizeSecuritySymbol", () => { it("uppercases and trims", () => { expect(normalizeSecuritySymbol(" aapl ")).toBe("AAPL"); expect(normalizeSecuritySymbol("btc")).toBe("BTC"); expect(normalizeSecuritySymbol("VeQt.TO")).toBe("VEQT.TO"); }); }); describe("roundToCent", () => { it("rounds to two decimals (half-up at the cent)", () => { expect(roundToCent(13.2038)).toBe(13.2); expect(roundToCent(0.005)).toBe(0.01); expect(roundToCent(100)).toBe(100); expect(roundToCent(2.675)).toBeCloseTo(2.68, 5); }); }); describe("findOrCreateSecurity", () => { it("rejects an empty/whitespace symbol", async () => { await expect( findOrCreateSecurity({ symbol: " ", asset_type: "stock" }) ).rejects.toMatchObject({ code: "security_symbol_required" }); expect(mockExecute).not.toHaveBeenCalled(); }); it("rejects an invalid asset_type", async () => { await expect( // @ts-expect-error runtime guard findOrCreateSecurity({ symbol: "AAPL", asset_type: "bond" }) ).rejects.toMatchObject({ code: "security_asset_type_invalid" }); expect(mockExecute).not.toHaveBeenCalled(); }); it("UPSERTs on the NORMALIZED symbol and returns the row (casing-dedup)", async () => { mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); // upsert mockSelect.mockResolvedValueOnce([ { id: 3, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "", }, ]); const sec = await findOrCreateSecurity({ symbol: " aapl ", asset_type: "stock", }); expect(sec.id).toBe(3); // The INSERT and the lookup both use the normalized symbol. expect(mockExecute.mock.calls[0][1]?.[0]).toBe("AAPL"); expect(mockExecute.mock.calls[0][0]).toContain("ON CONFLICT(symbol) DO UPDATE"); expect(mockSelect.mock.calls[0][1]).toEqual(["AAPL"]); }); it("a second call with different casing resolves the SAME row (dedup)", async () => { // First create. mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); mockSelect.mockResolvedValueOnce([ { id: 9, symbol: "BTC", name: null, currency: "CAD", asset_type: "crypto", created_at: "", updated_at: "" }, ]); const a = await findOrCreateSecurity({ symbol: "btc", asset_type: "crypto" }); // Second call, different casing — the normalized conflict target returns id 9. mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); mockSelect.mockResolvedValueOnce([ { id: 9, symbol: "BTC", name: null, currency: "CAD", asset_type: "crypto", created_at: "", updated_at: "" }, ]); const b = await findOrCreateSecurity({ symbol: " BtC ", asset_type: "crypto" }); expect(a.id).toBe(b.id); expect(b.id).toBe(9); }); it("threads an executor when given (in-txn create)", async () => { const execSelect = vi.fn().mockResolvedValueOnce([ { id: 1, symbol: "VEQT.TO", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, ]); const execExecute = vi.fn().mockResolvedValueOnce({ rowsAffected: 1 }); const exec = { select: execSelect, execute: execExecute }; await findOrCreateSecurity({ symbol: "veqt.to", asset_type: "stock" }, exec); // The provided executor was used, NOT the top-level db handle. expect(execExecute).toHaveBeenCalledTimes(1); expect(mockExecute).not.toHaveBeenCalled(); expect(mockSelect).not.toHaveBeenCalled(); }); }); describe("listSecurities / getSecurity / updateSecurity", () => { it("listSecurities orders by symbol", async () => { mockSelect.mockResolvedValueOnce([]); await listSecurities(); expect(mockSelect.mock.calls[0][0]).toContain("FROM balance_securities"); expect(mockSelect.mock.calls[0][0]).toContain("ORDER BY symbol ASC"); }); it("getSecurity returns null when absent", async () => { mockSelect.mockResolvedValueOnce([]); expect(await getSecurity(404)).toBeNull(); }); it("updateSecurity rejects on missing id", async () => { mockSelect.mockResolvedValueOnce([]); // getSecurity → none await expect(updateSecurity(7, { name: "x" })).rejects.toMatchObject({ code: "security_not_found", }); expect(mockExecute).not.toHaveBeenCalled(); }); it("updateSecurity re-normalizes symbol and validates asset_type", async () => { mockSelect.mockResolvedValueOnce([ { id: 7, symbol: "AAPL", name: "Apple", currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, ]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await updateSecurity(7, { symbol: " msft ", asset_type: "stock" }); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[0]).toBe("MSFT"); // normalized }); it("updateSecurity rejects an invalid asset_type", async () => { mockSelect.mockResolvedValueOnce([ { id: 7, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, ]); await expect( // @ts-expect-error runtime guard updateSecurity(7, { asset_type: "etf" }) ).rejects.toMatchObject({ code: "security_asset_type_invalid" }); expect(mockExecute).not.toHaveBeenCalled(); }); }); describe("isDetailedLine", () => { it("is true iff a holdings array is present (even empty)", () => { expect(isDetailedLine({ account_id: 1, value: 0, holdings: [] })).toBe(true); expect( isDetailedLine({ account_id: 1, value: 10, holdings: [ { symbol: "AAPL", asset_type: "stock", quantity: 1, unit_price: 10, value: 10 }, ], }) ).toBe(true); expect(isDetailedLine({ account_id: 1, value: 10 })).toBe(false); }); }); describe("validateDetailedSnapshot", () => { const h = (value: number, over: Partial<{ symbol: string; quantity: number; unit_price: number }> = {}) => ({ symbol: over.symbol ?? "AAPL", asset_type: "stock" as const, quantity: over.quantity ?? 1, unit_price: over.unit_price ?? value, value, }); it("accepts a detailed line whose value = rounded-cent SUM of holdings", () => { expect(() => validateDetailedSnapshot({ account_id: 7, value: 300, holdings: [h(100, { symbol: "AAPL" }), h(200, { symbol: "MSFT" })], }) ).not.toThrow(); }); it("tolerates a detailed account with NO holdings (pre-pivot aggregated)", () => { expect(() => validateDetailedSnapshot({ account_id: 7, value: 5000, holdings: [] }) ).not.toThrow(); }); it("rejects a detailed line that carries a scalar quantity/unit_price", () => { expect(() => validateDetailedSnapshot({ account_id: 7, value: 100, quantity: 1, holdings: [h(100)], }) ).toThrow(BalanceServiceError); let scalarErr: unknown; try { validateDetailedSnapshot({ account_id: 7, value: 100, unit_price: 100, holdings: [h(100)], }); } catch (e) { scalarErr = e; } expect((scalarErr as BalanceServiceError).code).toBe( "snapshot_detailed_must_be_aggregate" ); }); it("rejects when line value ≠ rounded-cent SUM", () => { let caught: unknown; try { validateDetailedSnapshot({ account_id: 7, value: 305, // should be 300 holdings: [h(100), h(200, { symbol: "MSFT" })], }); } catch (e) { caught = e; } expect((caught as BalanceServiceError).code).toBe( "snapshot_detailed_value_mismatch" ); }); it("rejects a holding with an empty symbol or bad asset_type or non-finite numbers", () => { expect(() => validateDetailedSnapshot({ account_id: 7, value: 10, holdings: [{ symbol: " ", asset_type: "stock", quantity: 1, unit_price: 10, value: 10 }], }) ).toThrowError(/snapshot_holding_invalid|required/); expect(() => validateDetailedSnapshot({ account_id: 7, value: 10, // @ts-expect-error runtime guard holdings: [{ symbol: "AAPL", asset_type: "bond", quantity: 1, unit_price: 10, value: 10 }], }) ).toThrow(BalanceServiceError); expect(() => validateDetailedSnapshot({ account_id: 7, value: 10, holdings: [{ symbol: "AAPL", asset_type: "stock", quantity: NaN, unit_price: 10, value: 10 }], }) ).toThrow(BalanceServiceError); }); it("accepts N≥20 holdings — per-holding rounding never accumulates past a cent", () => { // 21 positions each at 0.005 → roundToCent ⇒ 0.01 each ⇒ exact SUM 0.21. // A naive Σ(raw)=0.105 with an absolute ε=0.01 would mis-handle this; // rounding each first makes the comparison exact on whole cents. const holdings = Array.from({ length: 21 }, (_, i) => ({ symbol: `S${i}`, asset_type: "stock" as const, quantity: 1, unit_price: 0.005, value: 0.005, })); expect(() => validateDetailedSnapshot({ account_id: 7, value: 0.21, holdings }) ).not.toThrow(); // Off-by-a-cent must still be rejected exactly. let offByCent: unknown; try { validateDetailedSnapshot({ account_id: 7, value: 0.22, holdings }); } catch (e) { offByCent = e; } expect((offByCent as BalanceServiceError).code).toBe( "snapshot_detailed_value_mismatch" ); }); }); describe("saveSnapshotAtomic — detailed account (holdings)", () => { it("writes the aggregated line + securities + holdings inside ONE BEGIN/COMMIT", async () => { mockSelect .mockResolvedValueOnce([]) // dup-check: free date // findOrCreateSecurity AAPL lookup, then MSFT lookup .mockResolvedValueOnce([ { id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, ]) .mockResolvedValueOnce([ { id: 12, symbol: "MSFT", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, ]); mockExecute .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN .mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines .mockResolvedValueOnce({ lastInsertId: 500, rowsAffected: 1 }) // INSERT aggregated line .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE holdings for line 500 .mockResolvedValueOnce({ rowsAffected: 1 }) // UPSERT security AAPL .mockResolvedValueOnce({ rowsAffected: 1 }) // INSERT holding AAPL .mockResolvedValueOnce({ rowsAffected: 1 }) // UPSERT security MSFT .mockResolvedValueOnce({ rowsAffected: 1 }) // INSERT holding MSFT .mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT const res = await saveSnapshotAtomic({ existingSnapshotId: null, snapshot_date: "2026-05-30", lines: [ { account_id: 7, value: 300, holdings: [ { symbol: "aapl", asset_type: "stock", quantity: 2, unit_price: 50, value: 100, book_cost: 80 }, { symbol: "msft", asset_type: "stock", quantity: 1, unit_price: 200, value: 200, book_cost: 150 }, ], }, ], }); expect(res.snapshotId).toBe(42); const calls = mockExecute.mock.calls.map((c: unknown[]) => String(c[0])); // The aggregated line is inserted with NULL qty/price and the SUM value. const aggInsertIdx = calls.findIndex((s: string) => s.includes("INSERT INTO balance_snapshot_lines")); expect(mockExecute.mock.calls[aggInsertIdx][1]).toEqual([42, 7, 300]); // Holdings are deleted then inserted referencing the captured line id (500). expect(calls).toContain("DELETE FROM balance_snapshot_holdings WHERE snapshot_line_id = $1"); const holdingInsert = mockExecute.mock.calls.find((c: unknown[]) => String(c[0]).includes("INSERT INTO balance_snapshot_holdings") ); expect(holdingInsert?.[1]?.[0]).toBe(500); // snapshot_line_id // Begins once, commits last, never rolls back. expect(mockExecute.mock.calls[0][0]).toBe("BEGIN"); expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe("COMMIT"); expect(calls).not.toContain("ROLLBACK"); }); it("ROLLS BACK the whole save when a holding INSERT fails (no partial line/holdings)", async () => { mockSelect .mockResolvedValueOnce([]) // dup-check .mockResolvedValueOnce([ { id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, ]); // security AAPL lookup mockExecute .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN .mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines .mockResolvedValueOnce({ lastInsertId: 500, rowsAffected: 1 }) // INSERT aggregated line .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE holdings .mockResolvedValueOnce({ rowsAffected: 1 }) // UPSERT security AAPL .mockRejectedValueOnce(new Error("holding FK violation")) // INSERT holding FAILS .mockResolvedValueOnce({ rowsAffected: 0 }); // ROLLBACK await expect( saveSnapshotAtomic({ existingSnapshotId: null, snapshot_date: "2026-05-30", lines: [ { account_id: 7, value: 100, holdings: [ { symbol: "AAPL", asset_type: "stock", quantity: 2, unit_price: 50, value: 100 }, ], }, ], }) ).rejects.toThrow("holding FK violation"); // ROLLBACK was the last call, no COMMIT happened. 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("recomputes the aggregated value server-side from the rounded-cent SUM", async () => { // Caller passes value=0.21 with 21×0.005 holdings; the line must store 0.21. mockSelect.mockResolvedValueOnce([]); // dup-check for (let i = 0; i < 21; i++) { mockSelect.mockResolvedValueOnce([ { id: 100 + i, symbol: `S${i}`, name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" }, ]); } // BEGIN, INSERT snapshot, DELETE lines, INSERT line, DELETE holdings, // then 21×(upsert security + insert holding), UPDATE, COMMIT. mockExecute .mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN .mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot .mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines .mockResolvedValueOnce({ lastInsertId: 500, rowsAffected: 1 }) // INSERT line .mockResolvedValueOnce({ rowsAffected: 0 }); // DELETE holdings for (let i = 0; i < 21; i++) { mockExecute .mockResolvedValueOnce({ rowsAffected: 1 }) // upsert security .mockResolvedValueOnce({ rowsAffected: 1 }); // insert holding } mockExecute .mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT const holdings = Array.from({ length: 21 }, (_, i) => ({ symbol: `S${i}`, asset_type: "stock" as const, quantity: 1, unit_price: 0.005, value: 0.005, })); await saveSnapshotAtomic({ existingSnapshotId: null, snapshot_date: "2026-05-30", lines: [{ account_id: 7, value: 0.21, holdings }], }); const lineInsert = mockExecute.mock.calls.find((c: unknown[]) => String(c[0]).includes("INSERT INTO balance_snapshot_lines") ); // 21 × roundToCent(0.005)=0.01 ⇒ 0.21 stored. expect(lineInsert?.[1]).toEqual([42, 7, 0.21]); }); }); describe("updateBalanceAccount — detailed→simple guard (#212)", () => { const detailedAccount = { id: 7, balance_category_id: 1, name: "Courtage", symbol: null, currency: "CAD", notes: null, is_active: 1, archived_at: null, vehicle_type: null, kind: "detailed", detailed_since: "2026-05-01", created_at: "", updated_at: "", }; it("rejects detailed→simple when holdings exist (typed error, no UPDATE)", async () => { mockSelect .mockResolvedValueOnce([detailedAccount]) // getBalanceAccount .mockResolvedValueOnce([{ n: 3 }]); // holdings count > 0 await expect( updateBalanceAccount(7, { kind: "simple" }) ).rejects.toMatchObject({ code: "account_kind_detailed_has_holdings" }); // Guard fires before the UPDATE. expect( mockExecute.mock.calls.some((c: unknown[]) => String(c[0]).includes("UPDATE balance_accounts") ) ).toBe(false); }); it("ALLOWS detailed→simple when no holdings exist yet", async () => { mockSelect .mockResolvedValueOnce([detailedAccount]) // getBalanceAccount .mockResolvedValueOnce([{ n: 0 }]); // no holdings mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); // UPDATE await updateBalanceAccount(7, { kind: "simple" }); const sql = mockExecute.mock.calls[0][0] as string; expect(sql).toContain("kind = $7"); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[6]).toBe("simple"); // kind }); it("does NOT run the holdings check on simple→detailed (only the dangerous direction is gated)", async () => { const simpleAccount = { ...detailedAccount, kind: "simple", detailed_since: null }; mockSelect.mockResolvedValueOnce([simpleAccount]); // getBalanceAccount only mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); // UPDATE await updateBalanceAccount(7, { kind: "detailed", detailed_since: "2026-06-01" }); // Exactly one SELECT (the account read) — no holdings count query. expect(mockSelect).toHaveBeenCalledTimes(1); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[6]).toBe("detailed"); expect(params[7]).toBe("2026-06-01"); // detailed_since normalized }); it("rejects an invalid kind value", async () => { mockSelect.mockResolvedValueOnce([{ ...detailedAccount, kind: "simple" }]); await expect( // @ts-expect-error runtime guard updateBalanceAccount(7, { kind: "weird" }) ).rejects.toMatchObject({ code: "account_kind_invalid" }); }); }); describe("listHoldingsBySnapshotLine / getHoldingsForLatestSnapshot", () => { it("listHoldingsBySnapshotLine joins the security and filters by line", async () => { mockSelect.mockResolvedValueOnce([]); await listHoldingsBySnapshotLine(500); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("FROM balance_snapshot_holdings h"); expect(sql).toContain("JOIN balance_securities s"); expect(sql).toContain("WHERE h.snapshot_line_id = $1"); expect(mockSelect.mock.calls[0][1]).toEqual([500]); }); it("getHoldingsForLatestSnapshot EXCLUDES quantity-0 positions", async () => { mockSelect.mockResolvedValueOnce([]); await getHoldingsForLatestSnapshot(7); const sql = mockSelect.mock.calls[0][0] as string; // Latest-snapshot subquery + the qty<>0 filter for the prefill. expect(sql).toContain("h.quantity <> 0"); expect(sql).toContain("ORDER BY s2.snapshot_date DESC"); expect(mockSelect.mock.calls[0][1]).toEqual([7]); }); }); describe("computeUnrealizedGain", () => { it("computes per-holding gain and % from book_cost", () => { const r = computeUnrealizedGain([ { security_id: 1, value: 120, book_cost: 100, symbol: "AAPL" }, { security_id: 2, value: 80, book_cost: 100, symbol: "MSFT" }, ]); expect(r.total_value).toBe(200); expect(r.total_book_cost).toBe(200); expect(r.total_gain).toBe(0); expect(r.total_gain_pct).toBe(0); expect(r.holdings[0].gain).toBe(20); expect(r.holdings[0].gain_pct).toBeCloseTo(0.2, 5); expect(r.holdings[1].gain).toBe(-20); expect(r.holdings[1].gain_pct).toBeCloseTo(-0.2, 5); expect(r.has_unknown_book_cost).toBe(false); }); it("book_cost = 0 ⇒ gain = value but gain_pct = null (N/A, no divide-by-zero)", () => { const r = computeUnrealizedGain([ { security_id: 1, value: 50, book_cost: 0 }, ]); expect(r.holdings[0].gain).toBe(50); expect(r.holdings[0].gain_pct).toBeNull(); // book_cost 0 contributes to the (zero) aggregate denominator ⇒ total % null. expect(r.total_book_cost).toBe(0); expect(r.total_gain_pct).toBeNull(); expect(r.has_unknown_book_cost).toBe(false); }); it("NULL book_cost ⇒ gain & % null, EXCLUDED from aggregate, flagged", () => { const r = computeUnrealizedGain([ { security_id: 1, value: 120, book_cost: 100 }, { security_id: 2, value: 300, book_cost: null }, ]); // The NULL-book_cost holding's value still counts toward total_value… expect(r.total_value).toBe(420); // …but NOT toward book_cost / gain aggregates. expect(r.total_book_cost).toBe(100); expect(r.total_gain).toBe(20); expect(r.total_gain_pct).toBeCloseTo(0.2, 5); expect(r.holdings[1].gain).toBeNull(); expect(r.holdings[1].gain_pct).toBeNull(); expect(r.has_unknown_book_cost).toBe(true); }); it("empty holdings ⇒ zeros and null %", () => { const r = computeUnrealizedGain([]); expect(r.total_value).toBe(0); expect(r.total_book_cost).toBe(0); expect(r.total_gain).toBe(0); expect(r.total_gain_pct).toBeNull(); expect(r.has_unknown_book_cost).toBe(false); expect(r.holdings).toEqual([]); }); }); describe("getAccountLatentGainByLine", () => { it("reads the line's holdings then folds them through computeUnrealizedGain", async () => { mockSelect.mockResolvedValueOnce([ { security_id: 1, value: 120, book_cost: 100, security_symbol: "AAPL", }, { security_id: 2, value: 300, book_cost: null, security_symbol: "MSFT", }, ]); const r = await getAccountLatentGainByLine(42); // It reads holdings of the supplied line id. expect(mockSelect.mock.calls[0][1]).toEqual([42]); // Aggregate mirrors computeUnrealizedGain (NULL book_cost excluded, flagged). expect(r.total_value).toBe(420); expect(r.total_gain).toBe(20); expect(r.has_unknown_book_cost).toBe(true); // Symbol from the security join is carried onto the per-holding rows. expect(r.holdings[0].symbol).toBe("AAPL"); }); }); describe("rollupLatentGain", () => { // A small AccountUnrealizedGain factory keeps the cases readable. const g = ( over: Partial ): AccountUnrealizedGain => ({ total_value: 0, total_book_cost: 0, total_gain: 0, total_gain_pct: null, has_unknown_book_cost: false, holdings: [], ...over, }); it("groups by asset class and by envelope, and sums a grand total", () => { const r = rollupLatentGain([ { category_key: "stock", vehicle_type: "tfsa", gain: g({ total_value: 120, total_book_cost: 100, total_gain: 20 }), }, { category_key: "stock", vehicle_type: "rrsp", gain: g({ total_value: 80, total_book_cost: 100, total_gain: -20 }), }, { category_key: "crypto", vehicle_type: "tfsa", gain: g({ total_value: 50, total_book_cost: 40, total_gain: 10 }), }, ]); // Asset-class buckets: stock merges the two accounts, crypto stands alone. const stock = r.byClass.find((b) => b.group_key === "stock")!; expect(stock.total_value).toBe(200); expect(stock.total_gain).toBe(0); expect(stock.total_gain_pct).toBe(0); // 0 / 200 const crypto = r.byClass.find((b) => b.group_key === "crypto")!; expect(crypto.total_gain_pct).toBeCloseTo(0.25, 5); // 10 / 40 // Envelope buckets: tfsa merges stock+crypto, rrsp stands alone. const tfsa = r.byVehicle.find((b) => b.group_key === "tfsa")!; expect(tfsa.total_value).toBe(170); expect(tfsa.total_gain).toBe(30); expect(tfsa.total_gain_pct).toBeCloseTo(30 / 140, 5); // Grand total across every account. expect(r.grandTotal.total_value).toBe(250); expect(r.grandTotal.total_gain).toBe(10); expect(r.grandTotal.total_gain_pct).toBeCloseTo(10 / 240, 5); expect(r.grandTotal.has_unknown_book_cost).toBe(false); }); it("buckets a NULL vehicle_type under the 'none' envelope key", () => { const r = rollupLatentGain([ { category_key: "other", vehicle_type: null, gain: g({ total_value: 10, total_book_cost: 10, total_gain: 0 }), }, ]); expect(r.byVehicle.map((b) => b.group_key)).toEqual(["none"]); }); it("a bucket with no known book_cost yields a null % and flags unknown", () => { const r = rollupLatentGain([ { category_key: "stock", vehicle_type: "tfsa", // All book_cost unknown ⇒ total_book_cost 0, gain 0, flagged. gain: g({ total_value: 500, has_unknown_book_cost: true }), }, ]); expect(r.byClass[0].total_gain_pct).toBeNull(); expect(r.byClass[0].has_unknown_book_cost).toBe(true); expect(r.grandTotal.total_gain_pct).toBeNull(); expect(r.grandTotal.has_unknown_book_cost).toBe(true); }); it("empty input ⇒ empty buckets and a zeroed grand total", () => { const r = rollupLatentGain([]); expect(r.byClass).toEqual([]); expect(r.byVehicle).toEqual([]); expect(r.grandTotal.total_value).toBe(0); expect(r.grandTotal.total_gain_pct).toBeNull(); }); });