Simpl-Resultat/src/components/balance/StarterAccountsModal.test.tsx
le king fu 8cb7e53698
All checks were successful
PR Check / rust (pull_request) Successful in 24m30s
PR Check / frontend (pull_request) Successful in 2m38s
fix(db): serialize DB access to fix "database is locked"; live-update log console
Database lock (the reported 0.10.0 regression):
- tauri-plugin-sql loads SQLite through a default multi-connection sqlx pool
  (Pool::connect => max_connections = 10) and exposes no JS transaction
  primitive. BEGIN and COMMIT issued as separate db.execute calls could land on
  different pooled connections, stranding an open write transaction on an idle
  connection — a permanent write lock until app restart. Triggered when the
  Bilan page's concurrent reads interleaved with an in-flight snapshot save.
- Fix: funnel every db operation through one FIFO lock in db.ts; withTransaction
  holds it across the whole BEGIN..COMMIT so a transaction's statements never
  span connections (and the saves become genuinely atomic). A reentrancy guard
  lets nested getDb() calls run directly instead of deadlocking. Applied to all
  5 transaction sites (saveSnapshotAtomic, upsertSnapshotLines,
  proposeStarterAccounts, applyKeywordWithReassignment, applyMigration) via
  in-place helper extraction. New db.test.ts covers serialization,
  cross-transaction non-interleaving, reentrancy, and lock release on error.

Log console (Settings -> Systems -> Journaux):
- getLogs returned the same mutated array reference, so useSyncExternalStore
  (identity comparison) never re-rendered on a new log -> the console never
  updated live. getLogs now returns an immutable snapshot rebuilt on each
  mutation (stable between mutations, new identity after each).
- Add logInfo/logWarn/logError app-logging API and instrument the snapshot save
  (info on success, error on failure) so DB issues surface in the console.

tsc + 635 vitest + vite build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:50:39 -04:00

219 lines
9 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", () => {
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");
});
});