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>
219 lines
9 KiB
TypeScript
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");
|
|
});
|
|
});
|