/** * Integration-flavoured tests for the v2 → v1 categories migration. * * We intentionally do NOT spin up a real sqlite: the `tauri-plugin-sql` bridge * is only available inside the Tauri WebView. Instead we use a carefully * crafted in-memory fake DB that: * - records every SQL statement (so we can assert ordering), * - simulates `rowsAffected` for UPDATEs, * - optionally fails on a matched SQL to simulate a mid-run error. * * The goal is to catch cross-service ordering bugs and the plan→backup→migrate * sequencing required by the spec (ADR: pre-migration backup is a hard gate). */ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("../services/db", () => ({ getDb: vi.fn(), })); vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), })); vi.mock("@tauri-apps/api/app", () => ({ getVersion: vi.fn(async () => "0.8.3-test"), })); // dataExportService helpers are used by both the backup service and the // restore service. We mock the "DB-reading" ones and keep the parsers real. vi.mock("../services/dataExportService", async () => { const actual = await vi.importActual< typeof import("../services/dataExportService") >("../services/dataExportService"); return { ...actual, getExportCategories: vi.fn(async () => []), getExportSuppliers: vi.fn(async () => []), getExportKeywords: vi.fn(async () => []), getExportTransactions: vi.fn(async () => []), importTransactionsWithCategories: vi.fn(async () => undefined), }; }); import { getDb } from "../services/db"; import { invoke } from "@tauri-apps/api/core"; import { computeMigrationPlan } from "../services/categoryMappingService"; import { createPreMigrationBackup } from "../services/categoryBackupService"; import { applyMigration } from "../services/categoryMigrationService"; import { restoreFromBackup } from "../services/categoryRestoreService"; import { importTransactionsWithCategories } from "../services/dataExportService"; import { makeV2Profile, makeV2ProfileWithCustom } from "../__fixtures__/profiles"; import type { Profile } from "../services/profileService"; const mockInvoke = vi.mocked(invoke); interface FakeDb { calls: Array<{ sql: string; params?: unknown[] }>; failAt: { sql: RegExp; error: string } | null; preferences: Map; select: ReturnType; execute: ReturnType; } function makeFakeDb(): FakeDb { const db: FakeDb = { calls: [], failAt: null, preferences: new Map(), select: vi.fn(), execute: vi.fn(), }; db.execute.mockImplementation(async (sql: string, params?: unknown[]) => { db.calls.push({ sql, params }); if (db.failAt && db.failAt.sql.test(sql)) { throw new Error(db.failAt.error); } // Capture preference upserts so readLastMigrationJournal / getPreference // can observe the written values. if (/INSERT INTO user_preferences/i.test(sql) && params) { const [key, value] = params as [string, string]; db.preferences.set(key, value); } const upper = sql.trim().toUpperCase(); if (/^(BEGIN|COMMIT|ROLLBACK)/.test(upper)) return { rowsAffected: 0 }; return { rowsAffected: 1 }; }); db.select.mockImplementation(async (sql: string, params?: unknown[]) => { if (/FROM user_preferences WHERE key/i.test(sql)) { const [key] = (params as [string]) ?? [""]; const value = db.preferences.get(key); return value ? [{ key, value, updated_at: "2026-04-20T00:00:00Z" }] : []; } return []; }); return db; } let fake: FakeDb; beforeEach(() => { fake = makeFakeDb(); vi.mocked(getDb).mockResolvedValue(fake as never); mockInvoke.mockReset(); vi.mocked(importTransactionsWithCategories).mockReset(); vi.mocked(importTransactionsWithCategories).mockResolvedValue(undefined); }); const PROFILE: Profile = { id: "p1", name: "Max", color: "#f59e0b", pin_hash: null, db_filename: "max.db", created_at: "2026-01-01T00:00:00Z", }; // --------------------------------------------------------------------------- // Flow 1 — plan → backup → migrate on a realistic v2 profile // --------------------------------------------------------------------------- describe("integration: plan → backup → migrate (happy path)", () => { it("produces a plan, verifies backup round-trip, then applies migration atomically", async () => { // 1. Compute the plan from a realistic v2 profile fixture. const plan = computeMigrationPlan(makeV2Profile()); expect(plan.rows.length).toBeGreaterThan(20); // 2. Backup invoke stubs — round-trip the content so the checksum matches. let captured: string | null = null; mockInvoke.mockImplementation(async (cmd, args) => { if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; if (cmd === "write_export_file") { captured = (args as { content: string }).content; return undefined; } if (cmd === "read_import_file") return captured ?? ""; if (cmd === "get_file_size") return 2048; throw new Error(`unexpected invoke: ${cmd}`); }); const backup = await createPreMigrationBackup({ profile: PROFILE }); expect(backup.encrypted).toBe(false); expect(backup.path).toMatch(/\.sref$/); // 3. Apply the migration. const outcome = await applyMigration(plan, backup); expect(outcome.succeeded).toBe(true); expect(outcome.backupPath).toBe(backup.path); // Plan has no unresolved after default fallback; `customPreservedCount` // is 0 because the fixture has no custom cats. expect(outcome.customPreservedCount).toBe(0); // 4. Verify ordering: BEGIN comes before any UPDATE and COMMIT is last. const upperCalls = fake.calls.map((c) => c.sql.trim().toUpperCase()); const beginIdx = upperCalls.indexOf("BEGIN"); const commitIdx = upperCalls.indexOf("COMMIT"); expect(beginIdx).toBeGreaterThanOrEqual(0); expect(commitIdx).toBeGreaterThan(beginIdx); expect(commitIdx).toBe(upperCalls.length - 1); }); }); // --------------------------------------------------------------------------- // Flow 2 — custom-preserving migration on a profile with custom cats // --------------------------------------------------------------------------- describe("integration: migration preserves custom categories", () => { it("creates the custom parent and re-parents the 3 custom cats", async () => { const plan = computeMigrationPlan(makeV2ProfileWithCustom()); expect(plan.preserved).toHaveLength(3); // Backup stub (plaintext) let captured: string | null = null; mockInvoke.mockImplementation(async (cmd, args) => { if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; if (cmd === "write_export_file") { captured = (args as { content: string }).content; return undefined; } if (cmd === "read_import_file") return captured ?? ""; if (cmd === "get_file_size") return 2048; throw new Error(`unexpected invoke: ${cmd}`); }); const backup = await createPreMigrationBackup({ profile: PROFILE }); const outcome = await applyMigration(plan, backup); expect(outcome.succeeded).toBe(true); expect(outcome.customPreservedCount).toBe(3); // Custom parent created (id 2000) const parentInsert = fake.calls.find( (c) => /INSERT OR IGNORE INTO categories/i.test(c.sql) && (c.params?.[0] as number) === 2000, ); expect(parentInsert).toBeDefined(); // All 3 customs re-parented const reparent = fake.calls.filter((c) => /UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql), ); expect(reparent.length).toBe(3); }); }); // --------------------------------------------------------------------------- // Flow 3 — backup failure aborts (no DB writes) // --------------------------------------------------------------------------- describe("integration: backup failure aborts before any DB write", () => { it("migration never runs when createPreMigrationBackup throws", async () => { mockInvoke.mockImplementation(async (cmd) => { if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; if (cmd === "write_export_file") throw new Error("disk full"); throw new Error(`unexpected invoke: ${cmd}`); }); const plan = computeMigrationPlan(makeV2Profile()); // The caller (UI) is responsible for calling backup FIRST; this test // simulates that policy. let backupErr: Error | null = null; try { await createPreMigrationBackup({ profile: PROFILE }); } catch (e) { backupErr = e as Error; } expect(backupErr).not.toBeNull(); // No migration call yet → DB calls must be empty. expect(fake.calls).toHaveLength(0); // Guard: applyMigration with a fake empty/invalid backup still refuses. const outcome = await applyMigration(plan, { path: "", size: 0, checksum: "", verifiedAt: "", encrypted: false, }); expect(outcome.succeeded).toBe(false); expect(fake.calls).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // Flow 4 — SQL failure rolls back and backup remains usable // --------------------------------------------------------------------------- describe("integration: SQL failure rolls back, backup remains", () => { it("ROLLBACK is emitted and the backup is unaffected", async () => { let writtenBackup: string | null = null; mockInvoke.mockImplementation(async (cmd, args) => { if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; if (cmd === "write_export_file") { writtenBackup = (args as { content: string }).content; return undefined; } if (cmd === "read_import_file") return writtenBackup ?? ""; if (cmd === "get_file_size") return 2048; // The migration never touches Tauri fs commands other than via // dataExportService helpers (already mocked). An unexpected cmd here // would signal a leak. throw new Error(`unexpected invoke: ${cmd}`); }); const plan = computeMigrationPlan(makeV2Profile()); const backup = await createPreMigrationBackup({ profile: PROFILE }); expect(writtenBackup).not.toBeNull(); // Force a mid-run SQL failure. fake.failAt = { sql: /UPDATE budget_entries SET category_id/i, error: "fk_violation", }; const outcome = await applyMigration(plan, backup); expect(outcome.succeeded).toBe(false); expect(outcome.error).toMatch(/fk_violation/); const upper = fake.calls.map((c) => c.sql.trim().toUpperCase()); expect(upper).toContain("ROLLBACK"); expect(upper).not.toContain("COMMIT"); // The backup is still a valid SREF string from the caller's perspective: // nothing in the migration writes to disk. We verify the captured content // still parses. expect(() => JSON.parse(writtenBackup!)).not.toThrow(); }); }); // --------------------------------------------------------------------------- // Flow 5 — restore flow (rollback by SREF import) // --------------------------------------------------------------------------- describe("integration: restore from SREF after a migration", () => { it("imports the backup, flips schema back to v2, stamps reverted_at", async () => { // 1. Run a successful migration so the journal is in place. let writtenBackup: string | null = null; mockInvoke.mockImplementation(async (cmd, args) => { if (cmd === "ensure_backup_dir") return "/tmp/sr-backups"; if (cmd === "write_export_file") { writtenBackup = (args as { content: string }).content; return undefined; } if (cmd === "read_import_file") return writtenBackup ?? ""; if (cmd === "get_file_size") return 2048; if (cmd === "file_exists") return true; if (cmd === "is_file_encrypted") return false; throw new Error(`unexpected invoke: ${cmd}`); }); const plan = computeMigrationPlan(makeV2Profile()); const backup = await createPreMigrationBackup({ profile: PROFILE }); const outcome = await applyMigration(plan, backup); expect(outcome.succeeded).toBe(true); // 2. Now call restoreFromBackup using the same path. const restoreResult = await restoreFromBackup(backup.path, null); expect(restoreResult.filePath).toBe(backup.path); expect(restoreResult.revertedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); // 3. importTransactionsWithCategories must have been called exactly once. expect(vi.mocked(importTransactionsWithCategories)).toHaveBeenCalledTimes(1); // 4. schema version reset + journal updated with reverted_at. expect(fake.preferences.get("categories_schema_version")).toBe("v2"); const journal = JSON.parse( fake.preferences.get("last_categories_migration") as string, ); expect(journal.reverted_at).toBe(restoreResult.revertedAt); }); });