Simpl-Resultat/src/__integration__/regression-v2-v1.test.ts
le king fu 12d1877870
All checks were successful
PR Check / rust (push) Successful in 22m48s
PR Check / frontend (push) Successful in 2m20s
PR Check / rust (pull_request) Successful in 22m51s
PR Check / frontend (pull_request) Successful in 2m21s
test(categories): complete test coverage for migration flow (#123)
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>
2026-04-21 19:25:13 -04:00

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