// 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", () => { const getDb = vi.fn(); return { getDb, withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())), }; }); 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 keyed 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.i18nKey).toMatch(/^balance\.starters\.items\./); } }); it("maps CELI/REER to the `other` asset class with a vehicle_type (#202)", () => { // After the envelope/asset-class split, only `cash` keeps its own class; // CELI/REER/non-registered all live under `other`, and the envelope is // carried by vehicle_type (NOT an automobile type). const byKey = Object.fromEntries(STARTER_ACCOUNTS.map((s) => [s.key, s])); expect(byKey.cash.categoryKey).toBe("cash"); expect(byKey.cash.vehicleType).toBeNull(); expect(byKey.tfsa.categoryKey).toBe("other"); expect(byKey.tfsa.vehicleType).toBe("tfsa"); expect(byKey.rrsp.categoryKey).toBe("other"); expect(byKey.rrsp.vehicleType).toBe("rrsp"); expect(byKey.other.categoryKey).toBe("other"); expect(byKey.other.vehicleType).toBeNull(); }); }); 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" }); it("flags the CELI starter when a CELI account lives in the `other` class (#202)", async () => { // Post-split, the CELI starter's categoryKey is `other`. An exact-name CELI // account in `other` is a collision; a non-registered account in `other` is not. mockSelect.mockResolvedValueOnce([ { key: "other", account_name: "CELI" }, { key: "other", account_name: "Compte non-enregistré" }, ]); const result = await getStarterCollisions(); expect(result.has("tfsa")).toBe(true); expect(result.has("other")).toBe(true); expect(result.has("rrsp")).toBe(false); expect(result.has("cash")).toBe(false); }); it("queries only the cash + other asset classes (#202)", async () => { mockSelect.mockResolvedValueOnce([]); await getStarterCollisions(); const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toMatch(/c\.key IN \('cash','other'\)/); }); it("excludes archived accounts via SQL filter", async () => { mockSelect.mockResolvedValueOnce([]); await getStarterCollisions(); const sql = mockSelect.mock.calls[0][0]; expect(sql).toMatch(/archived_at IS NULL/); }); }); 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 category id, SELECT in-txn collision check, INSERT mockSelect .mockResolvedValueOnce([{ id: 11 }]) // cash category lookup .mockResolvedValueOnce([{ count: 0 }]) // S3 collision check for cash .mockResolvedValueOnce([{ id: 13 }]) // rrsp category lookup .mockResolvedValueOnce([{ count: 0 }]); // S3 collision check for rrsp 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("skips silently when in-txn collision check finds an existing account (S3)", async () => { // BEGIN mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // First starter "cash": category lookup succeeds, collision check returns count=1 → skip mockSelect .mockResolvedValueOnce([{ id: 11 }]) // cash category lookup .mockResolvedValueOnce([{ count: 1 }]) // S3 collision: cash already exists // Second starter "rrsp": category lookup + clean collision check .mockResolvedValueOnce([{ id: 13 }]) // rrsp category lookup .mockResolvedValueOnce([{ count: 0 }]); // rrsp clean mockExecute .mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 101 }) // INSERT rrsp .mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // COMMIT const result = await proposeStarterAccounts(["cash", "rrsp"]); expect(result).toEqual([101]); // only rrsp inserted, cash skipped silently const sqls = mockExecute.mock.calls.map((c) => c[0]); expect(sqls.filter((s) => /INSERT INTO balance_accounts/.test(s))).toHaveLength(1); expect(sqls).toContain("COMMIT"); // no rollback — skip is normal flow }); it("resolves categories with is_active=1 and writes vehicle_type (#202)", async () => { mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN mockSelect .mockResolvedValueOnce([{ id: 50 }]) // other category lookup (for tfsa starter) .mockResolvedValueOnce([{ count: 0 }]); // S3 collision check clean mockExecute .mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 200 }) // INSERT CELI .mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // COMMIT const result = await proposeStarterAccounts(["tfsa"]); expect(result).toEqual([200]); // Category lookup must require is_active = 1 so a deactivated ex-envelope // seed is never picked up. const lookupSql = mockSelect.mock.calls[0][0] as string; expect(lookupSql).toMatch(/is_active = 1/); // The CELI starter is told to resolve `other`, not `tfsa`. expect(mockSelect.mock.calls[0][1]).toEqual(["other"]); // The INSERT carries vehicle_type='tfsa' as its 3rd param. const insertCall = mockExecute.mock.calls.find((c) => /INSERT INTO balance_accounts/.test(c[0] as string) )!; const insertSql = insertCall[0] as string; expect(insertSql).toContain("vehicle_type"); expect((insertCall[1] as unknown[])[2]).toBe("tfsa"); }); it("rolls back on insert failure", async () => { mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN mockSelect .mockResolvedValueOnce([{ id: 11 }]) // cash category .mockResolvedValueOnce([{ count: 0 }]); // S3 collision check clean 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"); }); });