import { describe, it, expect, beforeEach } from "vitest"; import { computeMigrationPlan, normalizeForMatch, __resetMappingServiceCachesForTests, type ProfileData, type V2CategoryInput, type V2KeywordInput, type V2TransactionInput, type V2SupplierInput, } from "./categoryMappingService"; import { resetTaxonomyCache } from "./categoryTaxonomyService"; beforeEach(() => { resetTaxonomyCache(); __resetMappingServiceCachesForTests(); }); // --------------------------------------------------------------------------- // Fixture helpers — we build just enough of ProfileData per test to stay // readable; everything defaults to empty arrays. // --------------------------------------------------------------------------- function makeProfile(partial: Partial): ProfileData { return { v2Categories: partial.v2Categories ?? [], keywords: partial.keywords ?? [], transactions: partial.transactions ?? [], suppliers: partial.suppliers, }; } function cat(id: number, name: string, parent_id: number | null = null): V2CategoryInput { return { id, name, parent_id }; } function kw(category_id: number, keyword: string): V2KeywordInput { return { category_id, keyword }; } function tx(id: number, description: string, category_id: number | null, supplier_id?: number): V2TransactionInput { return { id, description, category_id, supplier_id: supplier_id ?? null }; } function sup(id: number, name: string): V2SupplierInput { return { id, name }; } // --------------------------------------------------------------------------- // normalizeForMatch // --------------------------------------------------------------------------- describe("normalizeForMatch", () => { it("lowercases, strips accents, and collapses spaces", () => { expect(normalizeForMatch(" Épicerie Régulière ")).toBe("epicerie reguliere"); }); it("handles already-normalized text", () => { expect(normalizeForMatch("stm")).toBe("stm"); }); }); // --------------------------------------------------------------------------- // Custom categories → preserved bucket // --------------------------------------------------------------------------- describe("computeMigrationPlan — preserved (custom)", () => { it("moves a non-seeded v2 category into the preserved bucket", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(9001, "Ma catégorie perso", 2)], }) ); expect(plan.rows).toHaveLength(0); expect(plan.preserved).toHaveLength(1); expect(plan.preserved[0]).toMatchObject({ v2CategoryId: 9001, v2CategoryName: "Ma catégorie perso", v1TargetId: null, confidence: "none", reason: "preserved", }); }); it("ignores structural v2 parents (1–6)", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(1, "Revenus"), cat(2, "Dépenses récurrentes")], }) ); expect(plan.rows).toHaveLength(0); expect(plan.preserved).toHaveLength(0); expect(plan.unresolved).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // Pass 1 — keyword match // --------------------------------------------------------------------------- describe("computeMigrationPlan — Pass 1 (keyword)", () => { it("maps Transport en commun → 1521 (Autobus & métro) via STM keyword", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(28, "Transport en commun", 2)], keywords: [kw(28, "STM")], }) ); expect(plan.rows).toHaveLength(1); expect(plan.rows[0]).toMatchObject({ v2CategoryId: 28, v1TargetId: 1521, v1TargetName: "Autobus & métro", confidence: "high", reason: "keyword", }); }); it("maps Voiture → 1512 (Essence) via SHELL keyword", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(40, "Voiture", 3)], keywords: [kw(40, "SHELL")], }) ); expect(plan.rows[0]).toMatchObject({ v1TargetId: 1512, confidence: "high", reason: "keyword", }); }); it("picks the first matching KEYWORD_TO_V1 rule when multiple apply", () => { // SAAQ (1514) wins over any later rule because the list order of the // user's keywords drives it. const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(40, "Voiture", 3)], keywords: [kw(40, "SAAQ"), kw(40, "SHELL")], }) ); expect(plan.rows[0].v1TargetId).toBe(1514); }); }); // --------------------------------------------------------------------------- // Pass 2 — supplier propagation // --------------------------------------------------------------------------- describe("computeMigrationPlan — Pass 2 (supplier)", () => { it("propagates via a transaction description when no v2 keyword matches", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(28, "Transport en commun", 2)], // No keyword rows for cat 28. transactions: [tx(1, "PAIEMENT STM CARTE OPUS", 28)], }) ); expect(plan.rows[0]).toMatchObject({ v1TargetId: 1521, confidence: "medium", reason: "supplier", }); }); it("propagates via a supplier name when the description has no hit", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(47, "Voyage", 3)], transactions: [tx(1, "CARTE 1234", 47, 42)], suppliers: [sup(42, "Hilton Montreal")], }) ); expect(plan.rows[0]).toMatchObject({ v1TargetId: 1533, confidence: "medium", reason: "supplier", }); }); }); // --------------------------------------------------------------------------- // Pass 3 — default fallback // --------------------------------------------------------------------------- describe("computeMigrationPlan — Pass 3 (default)", () => { it("maps Loyer (20) → 1211 with high confidence (direct)", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(20, "Loyer", 2)], }) ); expect(plan.rows[0]).toMatchObject({ v1TargetId: 1211, v1TargetName: "Loyer", confidence: "high", reason: "default", }); }); it("maps Restaurant (24) → 1121 with medium confidence", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(24, "Restaurant", 2)], }) ); expect(plan.rows[0]).toMatchObject({ v1TargetId: 1121, confidence: "medium", reason: "default", }); }); it("exposes splits for Transport en commun (28) when no keyword/supplier resolves it", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(28, "Transport en commun", 2)], }) ); expect(plan.rows[0].splits).toEqual([ { v1TargetId: 1521, v1TargetName: "Autobus & métro" }, { v1TargetId: 1522, v1TargetName: "Train de banlieue" }, ]); expect(plan.rows[0].confidence).toBe("medium"); expect(plan.rows[0].reason).toBe("default"); // Primary target is the "reste → X par défaut" (1521 per rationale). expect(plan.rows[0].v1TargetId).toBe(1521); }); it("exposes 4-way splits for Voiture (40)", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(40, "Voiture", 3)], }) ); expect(plan.rows[0].splits?.map((s) => s.v1TargetId)).toEqual([1512, 1513, 1514, 1515]); expect(plan.rows[0].v1TargetId).toBe(1513); // entretien par défaut expect(plan.rows[0].confidence).toBe("low"); }); }); // --------------------------------------------------------------------------- // Pass 4 — review // --------------------------------------------------------------------------- describe("computeMigrationPlan — Pass 4 (review)", () => { it("flags Projets (73) for review (no direct v1 equivalent)", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(73, "Projets", 6)], }) ); expect(plan.rows[0]).toMatchObject({ v1TargetId: null, v1TargetName: null, confidence: "none", reason: "review", }); expect(plan.unresolved).toHaveLength(1); }); it("escapes Pass 4 for Projets when a CLAUDE.AI keyword is present", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(73, "Projets", 6)], keywords: [kw(73, "CLAUDE.AI")], }) ); expect(plan.rows[0]).toMatchObject({ v1TargetId: 1734, // Abonnements professionnels confidence: "high", reason: "keyword", }); }); }); // --------------------------------------------------------------------------- // Stats & aggregation // --------------------------------------------------------------------------- describe("computeMigrationPlan — stats", () => { it("reports per-confidence counts matching the rows", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [ cat(20, "Loyer", 2), // high (default direct) cat(24, "Restaurant", 2), // medium (default) cat(40, "Voiture", 3), // low (split, low confidence) cat(73, "Projets", 6), // none ], }) ); expect(plan.stats).toEqual({ total: 4, high: 1, medium: 1, low: 1, none: 1, }); expect(plan.unresolved).toHaveLength(1); expect(plan.unresolved[0].v2CategoryId).toBe(73); }); it("returns empty structures for an empty profile", () => { const plan = computeMigrationPlan(makeProfile({})); expect(plan.rows).toEqual([]); expect(plan.preserved).toEqual([]); expect(plan.unresolved).toEqual([]); expect(plan.stats).toEqual({ total: 0, high: 0, medium: 0, low: 0, none: 0 }); }); it("handles a mixed profile with seeded + custom categories in one call", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [ cat(1, "Revenus"), // structural → skipped cat(22, "Épicerie", 2), // high default cat(9002, "Dépenses projet X", 3), // custom → preserved ], }) ); expect(plan.rows).toHaveLength(1); expect(plan.rows[0].v2CategoryId).toBe(22); expect(plan.preserved).toHaveLength(1); expect(plan.preserved[0].v2CategoryId).toBe(9002); }); }); // --------------------------------------------------------------------------- // Pass priority — keyword beats default // --------------------------------------------------------------------------- describe("computeMigrationPlan — pass priority", () => { it("Pass 1 (keyword) wins over Pass 3 (default) on split categories", () => { // Transport en commun (28) default is 1521; with a GARE CENTRALE keyword // Pass 1 should push to 1522 (Train) instead. const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(28, "Transport en commun", 2)], keywords: [kw(28, "GARE CENTRALE")], }) ); expect(plan.rows[0]).toMatchObject({ v1TargetId: 1522, confidence: "high", reason: "keyword", }); // Splits are NOT exposed when Pass 1 resolves the row — only Pass 3 // attaches them. expect(plan.rows[0].splits).toBeUndefined(); }); it("Pass 2 (supplier) wins over Pass 3 (default)", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(40, "Voiture", 3)], // No v2 keyword row on cat 40 — description drives it. transactions: [tx(1, "PETRO-CANADA #1234 MTL", 40)], }) ); expect(plan.rows[0]).toMatchObject({ v1TargetId: 1512, // Essence confidence: "medium", reason: "supplier", }); }); }); // --------------------------------------------------------------------------- // DEFAULT_MAPPINGS exhaustive coverage — one expectation per seeded v2 id // --------------------------------------------------------------------------- describe("computeMigrationPlan — DEFAULT_MAPPINGS exhaustive", () => { // Each entry: [v2Id, v2Name, expectedV1TargetId|null, expectedBadge, isSplit] const CASES: Array<[number, string, number | null, "high" | "medium" | "low" | "none", boolean]> = [ // Revenus [10, "Paie", 1011, "high", false], [11, "Autres revenus", 1090, "high", false], // Dépenses récurrentes [20, "Loyer", 1211, "high", false], [21, "Électricité", 1221, "high", false], [22, "Épicerie", 1111, "high", false], [23, "Dons", 1931, "high", false], [24, "Restaurant", 1121, "medium", false], [25, "Frais bancaires", 1911, "high", false], [26, "Jeux Films & Livres", 1710, "low", true], [27, "Abonnements Musique", 1714, "high", false], [28, "Transport en commun", 1521, "medium", true], [29, "Internet & Télécom", 1231, "medium", true], [30, "Animaux", 1751, "medium", false], [31, "Assurances", 1250, "low", true], [32, "Pharmacie", 1611, "high", false], [33, "Taxes municipales", 1213, "high", false], // Dépenses ponctuelles [40, "Voiture", 1513, "low", true], [41, "Amazon", 1946, "medium", false], [42, "Électroniques", 1312, "low", false], [43, "Alcool", 1810, "high", false], [44, "Cadeaux", 1940, "high", false], [45, "Vêtements", 1410, "medium", false], [46, "CPA", 1932, "high", false], [47, "Voyage", 1533, "medium", true], [48, "Sports & Plein air", 1722, "medium", true], [49, "Spectacles & sorties", 1711, "high", false], // Maison [50, "Hypothèque", 1212, "high", false], [51, "Achats maison", 1243, "medium", false], [52, "Entretien maison", 1241, "high", false], [53, "Électroménagers & Meubles", 1311, "medium", true], [54, "Outils", 1243, "high", false], // Placements [60, "Placements", 1964, "medium", false], [61, "Transferts internes", 1980, "high", false], // Autres [70, "Impôts", 1922, "medium", false], [71, "Paiement CC", 1971, "high", false], [72, "Retrait cash", 1945, "high", false], [73, "Projets", null, "none", false], // Assurances children (already split in some profiles) [310, "Assurance-auto", 1516, "high", false], [311, "Assurance-habitation", 1250, "high", false], [312, "Assurance-vie", 1630, "high", false], ]; it.each(CASES)( "v2 id %i (%s) → v1 target %s with %s badge", (v2Id, v2Name, expectedV1, expectedBadge, isSplit) => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(v2Id, v2Name, 2)] }) ); expect(plan.rows).toHaveLength(1); const row = plan.rows[0]; expect(row.v2CategoryId).toBe(v2Id); expect(row.v1TargetId).toBe(expectedV1); expect(row.confidence).toBe(expectedBadge); if (isSplit) { expect(row.splits).toBeDefined(); expect(row.splits!.length).toBeGreaterThan(1); } } ); }); // --------------------------------------------------------------------------- // Keyword → V1 rules exhaustive coverage // --------------------------------------------------------------------------- describe("computeMigrationPlan — KEYWORD_TO_V1 rules", () => { // Each entry: [keyword, expected v1 id] — one test per rule so regressions // on the mapping table fail individually with a clear signal. const KW_RULES: Array<[string, number]> = [ // Jeux/Films/Livres ["STEAMGAMES", 1712], ["PLAYSTATION", 1712], ["NINTENDO", 1712], ["PRIMEVIDEO", 1713], ["RENAUD-BRAY", 1741], ["CINEMA DU PARC", 1711], ["LEGO", 1715], // Transport ["STM", 1521], ["GARE MONT-SAINT", 1522], ["GARE SAINT-HUBERT", 1522], ["GARE CENTRALE", 1522], ["REM", 1522], // Voiture ["SHELL", 1512], ["ESSO", 1512], ["ULTRAMAR", 1512], ["PETRO-CANADA", 1512], ["CREVIER", 1512], ["SAAQ", 1514], // Assurances ["BELAIR", 1250], ["PRYSM", 1250], ["INS/ASS", 1630], // Voyage ["NORWEGIAN CRUISE", 1533], ["AEROPORTS DE MONTREAL", 1531], ["HILTON", 1533], // Sports ["SEPAQ", 1723], ["BLOC SHOP", 1723], ["MOUNTAIN EQUIPMENT", 1722], ["DECATHLON", 1722], ["LA CORDEE", 1722], ["PHYSIOACTIF", 1615], // Meubles ["TANGUAY", 1311], ["BOUCLAIR", 1311], // Projets ["CLAUDE.AI", 1734], ["NAME-CHEAP", 1734], ]; it.each(KW_RULES)( "keyword %s resolves to v1 leaf %i with high confidence", (keyword, expectedV1) => { // Attach the keyword to any seeded v2 cat id — the keyword rules win // regardless of the host v2 cat (that's by design of Pass 1). const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(73, "Projets", 6)], keywords: [kw(73, keyword)], }) ); expect(plan.rows[0].v1TargetId).toBe(expectedV1); expect(plan.rows[0].confidence).toBe("high"); expect(plan.rows[0].reason).toBe("keyword"); } ); it("propagates keyword match even when the raw v2 keyword has mixed case and spaces", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(28, "Transport en commun", 2)], keywords: [kw(28, " Gare Centrale ")], }) ); expect(plan.rows[0].v1TargetId).toBe(1522); }); it("matches a keyword that literally names a v1 leaf (Loyer → 1211)", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(20, "Loyer", 2)], keywords: [kw(20, "loyer")], }) ); // With a matching leaf-name keyword, Pass 1 resolves with high confidence // (instead of falling through to Pass 3's high-confidence default; both // resolve to 1211, but the reason must be "keyword"). expect(plan.rows[0].v1TargetId).toBe(1211); expect(plan.rows[0].reason).toBe("keyword"); }); }); // --------------------------------------------------------------------------- // Pass 2 — deeper coverage // --------------------------------------------------------------------------- describe("computeMigrationPlan — Pass 2 deeper cases", () => { it("falls through to Pass 3 when neither keyword nor supplier matches anything", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(40, "Voiture", 3)], transactions: [tx(1, "SOMETHING UNRELATED", 40)], }) ); // Default split applies: primary = 1513 (entretien). expect(plan.rows[0]).toMatchObject({ v1TargetId: 1513, confidence: "low", reason: "default", }); expect(plan.rows[0].splits?.map((s) => s.v1TargetId)).toEqual([1512, 1513, 1514, 1515]); }); it("ignores suppliers that are in the map but not attached to any tx", () => { // Pass 2 iterates over transactions; a loose supplier list is irrelevant. const plan = computeMigrationPlan( makeProfile({ v2Categories: [cat(40, "Voiture", 3)], transactions: [tx(1, "MISC", 40)], suppliers: [{ id: 777, name: "Shell" }], }) ); // Without a tx.supplier_id pointing at 777, the Shell rule never fires. expect(plan.rows[0].reason).toBe("default"); }); }); // --------------------------------------------------------------------------- // Preserved — multiple custom categories // --------------------------------------------------------------------------- describe("computeMigrationPlan — preserved (multiple)", () => { it("collects all 3 custom categories under preserved[] with reason='preserved'", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [ cat(9001, "Projet maison", 3), cat(9002, "Activités enfants", 3), cat(9003, "Hobby moto", 3), ], }) ); expect(plan.preserved).toHaveLength(3); for (const row of plan.preserved) { expect(row.reason).toBe("preserved"); expect(row.v1TargetId).toBeNull(); expect(row.confidence).toBe("none"); } }); it("keeps seeded + custom in different buckets when both are present", () => { const plan = computeMigrationPlan( makeProfile({ v2Categories: [ cat(22, "Épicerie", 2), cat(9999, "Projet perso", 3), ], }) ); expect(plan.rows.map((r) => r.v2CategoryId)).toEqual([22]); expect(plan.preserved.map((r) => r.v2CategoryId)).toEqual([9999]); }); });