220 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|