// StarterAccountsModal — unit tests (issue #179) // // NOTE: This project does not have @testing-library/react or jsdom configured // (matches the BalanceOnboardingCard.test.tsx pattern from #178). Tests cover // the service-layer helpers (`getStarterCollisions`, `proposeStarterAccounts`) // and the `STARTER_ACCOUNTS` constant — the modal itself is pure orchestration // over those helpers. import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("../../services/db", () => ({ getDb: vi.fn(), })); import { getDb } from "../../services/db"; import { STARTER_ACCOUNTS, getStarterCollisions, proposeStarterAccounts, } from "../../services/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(); }); describe("STARTER_ACCOUNTS", () => { it("ships exactly 4 starters mapping cash/tfsa/rrsp/other", () => { expect(STARTER_ACCOUNTS).toHaveLength(4); expect(STARTER_ACCOUNTS.map((s) => s.key)).toEqual([ "cash", "tfsa", "rrsp", "other", ]); for (const s of STARTER_ACCOUNTS) { expect(s.categoryKey).toBe(s.key); expect(s.i18nKey).toMatch(/^balance\.starters\.items\./); } }); }); describe("getStarterCollisions", () => { it("returns empty set when no accounts collide", async () => { mockSelect.mockResolvedValueOnce([]); const result = await getStarterCollisions(); expect(result.size).toBe(0); }); it("flags exact-name collisions case-insensitive trim", async () => { mockSelect.mockResolvedValueOnce([ { key: "cash", account_name: " compte chèque " }, { key: "tfsa", account_name: "Mon CELI 2024" }, // does NOT match "CELI" exactly ]); const result = await getStarterCollisions(); expect(result.has("cash")).toBe(true); expect(result.has("tfsa")).toBe(false); expect(result.has("rrsp")).toBe(false); expect(result.has("other")).toBe(false); }); it("requires the account to live in the matching category", async () => { // CELI-named account but in 'cash' category → not a collision for tfsa starter mockSelect.mockResolvedValueOnce([ { key: "cash", account_name: "CELI" }, ]); const result = await getStarterCollisions(); expect(result.has("tfsa")).toBe(false); expect(result.has("cash")).toBe(false); // name "CELI" != "Compte chèque" }); }); describe("proposeStarterAccounts", () => { it("returns [] when no keys selected without opening a transaction", async () => { const result = await proposeStarterAccounts([]); expect(result).toEqual([]); expect(mockExecute).not.toHaveBeenCalled(); }); it("inserts selected starters atomically and returns their ids", async () => { // BEGIN mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // For each starter: SELECT id FROM balance_categories + INSERT mockSelect .mockResolvedValueOnce([{ id: 11 }]) // cash category .mockResolvedValueOnce([{ id: 13 }]); // rrsp category mockExecute .mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 100 }) // INSERT cash .mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 101 }) // INSERT rrsp .mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // COMMIT const result = await proposeStarterAccounts(["cash", "rrsp"]); expect(result).toEqual([100, 101]); const sqls = mockExecute.mock.calls.map((c) => c[0]); expect(sqls[0]).toBe("BEGIN"); expect(sqls[sqls.length - 1]).toBe("COMMIT"); expect(sqls.filter((s) => /INSERT INTO balance_accounts/.test(s))).toHaveLength(2); }); it("rolls back on insert failure", async () => { mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN mockSelect.mockResolvedValueOnce([{ id: 11 }]); mockExecute.mockRejectedValueOnce(new Error("disk full")); mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // ROLLBACK await expect(proposeStarterAccounts(["cash"])).rejects.toThrow(); const sqls = mockExecute.mock.calls.map((c) => c[0]); expect(sqls).toContain("BEGIN"); expect(sqls).toContain("ROLLBACK"); expect(sqls).not.toContain("COMMIT"); }); });