Part 1 — New profiles: seed 4 starter accounts in
consolidated_schema.sql (Compte chèque/CELI/REER/Compte
non-enregistré, currency CAD, is_active=1) right after the
balance_categories seeds. Categories resolved via SELECT subquery
on the seeded `key` values for robustness.
Part 2 — Existing profiles: StarterAccountsModal proposes the same
4 starters at first /balance visit. Default-checked checkboxes,
collision rule (case-insensitive trim name + matching category)
disables matches with a "Déjà présent" tooltip. The atomic helper
`proposeStarterAccounts` wraps the inserts in BEGIN/COMMIT (rolls
back on error). user_preferences.balance_starter_proposed records
{shown_at, accepted} so the modal never reappears, dismissed or
confirmed.
Part 3 — docs/adr/0012-balance-two-level-model.md (Proposed):
captures the future vehicles × compositions model for reflection,
no code change. Numbered 0012 because 0011 was already taken by
the providers-best-effort-yahoo ADR. Linked from architecture.md
ADR table and Bilan section.
Tests: StarterAccountsModal.test.tsx covers STARTER_ACCOUNTS shape,
getStarterCollisions (case-insensitive trim, category-scoped) and
proposeStarterAccounts (insert order, COMMIT, ROLLBACK on failure).
No render tests — mirrors the BalanceOnboardingCard pattern (no
jsdom configured).
Resolves #179
119 lines
4.3 KiB
TypeScript
119 lines
4.3 KiB
TypeScript
// 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");
|
|
});
|
|
});
|