Simpl-Resultat/src/components/balance/StarterAccountsModal.test.tsx
le king fu cd0a2b826f feat(balance): starter accounts + opt-in modal + ADR 0012
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
2026-05-02 11:59:45 -04:00

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");
});
});