test(categories): complete test coverage for migration flow (#123) #133
7 changed files with 2050 additions and 0 deletions
141
docs/qa-refonte-seed-categories-ipc.md
Normal file
141
docs/qa-refonte-seed-categories-ipc.md
Normal 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.
|
||||
180
src/__fixtures__/profiles.ts
Normal file
180
src/__fixtures__/profiles.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
332
src/__integration__/category-migration.test.ts
Normal file
332
src/__integration__/category-migration.test.ts
Normal 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 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<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);
|
||||
});
|
||||
});
|
||||
382
src/__integration__/regression-v2-v1.test.ts
Normal file
382
src/__integration__/regression-v2-v1.test.ts
Normal 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");
|
||||
});
|
||||
},
|
||||
);
|
||||
416
src/services/categoryBackupService.test.ts
Normal file
416
src/services/categoryBackupService.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
358
src/services/categoryMigrationService.test.ts
Normal file
358
src/services/categoryMigrationService.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue