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>
191 lines
6.9 KiB
TypeScript
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);
|
|
});
|
|
});
|