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>
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
/**
|
||
* Regression-style tests parameterised on both v2 (current seed) and v1
|
||
* (new IPC taxonomy) category ids. These exercise the same app services on
|
||
* both shapes and assert identical observable behaviour — the spec's
|
||
* guarantee that the migration does not silently break:
|
||
*
|
||
* - categorizationService (keyword → regex → category_id matching)
|
||
* - budgetService.getBudgetVsActualData (parent/child aggregation)
|
||
* - dataExportService envelope round-trip (SREF format parity)
|
||
*
|
||
* We mock the DB per test and drive each service with a small, deterministic
|
||
* dataset scoped to either v2 or v1 category ids.
|
||
*/
|
||
|
||
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 {
|
||
normalizeDescription,
|
||
buildKeywordRegex,
|
||
compileKeywords,
|
||
categorizeBatch,
|
||
} from "../services/categorizationService";
|
||
import { getBudgetVsActualData } from "../services/budgetService";
|
||
import {
|
||
parseImportedJson,
|
||
serializeToJson,
|
||
type ExportEnvelope,
|
||
} from "../services/dataExportService";
|
||
import type { Keyword, Category, BudgetEntry } from "../shared/types";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shared mock DB harness — each test resets and stubs specific SELECTs.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const mockSelect = vi.fn();
|
||
const mockExecute = vi.fn();
|
||
|
||
beforeEach(() => {
|
||
vi.mocked(getDb).mockResolvedValue({ select: mockSelect, execute: mockExecute } as never);
|
||
mockSelect.mockReset();
|
||
mockExecute.mockReset();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// normalizeDescription — identical on v2/v1 inputs (no schema dependency)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe("regression: normalizeDescription is schema-agnostic", () => {
|
||
it.each([
|
||
["IGA #5555", "iga #5555"],
|
||
["SHELL\t#231", "shell #231"],
|
||
[" Hydro-Québec FACTURE ", "hydro-quebec facture"],
|
||
])("%s -> %s", (input, expected) => {
|
||
expect(normalizeDescription(input)).toBe(expected);
|
||
});
|
||
});
|
||
|
||
describe("regression: buildKeywordRegex boundaries", () => {
|
||
it("matches whole-word keywords on both v2- and v1-style descriptions", () => {
|
||
const re = buildKeywordRegex(normalizeDescription("STM"));
|
||
expect(re.test(normalizeDescription("STM CARTE OPUS"))).toBe(true);
|
||
expect(re.test(normalizeDescription("METROSTMONTREAL"))).toBe(false);
|
||
});
|
||
it("handles a keyword with a non-word leading char (common in bank exports)", () => {
|
||
const re = buildKeywordRegex(normalizeDescription("[INTERAC]"));
|
||
expect(re.test(normalizeDescription("PAIEMENT [INTERAC] XXX"))).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// categorizeBatch parameterised per schema — same description must map to the
|
||
// same (schema-specific) category id.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type Schema = "v2" | "v1";
|
||
|
||
function kwFixture(schema: Schema): Keyword[] {
|
||
const shellCat = schema === "v2" ? 40 : 1512;
|
||
const igaCat = schema === "v2" ? 22 : 1111;
|
||
const stmCat = schema === "v2" ? 28 : 1521;
|
||
return [
|
||
{
|
||
id: 1,
|
||
keyword: "SHELL",
|
||
category_id: shellCat,
|
||
priority: 100,
|
||
is_active: true,
|
||
} as Keyword,
|
||
{
|
||
id: 2,
|
||
keyword: "IGA",
|
||
category_id: igaCat,
|
||
priority: 100,
|
||
is_active: true,
|
||
} as Keyword,
|
||
{
|
||
id: 3,
|
||
keyword: "STM",
|
||
category_id: stmCat,
|
||
priority: 100,
|
||
is_active: true,
|
||
} as Keyword,
|
||
];
|
||
}
|
||
|
||
describe.each<[Schema]>([["v2"], ["v1"]])(
|
||
"regression: categorizeBatch [%s]",
|
||
(schema) => {
|
||
it("matches the expected category ids for each description", async () => {
|
||
mockSelect.mockResolvedValueOnce(kwFixture(schema));
|
||
const results = await categorizeBatch([
|
||
"SHELL #231 LAVAL",
|
||
"IGA EXTRA #5555",
|
||
"STM CARTE OPUS",
|
||
"UNRELATED",
|
||
]);
|
||
const [shell, iga, stm, none] = results;
|
||
expect(shell.category_id).toBe(schema === "v2" ? 40 : 1512);
|
||
expect(iga.category_id).toBe(schema === "v2" ? 22 : 1111);
|
||
expect(stm.category_id).toBe(schema === "v2" ? 28 : 1521);
|
||
expect(none.category_id).toBeNull();
|
||
});
|
||
},
|
||
);
|
||
|
||
describe("regression: compileKeywords parity across schemas", () => {
|
||
it("produces identical regex patterns regardless of the category_id", () => {
|
||
const v2Kw = kwFixture("v2");
|
||
const v1Kw = kwFixture("v1");
|
||
const v2Compiled = compileKeywords(v2Kw);
|
||
const v1Compiled = compileKeywords(v1Kw);
|
||
expect(v2Compiled.map((c) => c.regex.source)).toEqual(
|
||
v1Compiled.map((c) => c.regex.source),
|
||
);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// budgetService.getBudgetVsActualData parameterised per schema
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function budgetFixture(schema: Schema): {
|
||
categories: Category[];
|
||
entries: BudgetEntry[];
|
||
} {
|
||
if (schema === "v2") {
|
||
const categories: Category[] = [
|
||
// Parent + 2 children to exercise aggregation
|
||
mkCat(22, "Épicerie", null, "expense"),
|
||
mkCat(220, "Épicerie courante", 22, "expense"),
|
||
mkCat(221, "Gros achats", 22, "expense"),
|
||
];
|
||
const entries: BudgetEntry[] = [
|
||
mkEntry(220, 2026, 3, 400),
|
||
mkEntry(221, 2026, 3, 200),
|
||
];
|
||
return { categories, entries };
|
||
}
|
||
// v1: Épicerie → 1110 (subcategory Alimentation), 1111 + 1112 leaves
|
||
const categories: Category[] = [
|
||
mkCat(1100, "Alimentation", null, "expense"),
|
||
mkCat(1110, "Épicerie", 1100, "expense"),
|
||
mkCat(1111, "Régulière", 1110, "expense"),
|
||
mkCat(1112, "Bio / spécialisée", 1110, "expense"),
|
||
];
|
||
const entries: BudgetEntry[] = [
|
||
mkEntry(1111, 2026, 3, 400),
|
||
mkEntry(1112, 2026, 3, 200),
|
||
];
|
||
return { categories, entries };
|
||
}
|
||
|
||
function mkCat(
|
||
id: number,
|
||
name: string,
|
||
parent_id: number | null,
|
||
type: "expense" | "income" | "transfer",
|
||
): Category {
|
||
return {
|
||
id,
|
||
name,
|
||
parent_id,
|
||
color: "#000",
|
||
type,
|
||
is_active: 1,
|
||
is_inputable: 1,
|
||
sort_order: id,
|
||
i18n_key: null,
|
||
} as unknown as Category;
|
||
}
|
||
|
||
function mkEntry(
|
||
category_id: number,
|
||
year: number,
|
||
month: number,
|
||
amount: number,
|
||
): BudgetEntry {
|
||
return {
|
||
id: category_id * 100 + month,
|
||
category_id,
|
||
year,
|
||
month,
|
||
amount,
|
||
notes: null,
|
||
} as unknown as BudgetEntry;
|
||
}
|
||
|
||
describe.each<[Schema]>([["v2"], ["v1"]])(
|
||
"regression: getBudgetVsActualData [%s]",
|
||
(schema) => {
|
||
it("aggregates leaf budgets under their parent and multiplies by -1 for expenses", async () => {
|
||
const { categories, entries } = budgetFixture(schema);
|
||
|
||
// Stub the 4 parallel selects in order:
|
||
// 1) getAllActiveCategories
|
||
// 2) getBudgetEntriesForYear
|
||
// 3) getActualsByCategoryRange (month)
|
||
// 4) getActualsByCategoryRange (ytd)
|
||
mockSelect.mockImplementation((sql: string) => {
|
||
if (/FROM categories/i.test(sql)) return Promise.resolve(categories);
|
||
if (/FROM budget_entries WHERE year/i.test(sql))
|
||
return Promise.resolve(entries);
|
||
if (/GROUP BY category_id/i.test(sql)) return Promise.resolve([]);
|
||
return Promise.resolve([]);
|
||
});
|
||
|
||
const rows = await getBudgetVsActualData(2026, 3);
|
||
// We expect at least the two leaves to appear. In both schemas the
|
||
// budget sum on the parent = -600 (expenses → sign -1).
|
||
const leafIds = schema === "v2" ? [220, 221] : [1111, 1112];
|
||
const parentId = schema === "v2" ? 22 : 1110;
|
||
|
||
// Collect budgets per row keyed by category_id.
|
||
const budgetById = new Map<number, number>();
|
||
for (const r of rows) {
|
||
budgetById.set(r.category_id, r.monthBudget);
|
||
}
|
||
|
||
// Both leaves get their stored budget × -1.
|
||
expect(budgetById.get(leafIds[0])).toBe(-400);
|
||
expect(budgetById.get(leafIds[1])).toBe(-200);
|
||
// The parent aggregates to -600.
|
||
expect(budgetById.get(parentId)).toBe(-600);
|
||
});
|
||
},
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// dataExportService envelope round-trip parity — the SREF JSON format must
|
||
// remain identical before and after the migration.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function envelopeFixture(schema: Schema): ExportEnvelope {
|
||
const catId = schema === "v2" ? 22 : 1111;
|
||
return {
|
||
export_type: "transactions_with_categories",
|
||
app_version: "0.8.3-test",
|
||
exported_at: "2026-04-20T00:00:00Z",
|
||
data: {
|
||
categories: [
|
||
{
|
||
id: catId,
|
||
name: "Épicerie",
|
||
parent_id: null,
|
||
color: "#000",
|
||
type: "expense",
|
||
is_active: 1,
|
||
is_inputable: 1,
|
||
sort_order: 1,
|
||
i18n_key: null,
|
||
} as unknown as Category,
|
||
],
|
||
suppliers: [],
|
||
keywords: [],
|
||
transactions: [],
|
||
},
|
||
};
|
||
}
|
||
|
||
describe.each<[Schema]>([["v2"], ["v1"]])(
|
||
"regression: SREF envelope round-trip [%s]",
|
||
(schema) => {
|
||
it("serialize → parse returns an equivalent envelope", () => {
|
||
const original = envelopeFixture(schema);
|
||
const serialized = serializeToJson(
|
||
original.export_type,
|
||
original.data,
|
||
original.app_version,
|
||
);
|
||
const { envelope } = parseImportedJson(serialized);
|
||
expect(envelope.export_type).toBe(original.export_type);
|
||
expect(envelope.data.categories).toHaveLength(1);
|
||
expect(envelope.data.categories![0].id).toBe(
|
||
schema === "v2" ? 22 : 1111,
|
||
);
|
||
});
|
||
},
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Split transactions — the parent_transaction_id / is_split columns must be
|
||
// honoured identically after a migration. We exercise them through the
|
||
// export envelope, which is the canonical observable surface.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe.each<[Schema]>([["v2"], ["v1"]])(
|
||
"regression: split transactions survive export [%s]",
|
||
(schema) => {
|
||
it("preserves is_split and parent_transaction_id in the envelope", () => {
|
||
const parentCat = schema === "v2" ? 28 : 1521;
|
||
const leg1Cat = schema === "v2" ? 28 : 1521;
|
||
const leg2Cat = schema === "v2" ? 22 : 1111;
|
||
const envelope: ExportEnvelope = {
|
||
export_type: "transactions_with_categories",
|
||
app_version: "0.8.3-test",
|
||
exported_at: "2026-04-20T00:00:00Z",
|
||
data: {
|
||
categories: [],
|
||
suppliers: [],
|
||
keywords: [],
|
||
transactions: [
|
||
{
|
||
id: 100,
|
||
date: "2026-03-10",
|
||
description: "STM Opus + snack",
|
||
amount: -50,
|
||
category_id: parentCat,
|
||
category_name: null,
|
||
original_description: null,
|
||
notes: null,
|
||
is_manually_categorized: 0,
|
||
is_split: 1,
|
||
parent_transaction_id: null,
|
||
},
|
||
{
|
||
id: 101,
|
||
date: "2026-03-10",
|
||
description: "STM leg",
|
||
amount: -30,
|
||
category_id: leg1Cat,
|
||
category_name: null,
|
||
original_description: null,
|
||
notes: null,
|
||
is_manually_categorized: 1,
|
||
is_split: 0,
|
||
parent_transaction_id: 100,
|
||
},
|
||
{
|
||
id: 102,
|
||
date: "2026-03-10",
|
||
description: "Snack leg",
|
||
amount: -20,
|
||
category_id: leg2Cat,
|
||
category_name: null,
|
||
original_description: null,
|
||
notes: null,
|
||
is_manually_categorized: 1,
|
||
is_split: 0,
|
||
parent_transaction_id: 100,
|
||
},
|
||
],
|
||
},
|
||
};
|
||
const json = serializeToJson(envelope.export_type, envelope.data, envelope.app_version);
|
||
const parsed = parseImportedJson(json).envelope;
|
||
const txs = parsed.data.transactions!;
|
||
expect(txs).toHaveLength(3);
|
||
expect(txs[0].is_split).toBe(1);
|
||
expect(txs[1].parent_transaction_id).toBe(100);
|
||
expect(txs[2].parent_transaction_id).toBe(100);
|
||
// Category ids are schema-specific but never null.
|
||
expect(typeof txs[0].category_id).toBe("number");
|
||
expect(typeof txs[1].category_id).toBe("number");
|
||
expect(typeof txs[2].category_id).toBe("number");
|
||
});
|
||
},
|
||
);
|