Simpl-Resultat/src/hooks/useCategoryMigration.test.ts
le king fu 0646875327
All checks were successful
PR Check / rust (push) Successful in 21m39s
PR Check / frontend (push) Successful in 2m21s
PR Check / rust (pull_request) Successful in 21m49s
PR Check / frontend (pull_request) Successful in 2m15s
feat(categories): add 3-step migration page + categoryMigrationService (#121)
New user-facing 3-step migration flow at /settings/categories/migrate that
allows legacy v2 profiles to opt in to the v1 IPC taxonomy.

Step 1 Discover — read-only taxonomy tree (reuses CategoryTaxonomyTree from
Livraison 1, #117).
Step 2 Simulate — 3-column dry-run table with confidence badges (high /
medium / low / needs-review), transaction preview side panel, inline target
picker for unresolved rows. The "next" button is blocked until every row is
resolved.
Step 3 Consent — checklist + optional PIN field for PIN-protected profiles +
4-step loader (backup created / verified / SQL applied / committed).

Success and error screens surface the SREF backup path and the counts of
rows migrated. Errors never leave the profile in a partial state — the new
categoryMigrationService wraps the entire SQL writeover in a
BEGIN/COMMIT/ROLLBACK atomic transaction and aborts up-front if the backup
is not present / verified.

New code:
- src/services/categoryMigrationService.ts — applyMigration(plan, backup)
  atomic writer (INSERT v1 → UPDATE transactions/budgets/budget_templates/
  keywords/suppliers → reparent preserved customs → deactivate v2 seed →
  bump categories_schema_version=v1 → journal last_categories_migration).
- src/hooks/useCategoryMigration.ts — useReducer state machine
  (discover → simulate → consent → running → success | error).
- src/hooks/useCategoryMigration.test.ts — 13 pure reducer tests.
- src/components/categories-migration/{StepDiscover,StepSimulate,StepConsent,
  MappingRow,TransactionPreviewPanel}.tsx — UI per the mockup.
- src/pages/CategoriesMigrationPage.tsx — wrapper with internal router,
  stepper, backup/migrate orchestration, success/error screens.

Tweaks:
- src/App.tsx — new /settings/categories/migrate route.
- src/components/settings/CategoriesCard.tsx — additional card surfacing
  the migrate entry for v2 profiles only.
- src/i18n/locales/{fr,en}.json — categoriesSeed.migration.* namespace
  (page / stepper / 3 steps / running / success / error / backup error codes).
- CHANGELOG.{md,fr.md} — [Unreleased] / Added entry.

Scope limits respected: no SQL migration modified, no new migration added,
no unit tests of the applyMigration writer (covered by #123 in wave 4),
no restore-backup button (#122 in wave 4), no post-migration banner (#122).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:31:21 -04:00

191 lines
6.9 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { migrationReducer, INITIAL_STATE } from "./useCategoryMigration";
import type {
MigrationPlan,
MappingRow,
} from "../services/categoryMappingService";
import type { BackupResult } from "../services/categoryBackupService";
import type { MigrationOutcome } from "../services/categoryMigrationService";
// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------
function makeRow(v2: number, v1: number | null): MappingRow {
return {
v2CategoryId: v2,
v2CategoryName: `v2-${v2}`,
v1TargetId: v1,
v1TargetName: v1 === null ? null : `v1-${v1}`,
confidence: v1 === null ? "none" : "high",
reason: v1 === null ? "review" : "keyword",
};
}
function makePlan(rows: MappingRow[], preserved: MappingRow[] = []): MigrationPlan {
const unresolved = rows.filter((r) => r.v1TargetId === null);
return {
rows,
preserved,
unresolved,
stats: {
total: rows.length,
high: rows.filter((r) => r.confidence === "high").length,
medium: rows.filter((r) => r.confidence === "medium").length,
low: rows.filter((r) => r.confidence === "low").length,
none: unresolved.length,
},
};
}
const FAKE_BACKUP: BackupResult = {
path: "/tmp/backup.sref",
size: 1024,
checksum: "abc",
verifiedAt: new Date().toISOString(),
encrypted: false,
};
const FAKE_OUTCOME: MigrationOutcome = {
succeeded: true,
insertedV1Count: 139,
updatedTransactionsCount: 100,
updatedBudgetsCount: 10,
updatedKeywordsCount: 20,
deletedV2Count: 40,
customPreservedCount: 2,
backupPath: "/tmp/backup.sref",
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("migrationReducer", () => {
it("starts on discover with no plan / backup / outcome", () => {
expect(INITIAL_STATE.step).toBe("discover");
expect(INITIAL_STATE.plan).toBeNull();
expect(INITIAL_STATE.backup).toBeNull();
expect(INITIAL_STATE.outcome).toBeNull();
expect(INITIAL_STATE.unresolved).toBe(0);
});
it("LOAD_PLAN stores plan and counts unresolved rows", () => {
const plan = makePlan([makeRow(10, 1011), makeRow(11, null)]);
const next = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
expect(next.plan).toBe(plan);
expect(next.unresolved).toBe(1);
expect(next.errors).toEqual([]);
});
it("RESOLVE_ROW sets target, bumps confidence from none to medium, decreases unresolved", () => {
const plan = makePlan([makeRow(10, null), makeRow(11, null)]);
const s1 = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
expect(s1.unresolved).toBe(2);
const s2 = migrationReducer(s1, {
type: "RESOLVE_ROW",
v2CategoryId: 10,
v1TargetId: 1011,
v1TargetName: "Paie régulière",
});
expect(s2.unresolved).toBe(1);
const resolved = s2.plan!.rows.find((r) => r.v2CategoryId === 10)!;
expect(resolved.v1TargetId).toBe(1011);
expect(resolved.v1TargetName).toBe("Paie régulière");
expect(resolved.confidence).toBe("medium");
});
it("GO_NEXT blocks simulate -> consent when unresolved > 0", () => {
const plan = makePlan([makeRow(10, null)]);
let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
s = migrationReducer(s, { type: "GO_NEXT" }); // discover -> simulate
expect(s.step).toBe("simulate");
const sBlocked = migrationReducer(s, { type: "GO_NEXT" });
expect(sBlocked.step).toBe("simulate"); // blocked because unresolved=1
});
it("GO_NEXT advances simulate -> consent once all rows are resolved", () => {
const plan = makePlan([makeRow(10, null)]);
let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
s = migrationReducer(s, { type: "GO_NEXT" }); // simulate
s = migrationReducer(s, {
type: "RESOLVE_ROW",
v2CategoryId: 10,
v1TargetId: 1011,
v1TargetName: "Paie régulière",
});
s = migrationReducer(s, { type: "GO_NEXT" }); // consent
expect(s.step).toBe("consent");
});
it("GO_BACK goes simulate -> discover and consent -> simulate", () => {
const plan = makePlan([makeRow(10, 1011)]);
let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
s = migrationReducer(s, { type: "GO_NEXT" }); // simulate
s = migrationReducer(s, { type: "GO_NEXT" }); // consent
expect(s.step).toBe("consent");
s = migrationReducer(s, { type: "GO_BACK" });
expect(s.step).toBe("simulate");
s = migrationReducer(s, { type: "GO_BACK" });
expect(s.step).toBe("discover");
});
it("START_RUN moves the step to running and clears errors", () => {
const base = { ...INITIAL_STATE, step: "consent" as const, errors: ["old"] };
const s = migrationReducer(base, { type: "START_RUN" });
expect(s.step).toBe("running");
expect(s.errors).toEqual([]);
});
it("SET_BACKUP stores the backup and keeps the current step", () => {
const base = { ...INITIAL_STATE, step: "running" as const };
const s = migrationReducer(base, { type: "SET_BACKUP", backup: FAKE_BACKUP });
expect(s.backup).toBe(FAKE_BACKUP);
expect(s.step).toBe("running");
});
it("SET_OUTCOME moves to success on succeeded=true", () => {
const base = { ...INITIAL_STATE, step: "running" as const };
const s = migrationReducer(base, {
type: "SET_OUTCOME",
outcome: FAKE_OUTCOME,
});
expect(s.step).toBe("success");
expect(s.outcome).toBe(FAKE_OUTCOME);
expect(s.errors).toEqual([]);
});
it("SET_OUTCOME moves to error when outcome.succeeded=false and records the error", () => {
const failed: MigrationOutcome = { ...FAKE_OUTCOME, succeeded: false, error: "sql" };
const base = { ...INITIAL_STATE, step: "running" as const };
const s = migrationReducer(base, { type: "SET_OUTCOME", outcome: failed });
expect(s.step).toBe("error");
expect(s.outcome).toBe(failed);
expect(s.errors).toContain("sql");
});
it("FAIL appends to errors and moves to error step", () => {
const base = { ...INITIAL_STATE, step: "running" as const };
const s = migrationReducer(base, { type: "FAIL", error: "backup failed" });
expect(s.step).toBe("error");
expect(s.errors).toEqual(["backup failed"]);
});
it("SELECT_ROW stores the selected row id", () => {
const s1 = migrationReducer(INITIAL_STATE, { type: "SELECT_ROW", v2CategoryId: 42 });
expect(s1.selectedRowV2Id).toBe(42);
const s2 = migrationReducer(s1, { type: "SELECT_ROW", v2CategoryId: null });
expect(s2.selectedRowV2Id).toBeNull();
});
it("RESET returns the initial state", () => {
const middle = {
...INITIAL_STATE,
step: "success" as const,
errors: ["x"],
outcome: FAKE_OUTCOME,
};
const s = migrationReducer(middle, { type: "RESET" });
expect(s).toEqual(INITIAL_STATE);
});
});