Simpl-Resultat/src/__integration__/category-migration.test.ts
le king fu 12d1877870
All checks were successful
PR Check / rust (push) Successful in 22m48s
PR Check / frontend (push) Successful in 2m20s
PR Check / rust (pull_request) Successful in 22m51s
PR Check / frontend (pull_request) Successful in 2m21s
test(categories): complete test coverage for migration flow (#123)
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>
2026-04-21 19:25:13 -04:00

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