test(categories): complete test coverage for migration flow (#123) #133

Merged
maximus merged 1 commit from issue-123-complete-tests into main 2026-04-21 23:28:09 +00:00
7 changed files with 2050 additions and 0 deletions
Showing only changes of commit 12d1877870 - Show all commits

View file

@ -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 à <path>."
- [ ] 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.

View file

@ -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],
};
}

View file

@ -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 planbackupmigrate
* 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<string, string>;
select: ReturnType<typeof vi.fn>;
execute: ReturnType<typeof vi.fn>;
}
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);
});
});

View file

@ -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<number, number>();
for (const r of rows) {
budgetById.set(r.category_id, r.monthBudget);
}
// Both leaves get their stored budget × -1.
expect(budgetById.get(leafIds[0])).toBe(-400);
expect(budgetById.get(leafIds[1])).toBe(-200);
// The parent aggregates to -600.
expect(budgetById.get(parentId)).toBe(-600);
});
},
);
// ---------------------------------------------------------------------------
// dataExportService envelope round-trip parity — the SREF JSON format must
// remain identical before and after the migration.
// ---------------------------------------------------------------------------
function envelopeFixture(schema: Schema): ExportEnvelope {
const catId = schema === "v2" ? 22 : 1111;
return {
export_type: "transactions_with_categories",
app_version: "0.8.3-test",
exported_at: "2026-04-20T00:00:00Z",
data: {
categories: [
{
id: catId,
name: "Épicerie",
parent_id: null,
color: "#000",
type: "expense",
is_active: 1,
is_inputable: 1,
sort_order: 1,
i18n_key: null,
} as unknown as Category,
],
suppliers: [],
keywords: [],
transactions: [],
},
};
}
describe.each<[Schema]>([["v2"], ["v1"]])(
"regression: SREF envelope round-trip [%s]",
(schema) => {
it("serialize → parse returns an equivalent envelope", () => {
const original = envelopeFixture(schema);
const serialized = serializeToJson(
original.export_type,
original.data,
original.app_version,
);
const { envelope } = parseImportedJson(serialized);
expect(envelope.export_type).toBe(original.export_type);
expect(envelope.data.categories).toHaveLength(1);
expect(envelope.data.categories![0].id).toBe(
schema === "v2" ? 22 : 1111,
);
});
},
);
// ---------------------------------------------------------------------------
// Split transactions — the parent_transaction_id / is_split columns must be
// honoured identically after a migration. We exercise them through the
// export envelope, which is the canonical observable surface.
// ---------------------------------------------------------------------------
describe.each<[Schema]>([["v2"], ["v1"]])(
"regression: split transactions survive export [%s]",
(schema) => {
it("preserves is_split and parent_transaction_id in the envelope", () => {
const parentCat = schema === "v2" ? 28 : 1521;
const leg1Cat = schema === "v2" ? 28 : 1521;
const leg2Cat = schema === "v2" ? 22 : 1111;
const envelope: ExportEnvelope = {
export_type: "transactions_with_categories",
app_version: "0.8.3-test",
exported_at: "2026-04-20T00:00:00Z",
data: {
categories: [],
suppliers: [],
keywords: [],
transactions: [
{
id: 100,
date: "2026-03-10",
description: "STM Opus + snack",
amount: -50,
category_id: parentCat,
category_name: null,
original_description: null,
notes: null,
is_manually_categorized: 0,
is_split: 1,
parent_transaction_id: null,
},
{
id: 101,
date: "2026-03-10",
description: "STM leg",
amount: -30,
category_id: leg1Cat,
category_name: null,
original_description: null,
notes: null,
is_manually_categorized: 1,
is_split: 0,
parent_transaction_id: 100,
},
{
id: 102,
date: "2026-03-10",
description: "Snack leg",
amount: -20,
category_id: leg2Cat,
category_name: null,
original_description: null,
notes: null,
is_manually_categorized: 1,
is_split: 0,
parent_transaction_id: 100,
},
],
},
};
const json = serializeToJson(envelope.export_type, envelope.data, envelope.app_version);
const parsed = parseImportedJson(json).envelope;
const txs = parsed.data.transactions!;
expect(txs).toHaveLength(3);
expect(txs[0].is_split).toBe(1);
expect(txs[1].parent_transaction_id).toBe(100);
expect(txs[2].parent_transaction_id).toBe(100);
// Category ids are schema-specific but never null.
expect(typeof txs[0].category_id).toBe("number");
expect(typeof txs[1].category_id).toBe("number");
expect(typeof txs[2].category_id).toBe("number");
});
},
);

View file

@ -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<typeof import("./dataExportService")>(
"./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");
}
});
});

View file

@ -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]);
});
});

View file

@ -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<typeof vi.fn>;
execute: ReturnType<typeof vi.fn>;
}
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");
});
});