Files
ModelForge-Public/frontend/src/components/MigrationWorkspace.test.tsx
T

220 lines
13 KiB
TypeScript

import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MigrationWorkspace } from "./MigrationWorkspace";
import { clearOperatorCredential, setOperatorCredential } from "../lib/api";
const activePlan = (overrides: Record<string, unknown> = {}) => ({
id: "plan-1", project_id: "project-1", project_binding_id: "binding-1", capability_contract_id: "contract-1",
migration_class: "REQUIRES_REINDEX", environment: "LAB", state: "BACKFILL_PAUSED", version: 7, generation: 1,
adapter: { key: "examplerag.qdrant-reindex", version: "1", fingerprint: "a".repeat(64), operations: [] },
source_identity: {}, target_identity: {}, source_data_target: "rag_source_v1", target_shadow_target: "rag_shadow_v2",
source_space_ref: "space-v1", target_space_id: "space-2", corpus_revision: "corpus-1", migration_policy_revision: "m13-1",
validation_policy_revision_id: "policy-1", lifecycle_approval_id: "approval-1", rollback_target_ref: "rag_source_v1",
total_expected_items: 597, completed_items: 256, failed_items: 0, retryable_items: 0, permanent_failed_items: 0,
batch_size: 32, priority: "BACKGROUND", rollback_retention_days: 30, plan_fingerprint: "b".repeat(64),
approval_fingerprint: "c".repeat(64), failure_details: {}, last_cursor: "256", cancel_requested: false,
immutable_at: "2026-08-26T10:00:00Z", created_at: "2026-08-26T10:00:00Z", updated_at: "2026-08-26T10:01:00Z",
...overrides,
});
const validation = (planId = "plan-1", overrides: Record<string, unknown> = {}) => ({
id: `validation-${planId}`, migration_plan_id: planId, generation: 1, expected_count: 597, actual_count: 597,
missing_count: 0, duplicate_count: 0, malformed_count: 0, non_finite_count: 0, wrong_dimension_count: 0,
content_hash_mismatch_count: 0, wrong_space_count: 0, comparable: true, critical_regressions: 0, passed: true,
technical_cutover_eligible: true, project_promotion_eligible: false, blockers: ["EXTERNAL_VALIDATION_NOT_SATISFIED"],
target_fingerprint: "d".repeat(64), snapshot_fingerprint: "e".repeat(64), evidence: {}, created_at: "2026-08-26T10:02:00Z",
...overrides,
});
const cutover = (planId = "plan-1", overrides: Record<string, unknown> = {}) => ({
id: `cutover-${planId}`, migration_plan_id: planId, stage: "ROLLED_BACK", generation: 1,
source_before: "rag_source_v1", target_after: "rag_shadow_v2", external_state_fingerprint: "f".repeat(64),
health_evidence: {}, failure_details: {}, switch_duration_ms: 3.2, rollback_duration_ms: 2.1,
started_at: "2026-08-26T10:03:00Z", finished_at: "2026-08-26T10:04:00Z", ...overrides,
});
const auditEvent = {
id: "event-1", migration_plan_id: "plan-1", event_type: "BACKFILL_PAUSED", before_state: "BACKFILLING",
after_state: "BACKFILL_PAUSED", actor: "operator", reason: "interruption rehearsal", policy_revision: "m13-1",
evidence_refs: [], source_identity: {}, target_identity: {}, generation: 1, change_id: "change-1", details: {},
occurred_at: "2026-08-26T10:01:00Z",
};
function mockMigration(options: {
plans?: Record<string, unknown>[];
validations?: Record<string, unknown>[];
cutovers?: Record<string, unknown>[];
events?: Record<string, unknown>[];
} = {}) {
const plans = options.plans ?? [activePlan()];
const validations = options.validations ?? [validation()];
const cutovers = options.cutovers ?? [cutover()];
const events = options.events ?? [auditEvent];
const requests: { url: string; method: string }[] = [];
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
requests.push({ url, method: init?.method ?? "GET" });
let payload: unknown = [];
if (url.endsWith("/plans")) payload = plans;
if (url.includes("/validation")) {
const planId = decodeURIComponent(url.split("/plans/")[1]?.split("/validation")[0] ?? "");
payload = validations.filter((item) => item.migration_plan_id === planId);
}
if (url.includes("/cutovers")) payload = cutovers;
if (url.includes("/events")) payload = events;
if (url.includes("/backfill/start")) payload = plans.find((item) => item.id === "plan-1") ?? activePlan();
return Promise.resolve(new Response(JSON.stringify(payload), { status: 200 }));
});
vi.stubGlobal("fetch", fetchMock);
return { fetchMock, requests };
}
afterEach(() => { clearOperatorCredential(); cleanup(); vi.unstubAllGlobals(); });
describe("M13 migration operator workspace", () => {
it("separates current work from terminal history and counts only current evidence", async () => {
mockMigration({
plans: [activePlan(), activePlan({
id: "plan-old", state: "ROLLED_BACK", source_data_target: "archive_source", target_shadow_target: "archive_target",
total_expected_items: 100, completed_items: 100, rollback_target_ref: "archive_source",
}), activePlan({
id: "plan-committed", state: "CUTOVER_COMMITTED", source_data_target: "committed_source",
target_shadow_target: "committed_target", total_expected_items: 10, completed_items: 10,
rollback_target_ref: "committed_source",
})],
validations: [validation(), validation("plan-old", { project_promotion_eligible: true })],
cutovers: [cutover(), cutover("plan-committed", {
stage: "COMMITTED", source_before: "committed_source", target_after: "committed_target", rollback_duration_ms: null,
})],
});
setOperatorCredential("operator-test");
render(<MigrationWorkspace />);
await screen.findByText("266 / 607");
expect(screen.getByText("Current item progress").closest(".migration-fact")).toHaveTextContent("266 / 607");
expect(screen.getByText("Current migrations").closest(".migration-fact")).toHaveTextContent("2");
expect(screen.getByText("Current project-promotion eligible").closest(".migration-fact")).toHaveTextContent("0");
fireEvent.click(screen.getByRole("tab", { name: "plans" }));
expect(await screen.findByRole("heading", { name: "Current migrations (2)" })).toBeInTheDocument();
expect(screen.getByText("Historical migrations (1)")).toBeInTheDocument();
const current = screen.getByRole("heading", { name: "rag_source_v1 → rag_shadow_v2" }).closest("article");
const history = screen.getByText("archive_source → archive_target").closest("article");
const committed = screen.getByRole("heading", { name: "committed_source → committed_target" }).closest("article");
expect(current).toHaveTextContent("Current migration");
expect(current).toHaveTextContent("Validation snapshot");
expect(history).toHaveTextContent("Historical record");
expect(history).toHaveTextContent("Read-only history");
expect(history).not.toHaveTextContent("Start/resume");
expect(history).not.toHaveTextContent("Cancel");
expect(committed).toHaveTextContent("Current migration");
expect(committed).toHaveTextContent("Rollback targetcommitted_source");
expect(committed).toHaveTextContent("30-day retention policy");
expect(committed).not.toHaveTextContent("Cancel");
});
it("keeps a selected historical plan visible and read-only for Quick Jump", async () => {
mockMigration({ plans: [activePlan({ id: "plan-old", state: "ROLLED_BACK" })] });
setOperatorCredential("operator-test");
render(<MigrationWorkspace targetPlanId="plan-old" />);
const target = await screen.findByText("QUICK JUMP TARGET");
expect(screen.getByRole("tab", { name: "plans" })).toHaveAttribute("aria-selected", "true");
expect(target.closest("article")).toHaveTextContent("Historical record");
expect(target.closest("article")).toHaveTextContent("rag_source_v1 → rag_shadow_v2");
expect(target.closest("article")).not.toHaveTextContent("Start/resume");
});
it("surfaces missing validation and cutover evidence on plans requiring reconciliation", async () => {
mockMigration({
plans: [activePlan({ state: "MANUAL_INTERVENTION_REQUIRED" })],
validations: [],
cutovers: [],
});
setOperatorCredential("operator-test");
render(<MigrationWorkspace />);
fireEvent.click(screen.getByRole("tab", { name: "plans" }));
const card = (await screen.findByText("rag_source_v1 → rag_shadow_v2")).closest("article");
expect(card).toHaveTextContent("Reconciliation required");
expect(card).toHaveTextContent("Validation evidence not recorded");
expect(card).toHaveTextContent("Cutover journal not recorded");
expect(card).toHaveTextContent(/Rollback target\s*rag_source_v1/);
expect(card).not.toHaveTextContent("Start/resume");
expect(card).not.toHaveTextContent("Pause");
expect(card).not.toHaveTextContent("Cancel");
});
it("does not present prior-generation evidence as current reconciliation context", async () => {
mockMigration({
plans: [activePlan({ generation: 2, state: "MANUAL_INTERVENTION_REQUIRED" })],
validations: [validation("plan-1", { generation: 1, project_promotion_eligible: true })],
cutovers: [cutover("plan-1", { generation: 1, stage: "COMMITTED" })],
});
setOperatorCredential("operator-test");
render(<MigrationWorkspace />);
fireEvent.click(screen.getByRole("tab", { name: "plans" }));
const card = (await screen.findByRole("heading", { name: "rag_source_v1 → rag_shadow_v2" })).closest("article");
expect(card).toHaveTextContent("stale generation");
expect(card).toHaveTextContent("current plan generation is 2");
expect(card).toHaveTextContent("Latest cutover journal is from generation 1");
expect(card).toHaveTextContent("not proven pending reconciliation");
expect(card).toHaveTextContent("not currently eligible");
expect(card).not.toHaveTextContent("snapshot eligible");
expect(screen.getByRole("heading", { name: "Needs operator attention (1)" })).toBeInTheDocument();
});
it("waits for an explicit current-plan action and never mutates history", async () => {
const { requests } = mockMigration({
plans: [activePlan(), activePlan({ id: "plan-old", state: "ROLLED_BACK" })],
});
setOperatorCredential("operator-test");
render(<MigrationWorkspace />);
await screen.findByText("256 / 597");
expect(requests.every((request) => request.method === "GET")).toBe(true);
fireEvent.click(screen.getByRole("tab", { name: "plans" }));
expect(await screen.findByRole("heading", { name: "Current migrations (1)" })).toBeInTheDocument();
expect(requests.every((request) => request.method === "GET")).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Start/resume" }));
await screen.findByText("Background backfill explicitly started or resumed.");
await waitFor(() => expect(requests.some((request) => request.method === "POST" && request.url.endsWith("/plan-1/backfill/start"))).toBe(true));
expect(requests.some((request) => request.method !== "GET" && request.url.includes("plan-old"))).toBe(false);
});
it("separates technical cutover from project-promotion eligibility", async () => {
mockMigration(); render(<MigrationWorkspace />);
fireEvent.change(screen.getByLabelText("Operator API key"), { target: { value: "operator-test" } });
fireEvent.click(screen.getByRole("button", { name: "Load migrations" }));
await screen.findByText("256 / 597");
fireEvent.click(screen.getByRole("tab", { name: "validation" }));
expect(screen.getByText("technical pass")).toBeInTheDocument();
expect(screen.getByText("external validation not satisfied")).toBeInTheDocument();
expect(screen.getByText("blocked")).toBeInTheDocument();
});
it("shows resumability, exact cutover identities, rollback timing and audit history", async () => {
mockMigration(); render(<MigrationWorkspace />);
fireEvent.change(screen.getByLabelText("Operator API key"), { target: { value: "operator-test" } });
fireEvent.click(screen.getByRole("button", { name: "Load migrations" }));
await screen.findByText("256 / 597"); fireEvent.click(screen.getByRole("tab", { name: "plans" }));
expect(screen.getByRole("button", { name: "Start/resume" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "cutover" }));
expect(screen.getByText("3.2 ms")).toBeInTheDocument(); expect(screen.getByText("2.1 ms")).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "history" }));
expect(screen.getByText("interruption rehearsal")).toBeInTheDocument();
});
it("does not select another plan when a Quick Jump target is absent", async () => {
mockMigration();
setOperatorCredential("operator-test");
render(<MigrationWorkspace targetPlanId="missing-plan" />);
expect(await screen.findByRole("status")).toHaveTextContent(/selected migration plan is not in the loaded index/i);
expect(screen.queryByText("QUICK JUMP TARGET")).not.toBeInTheDocument();
});
});