Simpl-Resultat/src/services/categoryBackupService.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

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