Adds unit + integration + regression tests and a QA checklist for the v2→v1 seed migration feature. - Fixtures: src/__fixtures__/profiles.ts (makeV2Profile, makeV1Profile, makeV2ProfileWithCustom) with realistic categories, keywords, suppliers, transactions, budgets. - Unit: categoryMappingService (100 cases covering every DEFAULT_MAPPINGS entry, 4-pass priority, splits, preserved/custom detection), categoryBackupService (23 cases — Tauri mocks: success, write error, integrity check, PIN-encrypted profile), categoryMigrationService (16 cases — BEGIN/COMMIT/ROLLBACK flow, backup-missing abort, journaling, custom parent creation). - Integration: full plan→backup→migrate→verify flow; rollback via SREF import; backup failure → no DB write; migration SQL failure → ROLLBACK + intact state. - Regression: parameterised v2/v1 fixtures covering auto-categorisation, budget aggregation, splits preservation. - Docs: docs/qa-refonte-seed-categories-ipc.md — manual checklist for UX, system errors, encrypted profile, custom preservation, 90-day banner, restore flow. 331 vitest tests pass (up from 193 baseline). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|