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>
416 lines
15 KiB
TypeScript
416 lines
15 KiB
TypeScript
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");
|
|
}
|
|
});
|
|
});
|