import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("./db", () => ({ getDb: vi.fn(), })); import { getDb } from "./db"; import { listBalanceCategories, createBalanceCategory, updateBalanceCategory, deleteBalanceCategory, listBalanceAccounts, createBalanceAccount, updateBalanceAccount, archiveBalanceAccount, unarchiveBalanceAccount, listSnapshots, getSnapshotByDate, createSnapshot, updateSnapshot, deleteSnapshot, listLinesBySnapshot, upsertSnapshotLines, getPreviousSnapshot, BalanceServiceError, } from "./balance.service"; const mockSelect = vi.fn(); const mockExecute = vi.fn(); const mockDb = { select: mockSelect, execute: mockExecute }; beforeEach(() => { vi.mocked(getDb).mockResolvedValue(mockDb as never); mockSelect.mockReset(); mockExecute.mockReset(); }); // ----------------------------------------------------------------------------- // Categories // ----------------------------------------------------------------------------- describe("listBalanceCategories", () => { it("orders by sort_order then key", async () => { mockSelect.mockResolvedValueOnce([]); await listBalanceCategories(); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("FROM balance_categories"); expect(sql).toContain("ORDER BY sort_order, key"); }); }); describe("createBalanceCategory", () => { it("rejects an empty key", async () => { await expect( createBalanceCategory({ key: " ", i18n_key: "x", kind: "simple" }) ).rejects.toBeInstanceOf(BalanceServiceError); expect(mockExecute).not.toHaveBeenCalled(); }); it("rejects an invalid kind", async () => { await expect( createBalanceCategory({ key: "custom", i18n_key: "balance.category.custom", // @ts-expect-error testing runtime guard kind: "weird", }) ).rejects.toBeInstanceOf(BalanceServiceError); expect(mockExecute).not.toHaveBeenCalled(); }); it("inserts with is_seed = 0 and returns lastInsertId", async () => { mockExecute.mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }); const id = await createBalanceCategory({ key: "ferr", i18n_key: "balance.category.ferr", kind: "simple", sort_order: 35, }); expect(id).toBe(42); const sql = mockExecute.mock.calls[0][0] as string; const params = mockExecute.mock.calls[0][1] as unknown[]; expect(sql).toContain("INSERT INTO balance_categories"); expect(sql).toContain("is_seed"); expect(sql).toMatch(/0\)$/); // is_seed hardcoded to 0 expect(params).toEqual(["ferr", "balance.category.ferr", "simple", 35]); }); }); describe("deleteBalanceCategory", () => { it("refuses to delete a seeded category", async () => { mockSelect.mockResolvedValueOnce([ { id: 1, key: "cash", i18n_key: "balance.category.cash", kind: "simple", sort_order: 10, is_active: 1, is_seed: 1, }, ]); await expect(deleteBalanceCategory(1)).rejects.toMatchObject({ code: "category_seed_protected", }); expect(mockExecute).not.toHaveBeenCalled(); }); it("refuses to delete a category with linked accounts", async () => { // 1st select = getBalanceCategory; 2nd select = COUNT(*) accounts linked mockSelect .mockResolvedValueOnce([ { id: 8, key: "ferr", i18n_key: "balance.category.ferr", kind: "simple", sort_order: 35, is_active: 1, is_seed: 0, }, ]) .mockResolvedValueOnce([{ count: 2 }]); await expect(deleteBalanceCategory(8)).rejects.toMatchObject({ code: "category_has_accounts", }); expect(mockExecute).not.toHaveBeenCalled(); }); it("deletes a user-created category with no linked accounts", async () => { mockSelect .mockResolvedValueOnce([ { id: 8, key: "ferr", i18n_key: "balance.category.ferr", kind: "simple", sort_order: 35, is_active: 1, is_seed: 0, }, ]) .mockResolvedValueOnce([{ count: 0 }]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await deleteBalanceCategory(8); expect(mockExecute).toHaveBeenCalledWith( "DELETE FROM balance_categories WHERE id = $1", [8] ); }); }); describe("updateBalanceCategory", () => { it("renames a seeded category (allowed)", async () => { mockSelect.mockResolvedValueOnce([ { id: 1, key: "cash", i18n_key: "balance.category.cash", kind: "simple", sort_order: 10, is_active: 1, is_seed: 1, }, ]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await updateBalanceCategory(1, { i18n_key: "balance.category.cash_renamed" }); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[0]).toBe("balance.category.cash_renamed"); }); it("rejects update on missing category", async () => { mockSelect.mockResolvedValueOnce([]); await expect(updateBalanceCategory(999, { sort_order: 5 })).rejects.toMatchObject({ code: "category_not_found", }); }); }); // ----------------------------------------------------------------------------- // Accounts // ----------------------------------------------------------------------------- describe("listBalanceAccounts", () => { it("excludes archived accounts by default", async () => { mockSelect.mockResolvedValueOnce([]); await listBalanceAccounts(); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("a.is_active = 1"); expect(sql).toContain("a.archived_at IS NULL"); }); it("includes archived accounts when requested", async () => { mockSelect.mockResolvedValueOnce([]); await listBalanceAccounts({ includeArchived: true }); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).not.toContain("archived_at IS NULL"); }); }); describe("createBalanceAccount", () => { it("rejects empty name", async () => { await expect( createBalanceAccount({ balance_category_id: 1, name: " " }) ).rejects.toMatchObject({ code: "name_required" }); }); it("rejects non-CAD currency at the MVP", async () => { await expect( createBalanceAccount({ balance_category_id: 1, name: "USD account", currency: "USD", }) ).rejects.toMatchObject({ code: "currency_unsupported" }); expect(mockExecute).not.toHaveBeenCalled(); }); it("rejects when the category does not exist", async () => { mockSelect.mockResolvedValueOnce([]); // getBalanceCategory returns null await expect( createBalanceAccount({ balance_category_id: 999, name: "Mystery" }) ).rejects.toMatchObject({ code: "category_not_found" }); }); it("inserts with default CAD currency", async () => { mockSelect.mockResolvedValueOnce([ { id: 1, key: "cash", i18n_key: "balance.category.cash", kind: "simple", sort_order: 10, is_active: 1, is_seed: 1, }, ]); mockExecute.mockResolvedValueOnce({ lastInsertId: 7, rowsAffected: 1 }); const id = await createBalanceAccount({ balance_category_id: 1, name: "Encaisse Wealthsimple", }); expect(id).toBe(7); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params).toEqual([1, "Encaisse Wealthsimple", null, "CAD", null]); }); }); describe("updateBalanceAccount", () => { it("rejects when account does not exist", async () => { mockSelect.mockResolvedValueOnce([]); await expect(updateBalanceAccount(42, { name: "x" })).rejects.toMatchObject({ code: "account_not_found", }); }); it("normalizes empty symbol to null", async () => { mockSelect.mockResolvedValueOnce([ { id: 7, balance_category_id: 1, name: "Encaisse", symbol: "OLD", currency: "CAD", notes: null, is_active: 1, archived_at: null, created_at: "", updated_at: "", }, ]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await updateBalanceAccount(7, { symbol: " " }); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[2]).toBeNull(); // symbol }); }); describe("archiveBalanceAccount / unarchiveBalanceAccount", () => { it("archives an existing account", async () => { mockSelect.mockResolvedValueOnce([ { id: 7, balance_category_id: 1, name: "Encaisse", symbol: null, currency: "CAD", notes: null, is_active: 1, archived_at: null, created_at: "", updated_at: "", }, ]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await archiveBalanceAccount(7); const sql = mockExecute.mock.calls[0][0] as string; expect(sql).toContain("archived_at = CURRENT_TIMESTAMP"); expect(sql).toContain("is_active = 0"); }); it("unarchives an existing account", async () => { mockSelect.mockResolvedValueOnce([ { id: 7, balance_category_id: 1, name: "Encaisse", symbol: null, currency: "CAD", notes: null, is_active: 0, archived_at: "2026-04-25 10:00:00", created_at: "", updated_at: "", }, ]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await unarchiveBalanceAccount(7); const sql = mockExecute.mock.calls[0][0] as string; expect(sql).toContain("archived_at = NULL"); expect(sql).toContain("is_active = 1"); }); }); // ----------------------------------------------------------------------------- // Snapshots + lines (Issue #146 / Bilan #1b — simple kind only) // ----------------------------------------------------------------------------- const FAKE_SNAPSHOT = { id: 5, snapshot_date: "2026-04-15", notes: null, created_at: "", updated_at: "", }; describe("listSnapshots", () => { it("orders by snapshot_date DESC", async () => { mockSelect.mockResolvedValueOnce([]); await listSnapshots(); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("FROM balance_snapshots"); expect(sql).toContain("ORDER BY snapshot_date DESC"); }); }); describe("getSnapshotByDate", () => { it("rejects empty / invalid dates with snapshot_date_required", async () => { await expect(getSnapshotByDate("")).rejects.toMatchObject({ code: "snapshot_date_required", }); await expect(getSnapshotByDate("2026/04/15")).rejects.toMatchObject({ code: "snapshot_date_required", }); expect(mockSelect).not.toHaveBeenCalled(); }); it("returns the snapshot row when found", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); const got = await getSnapshotByDate("2026-04-15"); expect(got).toEqual(FAKE_SNAPSHOT); expect(mockSelect.mock.calls[0][1]).toEqual(["2026-04-15"]); }); it("returns null when no row matches", async () => { mockSelect.mockResolvedValueOnce([]); expect(await getSnapshotByDate("2026-04-15")).toBeNull(); }); }); describe("createSnapshot", () => { it("rejects an invalid date", async () => { await expect( createSnapshot({ snapshot_date: " " }) ).rejects.toMatchObject({ code: "snapshot_date_required" }); expect(mockExecute).not.toHaveBeenCalled(); }); it("rejects a duplicate snapshot date with snapshot_date_taken", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); // existing await expect( createSnapshot({ snapshot_date: "2026-04-15" }) ).rejects.toMatchObject({ code: "snapshot_date_taken" }); expect(mockExecute).not.toHaveBeenCalled(); }); it("inserts a new snapshot and returns its id", async () => { mockSelect.mockResolvedValueOnce([]); // no existing mockExecute.mockResolvedValueOnce({ lastInsertId: 12, rowsAffected: 1 }); const id = await createSnapshot({ snapshot_date: "2026-04-25", notes: " monthly check ", }); expect(id).toBe(12); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params).toEqual(["2026-04-25", "monthly check"]); }); }); describe("updateSnapshot", () => { it("rejects when snapshot does not exist", async () => { mockSelect.mockResolvedValueOnce([]); await expect( updateSnapshot(999, { notes: "x" }) ).rejects.toMatchObject({ code: "snapshot_not_found" }); }); it("normalizes empty notes to null", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await updateSnapshot(5, { notes: " " }); const params = mockExecute.mock.calls[0][1] as unknown[]; expect(params[0]).toBeNull(); }); }); describe("deleteSnapshot", () => { it("rejects when snapshot does not exist", async () => { mockSelect.mockResolvedValueOnce([]); await expect(deleteSnapshot(999)).rejects.toMatchObject({ code: "snapshot_not_found", }); }); it("deletes when found (lines cascade via FK)", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); await deleteSnapshot(5); expect(mockExecute).toHaveBeenCalledWith( "DELETE FROM balance_snapshots WHERE id = $1", [5] ); }); }); describe("listLinesBySnapshot", () => { it("orders by id and filters by snapshot_id", async () => { mockSelect.mockResolvedValueOnce([]); await listLinesBySnapshot(5); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("FROM balance_snapshot_lines"); expect(sql).toContain("WHERE snapshot_id = $1"); expect(sql).toContain("ORDER BY id"); expect(mockSelect.mock.calls[0][1]).toEqual([5]); }); }); describe("upsertSnapshotLines (simple kind)", () => { it("rejects when the parent snapshot is missing", async () => { mockSelect.mockResolvedValueOnce([]); await expect( upsertSnapshotLines(99, [{ account_id: 1, value: 1000 }]) ).rejects.toMatchObject({ code: "snapshot_not_found" }); expect(mockExecute).not.toHaveBeenCalled(); }); it("rejects non-finite values with snapshot_value_invalid", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); await expect( upsertSnapshotLines(5, [ { account_id: 1, value: 1000 }, // @ts-expect-error testing runtime guard { account_id: 2, value: "not a number" }, ]) ).rejects.toMatchObject({ code: "snapshot_value_invalid" }); // Validation happens up-front, before any mutation expect(mockExecute).not.toHaveBeenCalled(); }); it("rejects NaN and Infinity", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); await expect( upsertSnapshotLines(5, [{ account_id: 1, value: NaN }]) ).rejects.toMatchObject({ code: "snapshot_value_invalid" }); mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); await expect( upsertSnapshotLines(5, [{ account_id: 1, value: Infinity }]) ).rejects.toMatchObject({ code: "snapshot_value_invalid" }); }); it("clears existing lines, inserts each line with NULL quantity/unit_price, and bumps updated_at", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); mockExecute .mockResolvedValueOnce({ rowsAffected: 1 }) // delete .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert 1 .mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert 2 .mockResolvedValueOnce({ rowsAffected: 1 }); // update updated_at await upsertSnapshotLines(5, [ { account_id: 1, value: 1234.56 }, { account_id: 2, value: 0 }, ]); // 1st call = DELETE expect(mockExecute.mock.calls[0][0]).toContain( "DELETE FROM balance_snapshot_lines" ); // Inserts use literal NULL for quantity/unit_price (simple kind invariant) const insertSql = mockExecute.mock.calls[1][0] as string; expect(insertSql).toContain("INSERT INTO balance_snapshot_lines"); expect(insertSql).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/); expect(insertSql).toContain("'manual'"); // First insert params expect(mockExecute.mock.calls[1][1]).toEqual([5, 1, 1234.56]); // Second insert params (zero is allowed) expect(mockExecute.mock.calls[2][1]).toEqual([5, 2, 0]); // Final call = UPDATE updated_at on parent snapshot expect(mockExecute.mock.calls[3][0]).toContain( "UPDATE balance_snapshots" ); expect(mockExecute.mock.calls[3][0]).toContain("updated_at"); }); it("clears all lines when called with an empty array", async () => { mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); mockExecute .mockResolvedValueOnce({ rowsAffected: 3 }) // delete only .mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at await upsertSnapshotLines(5, []); // Only DELETE + UPDATE updated_at — no INSERTs expect(mockExecute).toHaveBeenCalledTimes(2); }); }); describe("getPreviousSnapshot", () => { it("returns the most recent snapshot strictly before referenceDate", async () => { mockSelect.mockResolvedValueOnce([ { ...FAKE_SNAPSHOT, snapshot_date: "2026-03-15" }, ]); const got = await getPreviousSnapshot("2026-04-15"); expect(got?.snapshot_date).toBe("2026-03-15"); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("snapshot_date < $1"); expect(sql).toContain("ORDER BY snapshot_date DESC"); expect(sql).toContain("LIMIT 1"); }); it("returns null when no earlier snapshot exists", async () => { mockSelect.mockResolvedValueOnce([]); expect(await getPreviousSnapshot("2026-04-15")).toBeNull(); }); it("rejects an invalid reference date", async () => { await expect(getPreviousSnapshot("nope")).rejects.toMatchObject({ code: "snapshot_date_required", }); }); });