Simpl-Resultat/src/__integration__/regression-v2-v1.test.ts
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

386 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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