diff --git a/docs/qa-refonte-seed-categories-ipc.md b/docs/qa-refonte-seed-categories-ipc.md new file mode 100644 index 0000000..e49480d --- /dev/null +++ b/docs/qa-refonte-seed-categories-ipc.md @@ -0,0 +1,141 @@ +# QA — Refonte seed catégories IPC + +Checklist manuelle pour valider le flow complet de migration v2→v1 (spec `spec-refonte-seed-categories-ipc`). À dérouler avant chaque release touchant au module catégories. + +## Prérequis + +- [ ] Profil de test v2 avec au moins : 10+ catégories seedées, 2-3 catégories custom, 50+ transactions réparties, quelques suppliers et keywords, un budget actif. +- [ ] Profil de test v1 (nouveau profil créé après #115) pour les tests de régression. +- [ ] Variante profil v2 **avec PIN activé** (profil chiffré). + +--- + +## 1. Pré-migration (découverte) + +- [ ] Ouvrir l'app sur un profil v2 → bannière dashboard "Découvrez la nouvelle structure" (#118) visible en haut. +- [ ] Cliquer CTA bannière → redirige vers `/settings/categories/standard` (#117). +- [ ] Dismiss la bannière → disparaît immédiatement, reste dismiss après redémarrage de l'app. +- [ ] Créer un nouveau profil v1 (ou charger le profil v1 de test) → bannière **pas** affichée. + +## 2. Page Guide des catégories standard (#117) + +- [ ] Arbre affiche les 3 niveaux avec les noms traduits (FR et EN). +- [ ] Switcher EN → les noms basculent, l'arbre conserve son état d'expansion. +- [ ] Tooltip au hover d'un nœud affiche `i18n_key`, `type`, `id`. +- [ ] Compteur "N catégories" en haut correspond au nombre total de leaves. +- [ ] Recherche full-text filtre les noms correctement (accent-insensitive). +- [ ] Bouton Print → impression/PDF avec arbre entièrement déplié et toolbar masquée. +- [ ] Lien "Voir les catégories standard" dans Settings > Catégories → même page. + +## 3. Page de migration — Étape 1 (Discover) + +- [ ] Depuis Settings > Catégories sur un profil v2, cliquer "Migrer vers la nouvelle structure" → `/settings/categories/migrate`. +- [ ] Étape 1 affiche l'arbre v1 (read-only, comme la page Guide). +- [ ] Bouton Next passe à l'étape 2. + +## 4. Page de migration — Étape 2 (Simulate) + +- [ ] Table 3 colonnes : v2 source | confidence badge | cible v1 + action. +- [ ] Badges corrects : 🟢 haute (keyword), 🔵 moyenne (supplier), 🟠 basse (default), 🔴 aucune (review). +- [ ] Stats summary en haut : total / high / medium / low / none. +- [ ] Section "Catégories personnalisées préservées" liste les custom du profil (si présentes). +- [ ] Cliquer une ligne 🟠 ou 🔴 → panneau latéral "Transactions impactées" affiche jusqu'à 50 transactions liées. +- [ ] Pour une ligne unresolved, ouvrir le picker de cible → sélectionner un leaf v1 → badge passe à ✅ résolu. +- [ ] Bouton Next **disabled** tant qu'il reste des lignes unresolved. +- [ ] Résoudre toutes les unresolved → Next devient actif. + +## 5. Page de migration — Étape 3 (Consent) + +- [ ] Checklist obligatoire : "J'ai compris…", "Je consens…", "Je sauvegarde avant…". +- [ ] Bouton Apply disabled tant que la checklist n'est pas complète. +- [ ] Sur profil avec PIN, champ PIN demandé. +- [ ] Apply déclenche : loader 4 sous-étapes (1. Sauvegarde, 2. Insertion v1, 3. Remap transactions/budgets, 4. Cleanup v2). + +## 6. Migration — cas nominal + +- [ ] Après Apply, écran de succès affiche : + - [ ] Chemin du backup SREF (ex: `~/Documents/Simpl-Resultat/backups/MonProfil_avant-migration-2026-04-21T12-34-56.sref`). + - [ ] Récap : nb v1 insérées, nb transactions updatées, nb budgets updatées, nb keywords updatés, nb v2 désactivées, nb custom préservées. + - [ ] Liens vers Dashboard / Voir les catégories. +- [ ] Retour au Dashboard → bannière dashboard #118 disparue. +- [ ] Settings > Catégories → bannière "Sauvegarde disponible" (#122) visible (90 jours). +- [ ] Page Catégories → affiche la structure v1. + +## 7. Migration — échec backup + +Simuler : rendre le dossier `~/Documents/Simpl-Resultat/backups/` non-writable (Linux : `chmod -w`, Windows : permissions lecture seule) **avant** de lancer Apply. + +- [ ] Apply échoue à l'étape 1 (Sauvegarde). +- [ ] Message d'erreur clair : "Impossible de créer la sauvegarde. Aucune modification n'a été effectuée." +- [ ] **Aucune écriture BDD** : via outil SQLite, vérifier que `categories`, `transactions`, `budgets`, `keywords` sont strictement identiques avant/après. +- [ ] Flag `categories_schema_version` reste à `v2`. +- [ ] Bouton retry dispo ; rétablir les droits d'écriture, relancer → succès. + +## 8. Migration — échec SQL (rollback) + +Plus difficile à reproduire ; le chemin testé unitairement est le `ROLLBACK` déclenché si un `UPDATE` casse une contrainte FK/UNIQUE. Tester en injectant artificiellement une contrainte (ex: DB corrompue) si un outil le permet, sinon revue de code suffit. La checklist minimale : + +- [ ] Si un échec est simulé, écran d'erreur affiche "La migration a échoué. Vos données n'ont pas été modifiées. Votre sauvegarde est disponible à ." +- [ ] BDD intacte (même check qu'au point 7). +- [ ] Backup SREF créé (toujours disponible sur le disque). + +## 9. Bannière post-migration (#122) — 90 jours + +- [ ] Migration récente → bannière Settings > Catégories visible. +- [ ] Date d'expiration affichée = timestamp migration + 90 jours. +- [ ] Cliquer "Fermer" → bannière disparaît, flag `categories_migration_banner_dismissed=1`, ne réapparaît plus après redémarrage. +- [ ] Après 90 jours (avancer l'horloge système ou manipuler `last_categories_migration.timestamp` dans la DB) → bannière **plus** affichée, mais entrée permanente "Rétablir une sauvegarde" reste dispo dans Settings. + +## 10. Rétablir la sauvegarde (#122) + +- [ ] Cliquer "Rétablir la sauvegarde" depuis bannière ou depuis l'entrée permanente. +- [ ] Modale s'ouvre : chemin backup affiché, texte de warning, boutons Annuler / Rétablir (rouge). +- [ ] Sur profil chiffré (PIN), champ PIN demandé. +- [ ] Simuler fichier manquant : renommer/déplacer le `.sref` avant de cliquer Rétablir → erreur claire + file picker de secours. +- [ ] Choisir le fichier via le picker → la restauration procède. +- [ ] Après succès : + - [ ] Toast / message succès. + - [ ] L'app recharge, catégories v2 à nouveau actives. + - [ ] `categories_schema_version` revient à `v2`. + - [ ] `last_categories_migration.reverted_at` renseigné (ISO string). + - [ ] Transactions, budgets, keywords correspondent à l'état pré-migration. +- [ ] Annuler la modale → aucune modification. + +## 11. Profil avec catégories custom + +- [ ] Migration préserve les 3 custom sous un parent "Catégories personnalisées (migration)" (id 2000). +- [ ] Les transactions liées aux custom conservent leur `category_id`. +- [ ] Les keywords liés aux custom conservent leur `category_id`. + +## 12. Profil sans catégories custom + +- [ ] Migration **ne crée pas** de parent "Catégories personnalisées (migration)" (section `preserved` vide → skip). + +## 13. Régression — fonctionnalités auxiliaires + +Tester sur profil v2 ET profil v1 pour vérifier qu'aucune fonctionnalité n'a cassé : + +- [ ] **Auto-catégorisation CSV** : importer un CSV de test → catégorisation par keyword/supplier fonctionne identiquement. +- [ ] **Budget vs Actuel** : agrégation parent/enfant cohérente, montants corrects. +- [ ] **Splits** : transactions multi-catégories préservent leurs ratios. +- [ ] **Export/Import SREF** : export d'un profil v1 → re-import dans un nouveau profil → structure identique. +- [ ] **UI CategoryTree et CategoryCombobox** : rendent correctement l'arbre v1 et v2, pas d'affichage cassé ou mélangé. +- [ ] **Rapports** : aucune régression sur les graphiques catégorie (tendances, répartition). + +## 14. i18n + +- [ ] Toutes les nouvelles pages (Guide, Migration, bannières, modale) supportent FR et EN. +- [ ] Aucune chaîne en dur visible à l'écran. +- [ ] Bascule live FR↔EN ne casse pas l'état local des pages. + +--- + +## Tests automatisés équivalents + +Pour référence, les chemins de tests automatisés qui couvrent partiellement cette checklist : + +- Unitaires : `src/services/categoryMappingService.test.ts` (100 tests), `src/services/categoryBackupService.test.ts` (23), `src/services/categoryMigrationService.test.ts` (16), `src/services/categoryTaxonomyService.test.ts` (15), `src/services/categoryRestoreService.test.ts` (12), `src/hooks/useCategoryMigration.test.ts` (13). +- Intégration : `src/__integration__/category-migration.test.ts` (flow + échecs + rollback). +- Régression : `src/__integration__/regression-v2-v1.test.ts` (auto-catégorisation, budget agrégation, splits paramétrés v2/v1). + +Cette QA manuelle couvre les dimensions UX, erreurs système (permissions, fichiers manquants), profil chiffré et régression visuelle qui ne sont pas automatisables actuellement. diff --git a/src/__fixtures__/profiles.ts b/src/__fixtures__/profiles.ts new file mode 100644 index 0000000..47d93a6 --- /dev/null +++ b/src/__fixtures__/profiles.ts @@ -0,0 +1,180 @@ +/** + * Profile fixtures for the v2 → v1 categories migration tests. + * + * These fixtures are intentionally loose — they match the minimal shapes + * consumed by categoryMappingService.ProfileData and the broader migration + * test helpers (budget/categorization regression). They are *not* a mock of + * the full Tauri DB layer; tests that need DB access still mock `getDb`. + * + * Three flavours are provided: + * - `makeV2Profile()` realistic v2-seeded profile (~30 cats) + * - `makeV1Profile()` same user data but already on v1 taxonomy + * - `makeV2ProfileWithCustom()` v2 profile with 3 user-created categories + */ +import type { + ProfileData, + V2CategoryInput, + V2KeywordInput, + V2TransactionInput, + V2SupplierInput, +} from "../services/categoryMappingService"; + +// --------------------------------------------------------------------------- +// v2 seed (excerpt matching DEFAULT_MAPPINGS keys) +// --------------------------------------------------------------------------- + +const V2_STRUCTURAL_PARENTS: V2CategoryInput[] = [ + { id: 1, name: "Revenus", parent_id: null }, + { id: 2, name: "Dépenses récurrentes", parent_id: null }, + { id: 3, name: "Dépenses ponctuelles", parent_id: null }, + { id: 4, name: "Maison", parent_id: null }, + { id: 5, name: "Placements", parent_id: null }, + { id: 6, name: "Autres", parent_id: null }, +]; + +const V2_SEEDED_CATS: V2CategoryInput[] = [ + // Revenus + { id: 10, name: "Paie", parent_id: 1 }, + { id: 11, name: "Autres revenus", parent_id: 1 }, + // Dépenses récurrentes + { id: 20, name: "Loyer", parent_id: 2 }, + { id: 21, name: "Électricité", parent_id: 2 }, + { id: 22, name: "Épicerie", parent_id: 2 }, + { id: 23, name: "Dons", parent_id: 2 }, + { id: 24, name: "Restaurant", parent_id: 2 }, + { id: 25, name: "Frais bancaires", parent_id: 2 }, + { id: 26, name: "Jeux, Films & Livres", parent_id: 2 }, + { id: 27, name: "Abonnements Musique", parent_id: 2 }, + { id: 28, name: "Transport en commun", parent_id: 2 }, + { id: 29, name: "Internet & Télécom", parent_id: 2 }, + { id: 30, name: "Animaux", parent_id: 2 }, + { id: 31, name: "Assurances", parent_id: 2 }, + { id: 32, name: "Pharmacie", parent_id: 2 }, + // Dépenses ponctuelles + { id: 40, name: "Voiture", parent_id: 3 }, + { id: 41, name: "Amazon", parent_id: 3 }, + { id: 42, name: "Électroniques", parent_id: 3 }, + { id: 43, name: "Alcool", parent_id: 3 }, + { id: 44, name: "Cadeaux", parent_id: 3 }, + { id: 45, name: "Vêtements", parent_id: 3 }, + { id: 47, name: "Voyage", parent_id: 3 }, + { id: 48, name: "Sports & Plein air", parent_id: 3 }, + { id: 49, name: "Spectacles & sorties", parent_id: 3 }, + // Maison + { id: 50, name: "Hypothèque", parent_id: 4 }, + { id: 51, name: "Achats maison", parent_id: 4 }, + { id: 52, name: "Entretien maison", parent_id: 4 }, + { id: 53, name: "Électroménagers & Meubles", parent_id: 4 }, + // Autres + { id: 70, name: "Impôts", parent_id: 6 }, + { id: 71, name: "Paiement CC", parent_id: 6 }, + { id: 72, name: "Retrait cash", parent_id: 6 }, +]; + +const V2_KEYWORDS: V2KeywordInput[] = [ + { id: 101, keyword: "PAIE DEPOT", category_id: 10 }, + { id: 102, keyword: "IGA", category_id: 22 }, + { id: 103, keyword: "METRO PLUS", category_id: 22 }, + { id: 104, keyword: "STM", category_id: 28 }, + { id: 105, keyword: "SHELL", category_id: 40 }, + { id: 106, keyword: "NETFLIX", category_id: 26 }, + { id: 107, keyword: "PRIMEVIDEO", category_id: 26 }, + { id: 108, keyword: "AMAZON", category_id: 41 }, +]; + +const V2_SUPPLIERS: V2SupplierInput[] = [ + { id: 501, name: "STM" }, + { id: 502, name: "Shell Canada" }, + { id: 503, name: "Hilton Montreal" }, + { id: 504, name: "IGA" }, + { id: 505, name: "Hydro-Québec" }, +]; + +const V2_TRANSACTIONS: V2TransactionInput[] = [ + { id: 1, description: "DEPOT PAIE MAX", category_id: 10 }, + { id: 2, description: "IGA #5555", category_id: 22, supplier_id: 504 }, + { id: 3, description: "SHELL #231 LAVAL", category_id: 40, supplier_id: 502 }, + { id: 4, description: "STM CARTE OPUS", category_id: 28, supplier_id: 501 }, + { id: 5, description: "HYDRO-QUEBEC FACTURE", category_id: 21, supplier_id: 505 }, + { id: 6, description: "NETFLIX.COM", category_id: 26 }, + { id: 7, description: "HILTON SEATTLE", category_id: 47, supplier_id: 503 }, + { id: 8, description: "AMAZON.CA *XYZ", category_id: 41 }, +]; + +// --------------------------------------------------------------------------- +// Public builders +// --------------------------------------------------------------------------- + +export function makeV2Profile(): ProfileData { + return { + v2Categories: [...V2_STRUCTURAL_PARENTS, ...V2_SEEDED_CATS], + keywords: [...V2_KEYWORDS], + transactions: [...V2_TRANSACTIONS], + suppliers: [...V2_SUPPLIERS], + }; +} + +export function makeV2ProfileWithCustom(): ProfileData { + const base = makeV2Profile(); + const custom: V2CategoryInput[] = [ + { id: 9001, name: "Projet maison", parent_id: 3 }, + { id: 9002, name: "Activités enfants", parent_id: 3 }, + { id: 9003, name: "Hobby moto", parent_id: 3 }, + ]; + return { + ...base, + v2Categories: [...base.v2Categories, ...custom], + }; +} + +/** + * v1 "profile" — same user data after the migration has been applied. + * Category ids follow the v1 taxonomy (`categoryTaxonomyV1.json`) and the + * DEFAULT_MAPPINGS table from categoryMappingService. Useful for parameterised + * regression tests where the behaviour must be identical on v1 and v2. + */ +export function makeV1Profile(): ProfileData { + // v1 categories in this shape are just for iteration — the SQL write-over + // path runs the real v1 taxonomy. We only need a few leaves for keyword / + // budget tests that don't actually inspect the full tree. + const v1Cats: V2CategoryInput[] = [ + { id: 1011, name: "Paie régulière", parent_id: 1010 }, + { id: 1090, name: "Autres revenus", parent_id: 1000 }, + { id: 1111, name: "Épicerie régulière", parent_id: 1110 }, + { id: 1121, name: "Restaurants & sorties", parent_id: 1120 }, + { id: 1211, name: "Loyer", parent_id: 1210 }, + { id: 1221, name: "Électricité", parent_id: 1220 }, + { id: 1521, name: "Autobus & métro", parent_id: 1520 }, + { id: 1512, name: "Essence", parent_id: 1510 }, + { id: 1713, name: "Abonnements streaming", parent_id: 1710 }, + { id: 1533, name: "Hébergement en voyage", parent_id: 1530 }, + { id: 1946, name: "Achats divers", parent_id: 1940 }, + ]; + // Keywords/suppliers/transactions carry v1 category_id values now. + const v1Keywords: V2KeywordInput[] = [ + { id: 101, keyword: "PAIE DEPOT", category_id: 1011 }, + { id: 102, keyword: "IGA", category_id: 1111 }, + { id: 103, keyword: "METRO PLUS", category_id: 1111 }, + { id: 104, keyword: "STM", category_id: 1521 }, + { id: 105, keyword: "SHELL", category_id: 1512 }, + { id: 106, keyword: "NETFLIX", category_id: 1713 }, + { id: 107, keyword: "PRIMEVIDEO", category_id: 1713 }, + { id: 108, keyword: "AMAZON", category_id: 1946 }, + ]; + const v1Tx: V2TransactionInput[] = [ + { id: 1, description: "DEPOT PAIE MAX", category_id: 1011 }, + { id: 2, description: "IGA #5555", category_id: 1111, supplier_id: 504 }, + { id: 3, description: "SHELL #231 LAVAL", category_id: 1512, supplier_id: 502 }, + { id: 4, description: "STM CARTE OPUS", category_id: 1521, supplier_id: 501 }, + { id: 5, description: "HYDRO-QUEBEC FACTURE", category_id: 1221, supplier_id: 505 }, + { id: 6, description: "NETFLIX.COM", category_id: 1713 }, + { id: 7, description: "HILTON SEATTLE", category_id: 1533, supplier_id: 503 }, + { id: 8, description: "AMAZON.CA *XYZ", category_id: 1946 }, + ]; + return { + v2Categories: v1Cats, + keywords: v1Keywords, + transactions: v1Tx, + suppliers: [...V2_SUPPLIERS], + }; +} diff --git a/src/__integration__/category-migration.test.ts b/src/__integration__/category-migration.test.ts new file mode 100644 index 0000000..1e79aa9 --- /dev/null +++ b/src/__integration__/category-migration.test.ts @@ -0,0 +1,332 @@ +/** + * Integration-flavoured tests for the v2 → v1 categories migration. + * + * We intentionally do NOT spin up a real sqlite: the `tauri-plugin-sql` bridge + * is only available inside the Tauri WebView. Instead we use a carefully + * crafted in-memory fake DB that: + * - records every SQL statement (so we can assert ordering), + * - simulates `rowsAffected` for UPDATEs, + * - optionally fails on a matched SQL to simulate a mid-run error. + * + * The goal is to catch cross-service ordering bugs and the plan→backup→migrate + * sequencing required by the spec (ADR: pre-migration backup is a hard gate). + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../services/db", () => ({ + getDb: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +vi.mock("@tauri-apps/api/app", () => ({ + getVersion: vi.fn(async () => "0.8.3-test"), +})); + +// dataExportService helpers are used by both the backup service and the +// restore service. We mock the "DB-reading" ones and keep the parsers real. +vi.mock("../services/dataExportService", async () => { + const actual = await vi.importActual< + typeof import("../services/dataExportService") + >("../services/dataExportService"); + return { + ...actual, + getExportCategories: vi.fn(async () => []), + getExportSuppliers: vi.fn(async () => []), + getExportKeywords: vi.fn(async () => []), + getExportTransactions: vi.fn(async () => []), + importTransactionsWithCategories: vi.fn(async () => undefined), + }; +}); + +import { getDb } from "../services/db"; +import { invoke } from "@tauri-apps/api/core"; +import { computeMigrationPlan } from "../services/categoryMappingService"; +import { createPreMigrationBackup } from "../services/categoryBackupService"; +import { applyMigration } from "../services/categoryMigrationService"; +import { restoreFromBackup } from "../services/categoryRestoreService"; +import { importTransactionsWithCategories } from "../services/dataExportService"; +import { makeV2Profile, makeV2ProfileWithCustom } from "../__fixtures__/profiles"; +import type { Profile } from "../services/profileService"; + +const mockInvoke = vi.mocked(invoke); + +interface FakeDb { + calls: Array<{ sql: string; params?: unknown[] }>; + failAt: { sql: RegExp; error: string } | null; + preferences: Map; + select: ReturnType; + execute: ReturnType; +} + +function makeFakeDb(): FakeDb { + const db: FakeDb = { + calls: [], + failAt: null, + preferences: new Map(), + select: vi.fn(), + execute: vi.fn(), + }; + db.execute.mockImplementation(async (sql: string, params?: unknown[]) => { + db.calls.push({ sql, params }); + if (db.failAt && db.failAt.sql.test(sql)) { + throw new Error(db.failAt.error); + } + // Capture preference upserts so readLastMigrationJournal / getPreference + // can observe the written values. + if (/INSERT INTO user_preferences/i.test(sql) && params) { + const [key, value] = params as [string, string]; + db.preferences.set(key, value); + } + const upper = sql.trim().toUpperCase(); + if (/^(BEGIN|COMMIT|ROLLBACK)/.test(upper)) return { rowsAffected: 0 }; + return { rowsAffected: 1 }; + }); + db.select.mockImplementation(async (sql: string, params?: unknown[]) => { + if (/FROM user_preferences WHERE key/i.test(sql)) { + const [key] = (params as [string]) ?? [""]; + const value = db.preferences.get(key); + return value ? [{ key, value, updated_at: "2026-04-20T00:00:00Z" }] : []; + } + return []; + }); + return db; +} + +let fake: FakeDb; + +beforeEach(() => { + fake = makeFakeDb(); + vi.mocked(getDb).mockResolvedValue(fake as never); + mockInvoke.mockReset(); + vi.mocked(importTransactionsWithCategories).mockReset(); + vi.mocked(importTransactionsWithCategories).mockResolvedValue(undefined); +}); + +const PROFILE: Profile = { + id: "p1", + name: "Max", + color: "#f59e0b", + pin_hash: null, + db_filename: "max.db", + created_at: "2026-01-01T00:00:00Z", +}; + +// --------------------------------------------------------------------------- +// Flow 1 — plan → backup → migrate on a realistic v2 profile +// --------------------------------------------------------------------------- + +describe("integration: plan → backup → migrate (happy path)", () => { + it("produces a plan, verifies backup round-trip, then applies migration atomically", async () => { + // 1. Compute the plan from a realistic v2 profile fixture. + const plan = computeMigrationPlan(makeV2Profile()); + expect(plan.rows.length).toBeGreaterThan(20); + + // 2. Backup invoke stubs — round-trip the content so the checksum matches. + let captured: string | null = null; + mockInvoke.mockImplementation(async (cmd, args) => { + if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; + if (cmd === "write_export_file") { + captured = (args as { content: string }).content; + return undefined; + } + if (cmd === "read_import_file") return captured ?? ""; + if (cmd === "get_file_size") return 2048; + throw new Error(`unexpected invoke: ${cmd}`); + }); + + const backup = await createPreMigrationBackup({ profile: PROFILE }); + expect(backup.encrypted).toBe(false); + expect(backup.path).toMatch(/\.sref$/); + + // 3. Apply the migration. + const outcome = await applyMigration(plan, backup); + expect(outcome.succeeded).toBe(true); + expect(outcome.backupPath).toBe(backup.path); + // Plan has no unresolved after default fallback; `customPreservedCount` + // is 0 because the fixture has no custom cats. + expect(outcome.customPreservedCount).toBe(0); + + // 4. Verify ordering: BEGIN comes before any UPDATE and COMMIT is last. + const upperCalls = fake.calls.map((c) => c.sql.trim().toUpperCase()); + const beginIdx = upperCalls.indexOf("BEGIN"); + const commitIdx = upperCalls.indexOf("COMMIT"); + expect(beginIdx).toBeGreaterThanOrEqual(0); + expect(commitIdx).toBeGreaterThan(beginIdx); + expect(commitIdx).toBe(upperCalls.length - 1); + }); +}); + +// --------------------------------------------------------------------------- +// Flow 2 — custom-preserving migration on a profile with custom cats +// --------------------------------------------------------------------------- + +describe("integration: migration preserves custom categories", () => { + it("creates the custom parent and re-parents the 3 custom cats", async () => { + const plan = computeMigrationPlan(makeV2ProfileWithCustom()); + expect(plan.preserved).toHaveLength(3); + + // Backup stub (plaintext) + let captured: string | null = null; + mockInvoke.mockImplementation(async (cmd, args) => { + if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; + if (cmd === "write_export_file") { + captured = (args as { content: string }).content; + return undefined; + } + if (cmd === "read_import_file") return captured ?? ""; + if (cmd === "get_file_size") return 2048; + throw new Error(`unexpected invoke: ${cmd}`); + }); + + const backup = await createPreMigrationBackup({ profile: PROFILE }); + const outcome = await applyMigration(plan, backup); + expect(outcome.succeeded).toBe(true); + expect(outcome.customPreservedCount).toBe(3); + + // Custom parent created (id 2000) + const parentInsert = fake.calls.find( + (c) => + /INSERT OR IGNORE INTO categories/i.test(c.sql) && + (c.params?.[0] as number) === 2000, + ); + expect(parentInsert).toBeDefined(); + + // All 3 customs re-parented + const reparent = fake.calls.filter((c) => + /UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql), + ); + expect(reparent.length).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// Flow 3 — backup failure aborts (no DB writes) +// --------------------------------------------------------------------------- + +describe("integration: backup failure aborts before any DB write", () => { + it("migration never runs when createPreMigrationBackup throws", async () => { + mockInvoke.mockImplementation(async (cmd) => { + if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; + if (cmd === "write_export_file") throw new Error("disk full"); + throw new Error(`unexpected invoke: ${cmd}`); + }); + + const plan = computeMigrationPlan(makeV2Profile()); + + // The caller (UI) is responsible for calling backup FIRST; this test + // simulates that policy. + let backupErr: Error | null = null; + try { + await createPreMigrationBackup({ profile: PROFILE }); + } catch (e) { + backupErr = e as Error; + } + + expect(backupErr).not.toBeNull(); + // No migration call yet → DB calls must be empty. + expect(fake.calls).toHaveLength(0); + + // Guard: applyMigration with a fake empty/invalid backup still refuses. + const outcome = await applyMigration(plan, { + path: "", + size: 0, + checksum: "", + verifiedAt: "", + encrypted: false, + }); + expect(outcome.succeeded).toBe(false); + expect(fake.calls).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Flow 4 — SQL failure rolls back and backup remains usable +// --------------------------------------------------------------------------- + +describe("integration: SQL failure rolls back, backup remains", () => { + it("ROLLBACK is emitted and the backup is unaffected", async () => { + let writtenBackup: string | null = null; + mockInvoke.mockImplementation(async (cmd, args) => { + if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; + if (cmd === "write_export_file") { + writtenBackup = (args as { content: string }).content; + return undefined; + } + if (cmd === "read_import_file") return writtenBackup ?? ""; + if (cmd === "get_file_size") return 2048; + // The migration never touches Tauri fs commands other than via + // dataExportService helpers (already mocked). An unexpected cmd here + // would signal a leak. + throw new Error(`unexpected invoke: ${cmd}`); + }); + + const plan = computeMigrationPlan(makeV2Profile()); + const backup = await createPreMigrationBackup({ profile: PROFILE }); + expect(writtenBackup).not.toBeNull(); + + // Force a mid-run SQL failure. + fake.failAt = { + sql: /UPDATE budget_entries SET category_id/i, + error: "fk_violation", + }; + const outcome = await applyMigration(plan, backup); + + expect(outcome.succeeded).toBe(false); + expect(outcome.error).toMatch(/fk_violation/); + const upper = fake.calls.map((c) => c.sql.trim().toUpperCase()); + expect(upper).toContain("ROLLBACK"); + expect(upper).not.toContain("COMMIT"); + + // The backup is still a valid SREF string from the caller's perspective: + // nothing in the migration writes to disk. We verify the captured content + // still parses. + expect(() => JSON.parse(writtenBackup!)).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Flow 5 — restore flow (rollback by SREF import) +// --------------------------------------------------------------------------- + +describe("integration: restore from SREF after a migration", () => { + it("imports the backup, flips schema back to v2, stamps reverted_at", async () => { + // 1. Run a successful migration so the journal is in place. + let writtenBackup: string | null = null; + mockInvoke.mockImplementation(async (cmd, args) => { + if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; + if (cmd === "write_export_file") { + writtenBackup = (args as { content: string }).content; + return undefined; + } + if (cmd === "read_import_file") return writtenBackup ?? ""; + if (cmd === "get_file_size") return 2048; + if (cmd === "file_exists") return true; + if (cmd === "is_file_encrypted") return false; + throw new Error(`unexpected invoke: ${cmd}`); + }); + + const plan = computeMigrationPlan(makeV2Profile()); + const backup = await createPreMigrationBackup({ profile: PROFILE }); + const outcome = await applyMigration(plan, backup); + expect(outcome.succeeded).toBe(true); + + // 2. Now call restoreFromBackup using the same path. + const restoreResult = await restoreFromBackup(backup.path, null); + expect(restoreResult.filePath).toBe(backup.path); + expect(restoreResult.revertedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + + // 3. importTransactionsWithCategories must have been called exactly once. + expect(vi.mocked(importTransactionsWithCategories)).toHaveBeenCalledTimes(1); + + // 4. schema version reset + journal updated with reverted_at. + expect(fake.preferences.get("categories_schema_version")).toBe("v2"); + const journal = JSON.parse( + fake.preferences.get("last_categories_migration") as string, + ); + expect(journal.reverted_at).toBe(restoreResult.revertedAt); + }); +}); diff --git a/src/__integration__/regression-v2-v1.test.ts b/src/__integration__/regression-v2-v1.test.ts new file mode 100644 index 0000000..4065d99 --- /dev/null +++ b/src/__integration__/regression-v2-v1.test.ts @@ -0,0 +1,382 @@ +/** + * 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"); + }); + }, +); diff --git a/src/services/categoryBackupService.test.ts b/src/services/categoryBackupService.test.ts new file mode 100644 index 0000000..d7ea1b4 --- /dev/null +++ b/src/services/categoryBackupService.test.ts @@ -0,0 +1,416 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Hoisted mocks for every Tauri / dataExport dependency. We purposely do +// NOT talk to a real DB or FS — createPreMigrationBackup is a pure +// orchestration layer over `invoke()` + dataExportService helpers. + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +vi.mock("@tauri-apps/api/app", () => ({ + getVersion: vi.fn(async () => "0.8.3-test"), +})); + +vi.mock("./dataExportService", async () => { + const actual = await vi.importActual( + "./dataExportService", + ); + return { + ...actual, + getExportCategories: vi.fn(async () => []), + getExportSuppliers: vi.fn(async () => []), + getExportKeywords: vi.fn(async () => []), + getExportTransactions: vi.fn(async () => []), + }; +}); + +import { invoke } from "@tauri-apps/api/core"; +import { + createPreMigrationBackup, + sanitizeProfileName, + filesystemSafeIsoTimestamp, + buildBackupFilename, + sha256Hex, + BackupError, +} from "./categoryBackupService"; +import type { Profile } from "./profileService"; + +const mockInvoke = vi.mocked(invoke); + +// ----------------------------------------------------------------------------- +// Plain-text profile (no PIN). +// ----------------------------------------------------------------------------- + +const plainProfile: Profile = { + id: "p1", + name: "Max", + color: "#f59e0b", + pin_hash: null, + db_filename: "max.db", + created_at: "2026-01-01T00:00:00Z", +}; + +const encryptedProfile: Profile = { + ...plainProfile, + id: "p2", + pin_hash: "$argon2id$v=19$m=...", +}; + +// ----------------------------------------------------------------------------- +// Helpers — rebuild the "authoritative" SREF payload the service constructs +// so we can feed read_import_file a consistent round-trip. +// ----------------------------------------------------------------------------- + +function validEnvelope(): string { + return JSON.stringify({ + export_type: "transactions_with_categories", + app_version: "0.8.3-test", + exported_at: "2026-04-20T00:00:00Z", + data: { + categories: [], + suppliers: [], + keywords: [], + transactions: [], + }, + }); +} + +function setupHappyPathInvokes(payload: string): void { + mockInvoke.mockImplementation(async (cmd) => { + if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; + if (cmd === "write_export_file") return undefined; + if (cmd === "read_import_file") return payload; + if (cmd === "get_file_size") return payload.length; + throw new Error(`unexpected invoke: ${cmd}`); + }); +} + +beforeEach(() => { + mockInvoke.mockReset(); +}); + +// ----------------------------------------------------------------------------- +// Pure helper coverage +// ----------------------------------------------------------------------------- + +describe("sanitizeProfileName", () => { + it("strips forbidden Windows characters", () => { + expect(sanitizeProfileName('Bob/\\:*?"<>|Max')).toBe("BobMax"); + }); + it("collapses runs of whitespace into a single dash", () => { + expect(sanitizeProfileName("Bob and Alice")).toBe("Bob-and-Alice"); + }); + it("falls back to 'profile' on empty/all-stripped input", () => { + expect(sanitizeProfileName("")).toBe("profile"); + expect(sanitizeProfileName(' ///\\ ')).toBe("profile"); + }); + it("caps length at 80 characters", () => { + const long = "A".repeat(200); + expect(sanitizeProfileName(long).length).toBe(80); + }); +}); + +describe("filesystemSafeIsoTimestamp", () => { + it("replaces colons with dashes and drops fractional seconds", () => { + const ts = filesystemSafeIsoTimestamp(new Date("2026-04-19T14:22:05.456Z")); + expect(ts).toBe("2026-04-19T14-22-05Z"); + }); +}); + +describe("buildBackupFilename", () => { + it("concatenates sanitized name + timestamp + .sref suffix", () => { + const name = buildBackupFilename("Max", new Date("2026-04-19T14:22:05Z")); + expect(name).toBe("Max_avant-migration-2026-04-19T14-22-05Z.sref"); + }); + it("sanitizes the profile name portion", () => { + const name = buildBackupFilename("Bob:pro/file", new Date("2026-04-19T14:22:05Z")); + expect(name.startsWith("Bobprofile_")).toBe(true); + }); +}); + +describe("sha256Hex", () => { + it("matches the known SHA-256 of an empty string", async () => { + const digest = await sha256Hex(""); + expect(digest).toBe( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ); + }); + it("produces a stable 64-hex-chars digest for a short string", async () => { + const digest = await sha256Hex("hello"); + expect(digest).toMatch(/^[0-9a-f]{64}$/); + expect(digest).toBe( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + ); + }); +}); + +// ----------------------------------------------------------------------------- +// createPreMigrationBackup — success + failure modes +// ----------------------------------------------------------------------------- + +describe("createPreMigrationBackup — happy path (no PIN)", () => { + it("returns {path, size, checksum, encrypted=false} after round-trip", async () => { + // The exact payload is constructed inside the service; we hand back the + // same reference via read_import_file so the checksum matches. + let captured: string | null = null; + mockInvoke.mockImplementation(async (cmd, args) => { + if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; + if (cmd === "write_export_file") { + captured = (args as { content: string }).content; + return undefined; + } + if (cmd === "read_import_file") return captured ?? ""; + if (cmd === "get_file_size") return 4096; + throw new Error(`unexpected invoke: ${cmd}`); + }); + + const result = await createPreMigrationBackup({ profile: plainProfile }); + + expect(result.encrypted).toBe(false); + expect(result.path.startsWith("/tmp/sr-backups")).toBe(true); + expect(result.path.endsWith(".sref")).toBe(true); + expect(result.size).toBe(4096); + expect(result.checksum).toMatch(/^[0-9a-f]{64}$/); + expect(result.verifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("calls write_export_file with password=null for an unprotected profile", async () => { + setupHappyPathInvokes(validEnvelope()); + // Override write_export_file so we can inspect args. + const writeArgs: unknown[] = []; + mockInvoke.mockImplementation(async (cmd, args) => { + if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; + if (cmd === "write_export_file") { + writeArgs.push(args); + return undefined; + } + if (cmd === "read_import_file") { + // Re-serve the content that was just written so the checksum matches. + const a = writeArgs[0] as { content: string }; + return a.content; + } + if (cmd === "get_file_size") return 1; + throw new Error(`unexpected invoke: ${cmd}`); + }); + + await createPreMigrationBackup({ profile: plainProfile }); + + expect(writeArgs).toHaveLength(1); + expect((writeArgs[0] as { password: string | null }).password).toBeNull(); + }); +}); + +describe("createPreMigrationBackup — encrypted profile (PIN)", () => { + it("requires a password when the profile has a PIN hash", async () => { + await expect( + createPreMigrationBackup({ profile: encryptedProfile }), + ).rejects.toThrow(BackupError); + + try { + await createPreMigrationBackup({ profile: encryptedProfile }); + } catch (e) { + expect(e).toBeInstanceOf(BackupError); + expect((e as BackupError).code).toBe("missing_password"); + } + }); + + it("forwards the trimmed password to write_export_file AND read_import_file", async () => { + const writeArgs: Array<{ password: string | null }> = []; + const readArgs: Array<{ password: string | null }> = []; + let written = ""; + mockInvoke.mockImplementation(async (cmd, args) => { + if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; + if (cmd === "write_export_file") { + const a = args as { content: string; password: string | null }; + writeArgs.push({ password: a.password }); + written = a.content; + return undefined; + } + if (cmd === "read_import_file") { + const a = args as { password: string | null }; + readArgs.push({ password: a.password }); + return written; + } + if (cmd === "get_file_size") return written.length; + throw new Error(`unexpected invoke: ${cmd}`); + }); + + const r = await createPreMigrationBackup({ + profile: encryptedProfile, + password: " 1234 ", + }); + + expect(r.encrypted).toBe(true); + expect(writeArgs[0].password).toBe("1234"); + expect(readArgs[0].password).toBe("1234"); + }); + + it("rejects when the password is whitespace-only", async () => { + try { + await createPreMigrationBackup({ + profile: encryptedProfile, + password: " ", + }); + throw new Error("should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(BackupError); + expect((e as BackupError).code).toBe("missing_password"); + } + }); +}); + +describe("createPreMigrationBackup — write failures", () => { + it("raises write_failed when invoke(write_export_file) rejects (generic)", async () => { + mockInvoke.mockImplementation(async (cmd) => { + if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; + if (cmd === "write_export_file") throw new Error("write io error"); + throw new Error(`unexpected invoke: ${cmd}`); + }); + try { + await createPreMigrationBackup({ profile: plainProfile }); + throw new Error("should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(BackupError); + expect((e as BackupError).code).toBe("write_failed"); + } + }); + + it("maps 'no space left' to the disk_space code", async () => { + mockInvoke.mockImplementation(async (cmd) => { + if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; + if (cmd === "write_export_file") throw new Error("no space left on device"); + throw new Error(`unexpected invoke: ${cmd}`); + }); + try { + await createPreMigrationBackup({ profile: plainProfile }); + throw new Error("should have thrown"); + } catch (e) { + expect((e as BackupError).code).toBe("disk_space"); + } + }); + + it("maps 'permission denied' at write time to permission_denied", async () => { + mockInvoke.mockImplementation(async (cmd) => { + if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; + if (cmd === "write_export_file") throw new Error("permission denied"); + throw new Error(`unexpected invoke: ${cmd}`); + }); + try { + await createPreMigrationBackup({ profile: plainProfile }); + throw new Error("should have thrown"); + } catch (e) { + expect((e as BackupError).code).toBe("permission_denied"); + } + }); +}); + +describe("createPreMigrationBackup — dir failures", () => { + it("maps ensure_backup_dir 'create_dir_failed' prefix to the matching code", async () => { + mockInvoke.mockImplementation(async (cmd) => { + if (cmd === "ensure_backup_dir") throw new Error("create_dir_failed: EROFS"); + throw new Error(`unexpected invoke: ${cmd}`); + }); + try { + await createPreMigrationBackup({ profile: plainProfile }); + throw new Error("should have thrown"); + } catch (e) { + expect((e as BackupError).code).toBe("create_dir_failed"); + } + }); + + it("falls back to documents_dir_unavailable on unknown dir error", async () => { + mockInvoke.mockImplementation(async (cmd) => { + if (cmd === "ensure_backup_dir") throw new Error("some weird io error"); + throw new Error(`unexpected invoke: ${cmd}`); + }); + try { + await createPreMigrationBackup({ profile: plainProfile }); + throw new Error("should have thrown"); + } catch (e) { + expect((e as BackupError).code).toBe("documents_dir_unavailable"); + } + }); +}); + +describe("createPreMigrationBackup — integrity check", () => { + it("raises verification_mismatch when the re-read content has a different checksum", async () => { + mockInvoke.mockImplementation(async (cmd) => { + if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; + if (cmd === "write_export_file") return undefined; + // Deliberately return a DIFFERENT, but still valid, envelope so the + // parse check passes but the SHA-256 differs. + if (cmd === "read_import_file") + return JSON.stringify({ + export_type: "transactions_with_categories", + app_version: "0.0.0", + exported_at: "2025-01-01T00:00:00Z", + data: { categories: [{ id: 99 }] }, + }); + if (cmd === "get_file_size") return 123; + throw new Error(`unexpected invoke: ${cmd}`); + }); + try { + await createPreMigrationBackup({ profile: plainProfile }); + throw new Error("should have thrown"); + } catch (e) { + expect((e as BackupError).code).toBe("verification_mismatch"); + expect((e as BackupError).detail).toContain("checksum_diff"); + } + }); + + it("raises verification_mismatch when the re-read content is not JSON", async () => { + mockInvoke.mockImplementation(async (cmd) => { + if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; + if (cmd === "write_export_file") return undefined; + if (cmd === "read_import_file") return "not{json"; + if (cmd === "get_file_size") return 42; + throw new Error(`unexpected invoke: ${cmd}`); + }); + try { + await createPreMigrationBackup({ profile: plainProfile }); + throw new Error("should have thrown"); + } catch (e) { + expect((e as BackupError).code).toBe("verification_mismatch"); + expect((e as BackupError).detail).toMatch(/envelope_parse/); + } + }); + + it("raises verification_mismatch when the envelope is the wrong export_type", async () => { + mockInvoke.mockImplementation(async (cmd) => { + if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; + if (cmd === "write_export_file") return undefined; + if (cmd === "read_import_file") + return JSON.stringify({ + export_type: "categories_only", + app_version: "0.0.0", + exported_at: "2025-01-01T00:00:00Z", + data: { categories: [] }, + }); + if (cmd === "get_file_size") return 42; + throw new Error(`unexpected invoke: ${cmd}`); + }); + try { + await createPreMigrationBackup({ profile: plainProfile }); + throw new Error("should have thrown"); + } catch (e) { + expect((e as BackupError).code).toBe("verification_mismatch"); + expect((e as BackupError).detail).toContain("envelope_type"); + } + }); + + it("raises read_back_failed when invoke(read_import_file) rejects", async () => { + mockInvoke.mockImplementation(async (cmd) => { + if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; + if (cmd === "write_export_file") return undefined; + if (cmd === "read_import_file") throw new Error("file gone"); + throw new Error(`unexpected invoke: ${cmd}`); + }); + try { + await createPreMigrationBackup({ profile: plainProfile }); + throw new Error("should have thrown"); + } catch (e) { + expect((e as BackupError).code).toBe("read_back_failed"); + } + }); +}); diff --git a/src/services/categoryMappingService.test.ts b/src/services/categoryMappingService.test.ts index 4d62295..d218178 100644 --- a/src/services/categoryMappingService.test.ts +++ b/src/services/categoryMappingService.test.ts @@ -364,3 +364,244 @@ describe("computeMigrationPlan — pass priority", () => { }); }); }); + +// --------------------------------------------------------------------------- +// 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]); + }); +}); diff --git a/src/services/categoryMigrationService.test.ts b/src/services/categoryMigrationService.test.ts new file mode 100644 index 0000000..36303c8 --- /dev/null +++ b/src/services/categoryMigrationService.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// `getDb` is stubbed with a fake DB that captures every SQL statement issued +// during applyMigration. This is a unit test — we do NOT run a real SQLite. + +vi.mock("./db", () => ({ + getDb: vi.fn(), +})); + +// `setPreference` goes through the same fake DB — no extra stubbing needed +// because userPreferenceService itself calls getDb(). + +import { getDb } from "./db"; +import { applyMigration, markSchemaVersionV1 } from "./categoryMigrationService"; +import type { MigrationPlan, MappingRow } from "./categoryMappingService"; +import type { BackupResult } from "./categoryBackupService"; + +interface CapturedCall { + sql: string; + params?: unknown[]; +} + +interface FakeDb { + calls: CapturedCall[]; + failAt: { sql: RegExp; error: string } | null; + select: ReturnType; + execute: ReturnType; +} + +function makeFakeDb(): FakeDb { + const db: FakeDb = { + calls: [], + failAt: null, + select: vi.fn(async () => []), + execute: vi.fn(), + }; + db.execute.mockImplementation(async (sql: string, params?: unknown[]) => { + db.calls.push({ sql, params }); + if (db.failAt && db.failAt.sql.test(sql)) { + throw new Error(db.failAt.error); + } + // Return a rowsAffected=1 for UPDATE/DELETE/INSERT, 0 for BEGIN/COMMIT/ROLLBACK. + const upper = sql.trim().toUpperCase(); + if (/^(BEGIN|COMMIT|ROLLBACK)/.test(upper)) return { rowsAffected: 0 }; + // Heuristic: only simulate a write on statements that look like real + // UPDATE/DELETE/INSERT OR IGNORE — this gives us stable counts in tests. + return { rowsAffected: 1 }; + }); + return db; +} + +const FAKE_BACKUP: BackupResult = { + path: "/tmp/profile-backup.sref", + size: 4096, + checksum: "abc123", + verifiedAt: "2026-04-20T12:00:00Z", + encrypted: false, +}; + +function makeRow(v2: number, v1: number | null): MappingRow { + return { + v2CategoryId: v2, + v2CategoryName: `v2-${v2}`, + v1TargetId: v1, + v1TargetName: v1 === null ? null : `v1-${v1}`, + confidence: v1 === null ? "none" : "high", + reason: v1 === null ? "review" : "keyword", + }; +} + +function makePlan( + rows: MappingRow[], + preserved: MappingRow[] = [], +): MigrationPlan { + const unresolved = rows.filter((r) => r.v1TargetId === null); + return { + rows, + preserved, + unresolved, + stats: { + total: rows.length, + high: rows.filter((r) => r.confidence === "high").length, + medium: rows.filter((r) => r.confidence === "medium").length, + low: rows.filter((r) => r.confidence === "low").length, + none: unresolved.length, + }, + }; +} + +let fake: FakeDb; + +beforeEach(() => { + fake = makeFakeDb(); + vi.mocked(getDb).mockResolvedValue(fake as never); +}); + +// --------------------------------------------------------------------------- +// Guard: invalid backup short-circuits before any SQL runs +// --------------------------------------------------------------------------- + +describe("applyMigration — backup guard", () => { + it("returns succeeded=false with no DB calls when backup has no path", async () => { + const outcome = await applyMigration( + makePlan([makeRow(10, 1011)]), + { ...FAKE_BACKUP, path: "" }, + ); + expect(outcome.succeeded).toBe(false); + expect(outcome.error).toMatch(/invalid_backup/); + expect(fake.calls).toHaveLength(0); + }); + + it("returns succeeded=false when backup has no checksum", async () => { + const outcome = await applyMigration( + makePlan([makeRow(10, 1011)]), + { ...FAKE_BACKUP, checksum: "" }, + ); + expect(outcome.succeeded).toBe(false); + expect(outcome.error).toMatch(/invalid_backup/); + expect(fake.calls).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Happy path sequencing +// --------------------------------------------------------------------------- + +describe("applyMigration — happy path sequencing", () => { + it("wraps the entire run in BEGIN / COMMIT", async () => { + const outcome = await applyMigration( + makePlan([makeRow(22, 1111)]), + FAKE_BACKUP, + ); + expect(outcome.succeeded).toBe(true); + const trimmed = fake.calls.map((c) => c.sql.trim().toUpperCase()); + expect(trimmed[0]).toBe("BEGIN"); + expect(trimmed[trimmed.length - 1]).toBe("COMMIT"); + }); + + it("emits an UPDATE on transactions for each mapped v2 id", async () => { + await applyMigration( + makePlan([makeRow(22, 1111), makeRow(24, 1121)]), + FAKE_BACKUP, + ); + const txUpdates = fake.calls.filter((c) => + /UPDATE transactions SET category_id/i.test(c.sql), + ); + expect(txUpdates.length).toBe(2); + expect(txUpdates[0].params).toEqual([1111, 22]); + expect(txUpdates[1].params).toEqual([1121, 24]); + }); + + it("rewrites budget_entries and budget_template_entries", async () => { + await applyMigration( + makePlan([makeRow(22, 1111)]), + FAKE_BACKUP, + ); + const budgetEntries = fake.calls.filter((c) => + /UPDATE budget_entries SET category_id/i.test(c.sql), + ); + const templateEntries = fake.calls.filter((c) => + /UPDATE budget_template_entries SET category_id/i.test(c.sql), + ); + expect(budgetEntries.length).toBe(1); + expect(templateEntries.length).toBe(1); + }); + + it("rewrites keywords and suppliers", async () => { + await applyMigration( + makePlan([makeRow(22, 1111)]), + FAKE_BACKUP, + ); + const kwUpdates = fake.calls.filter((c) => + /UPDATE keywords SET category_id/i.test(c.sql), + ); + const supUpdates = fake.calls.filter((c) => + /UPDATE suppliers SET category_id/i.test(c.sql), + ); + expect(kwUpdates.length).toBe(1); + expect(supUpdates.length).toBe(1); + }); + + it("inserts all v1 taxonomy rows (INSERT OR IGNORE)", async () => { + await applyMigration( + makePlan([makeRow(22, 1111)]), + FAKE_BACKUP, + ); + const v1Inserts = fake.calls.filter((c) => + /INSERT OR IGNORE INTO categories/i.test(c.sql), + ); + // The v1 taxonomy has >100 rows; a low bound is safe as a regression + // signal without coupling to the exact count. + expect(v1Inserts.length).toBeGreaterThan(50); + }); + + it("soft-deletes v2 seeded categories (is_active=0) and the 1..6 structural parents", async () => { + await applyMigration( + makePlan([makeRow(22, 1111)]), + FAKE_BACKUP, + ); + const deactivateSeeded = fake.calls.filter( + (c) => + /UPDATE categories SET is_active = 0 WHERE id = \$1/i.test(c.sql) && + (c.params?.[0] as number) === 22, + ); + const deactivateParents = fake.calls.filter((c) => + /WHERE id IN \(1, 2, 3, 4, 5, 6\)/i.test(c.sql), + ); + expect(deactivateSeeded.length).toBe(1); + expect(deactivateParents.length).toBe(1); + }); + + it("writes categories_schema_version=v1 and a journal entry to user_preferences", async () => { + await applyMigration( + makePlan([makeRow(22, 1111)]), + FAKE_BACKUP, + ); + const prefWrites = fake.calls.filter((c) => + /INSERT INTO user_preferences/i.test(c.sql), + ); + expect(prefWrites.length).toBe(2); + expect(prefWrites[0].params?.[0]).toBe("categories_schema_version"); + expect(prefWrites[1].params?.[0]).toBe("last_categories_migration"); + // Journal is serialized JSON; sniff a stable key to avoid snapshot churn. + const journalJson = prefWrites[1].params?.[1] as string; + expect(journalJson).toContain('"timestamp"'); + expect(journalJson).toContain('"backupPath"'); + expect(journalJson).toContain(FAKE_BACKUP.path); + }); + + it("ignores rows whose v1TargetId is null (never emits UPDATE for them)", async () => { + await applyMigration( + makePlan([makeRow(22, 1111), makeRow(73, null)]), + FAKE_BACKUP, + ); + const txUpdates = fake.calls.filter((c) => + /UPDATE transactions SET category_id/i.test(c.sql), + ); + // Only one mapping (22 → 1111) was resolved; the unresolved row is + // skipped inside buildMappingFromRows. + expect(txUpdates.length).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Custom categories (preserved) +// --------------------------------------------------------------------------- + +describe("applyMigration — preserved custom categories", () => { + it("creates the 'Catégories personnalisées (migration)' parent when preserved is non-empty", async () => { + const outcome = await applyMigration( + makePlan([makeRow(22, 1111)], [makeRow(9001, null)]), + FAKE_BACKUP, + ); + expect(outcome.succeeded).toBe(true); + const parentInserts = fake.calls.filter( + (c) => + /INSERT OR IGNORE INTO categories/i.test(c.sql) && + (c.params?.[0] as number) === 2000, + ); + expect(parentInserts.length).toBe(1); + expect(parentInserts[0].params?.[4]).toBe( + "categoriesSeed.migration.customParent", + ); + }); + + it("does NOT create the custom parent when preserved is empty", async () => { + await applyMigration( + makePlan([makeRow(22, 1111)]), + FAKE_BACKUP, + ); + const parentInserts = fake.calls.filter( + (c) => + /INSERT OR IGNORE INTO categories/i.test(c.sql) && + (c.params?.[0] as number) === 2000, + ); + expect(parentInserts.length).toBe(0); + }); + + it("re-parents each preserved v2 category under the new parent id (2000)", async () => { + await applyMigration( + makePlan( + [makeRow(22, 1111)], + [makeRow(9001, null), makeRow(9002, null), makeRow(9003, null)], + ), + FAKE_BACKUP, + ); + const reparent = fake.calls.filter((c) => + /UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql), + ); + expect(reparent.length).toBe(3); + for (const call of reparent) { + expect(call.params?.[0]).toBe(2000); + } + expect(reparent.map((c) => c.params?.[1])).toEqual([9001, 9002, 9003]); + }); +}); + +// --------------------------------------------------------------------------- +// Rollback on SQL failure +// --------------------------------------------------------------------------- + +describe("applyMigration — rollback on SQL failure", () => { + it("emits ROLLBACK when an UPDATE throws mid-run", async () => { + fake.failAt = { + sql: /UPDATE transactions SET category_id/i, + error: "fk_violation", + }; + const outcome = await applyMigration( + makePlan([makeRow(22, 1111)]), + FAKE_BACKUP, + ); + expect(outcome.succeeded).toBe(false); + expect(outcome.error).toMatch(/fk_violation/); + const upper = fake.calls.map((c) => c.sql.trim().toUpperCase()); + expect(upper).toContain("ROLLBACK"); + expect(upper).not.toContain("COMMIT"); + }); + + it("does not clobber the outcome.error even when ROLLBACK itself fails", async () => { + // First throw on transactions UPDATE; the subsequent ROLLBACK must not + // overwrite the original error. + let seen = 0; + fake.execute.mockImplementation(async (sql: string) => { + fake.calls.push({ sql }); + seen++; + if (/UPDATE transactions SET category_id/i.test(sql)) { + throw new Error("original_sql_error"); + } + if (/^ROLLBACK/i.test(sql.trim())) { + throw new Error("rollback_also_failed"); + } + return { rowsAffected: 1 }; + }); + const outcome = await applyMigration( + makePlan([makeRow(22, 1111)]), + FAKE_BACKUP, + ); + expect(outcome.succeeded).toBe(false); + expect(outcome.error).toMatch(/original_sql_error/); + expect(seen).toBeGreaterThan(2); + }); +}); + +// --------------------------------------------------------------------------- +// markSchemaVersionV1 — helper +// --------------------------------------------------------------------------- + +describe("markSchemaVersionV1", () => { + it("writes an upsert into user_preferences with key=categories_schema_version", async () => { + await markSchemaVersionV1(); + const prefWrite = fake.calls.find((c) => + /INSERT INTO user_preferences/i.test(c.sql), + ); + expect(prefWrite).toBeDefined(); + expect(prefWrite!.params?.[0]).toBe("categories_schema_version"); + expect(prefWrite!.params?.[1]).toBe("v1"); + }); +});