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