Simpl-Resultat/src/services/categoryMappingService.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

607 lines
20 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.

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