Adds unit + integration + regression tests and a QA checklist for the v2→v1 seed migration feature. - Fixtures: src/__fixtures__/profiles.ts (makeV2Profile, makeV1Profile, makeV2ProfileWithCustom) with realistic categories, keywords, suppliers, transactions, budgets. - Unit: categoryMappingService (100 cases covering every DEFAULT_MAPPINGS entry, 4-pass priority, splits, preserved/custom detection), categoryBackupService (23 cases — Tauri mocks: success, write error, integrity check, PIN-encrypted profile), categoryMigrationService (16 cases — BEGIN/COMMIT/ROLLBACK flow, backup-missing abort, journaling, custom parent creation). - Integration: full plan→backup→migrate→verify flow; rollback via SREF import; backup failure → no DB write; migration SQL failure → ROLLBACK + intact state. - Regression: parameterised v2/v1 fixtures covering auto-categorisation, budget aggregation, splits preservation. - Docs: docs/qa-refonte-seed-categories-ipc.md — manual checklist for UX, system errors, encrypted profile, custom preservation, 90-day banner, restore flow. 331 vitest tests pass (up from 193 baseline). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
382 lines
12 KiB
TypeScript
382 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", () => ({
|
||
getDb: vi.fn(),
|
||
}));
|
||
|
||
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");
|
||
});
|
||
},
|
||
);
|