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

127 lines
8.5 KiB
TypeScript

import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { api, type ConfigurationCatalog, type ConfigurationPreview } from "../lib/api";
import { ConfigurationEditor } from "./ConfigurationEditor";
const catalog: ConfigurationCatalog = {
environment: "test", read_only: false, revision: 0, overrides: {}, settings: [{
key: "gateway_max_batch_size", display_name: "Gateway batch limit", description: "Inputs per request",
environment_variable: "MODELFORGE_GATEWAY_MAX_BATCH_SIZE", sensitivity: "public", scope: "control_plane",
required_in_production: false, application_mode: "dynamic", editable: true, configured: true,
source: "pydantic_default", value: 8, category: "gateway", data_type: "integer", default_value: 8,
validation_rules: { minimum: 1, maximum: 128 }, consumer: "control_plane.serving", rollback: "Preview a prior revision",
}],
};
const preview: ConfigurationPreview = {
expected_revision: 0, preview_digest: "a".repeat(64), overrides: { gateway_max_batch_size: 2 },
differences: [{ key: "gateway_max_batch_size", before: 8, after: 2, source_before: "pydantic_default", source_after: "operator_override" }],
affected_components: ["control_plane.serving"], impact: "New requests only; no restart.", rollback: "Preview a prior revision.",
};
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
describe("Configuration changes", () => {
it("allows a viewer to inspect history but disables configuration proposals", () => {
render(<ConfigurationEditor catalog={catalog} onApplied={vi.fn()} role="viewer" />);
expect(screen.getByLabelText(/Gateway batch limit/)).toBeDisabled();
expect(screen.getByRole("button", { name: "Preview import" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Load revision history" })).not.toBeDisabled();
expect(screen.getByRole("status")).toHaveTextContent("Viewer access");
});
it("allows operator preview without offering administrator apply", async () => {
vi.spyOn(api, "configurationPreview").mockResolvedValue(preview);
render(<ConfigurationEditor catalog={catalog} onApplied={vi.fn()} role="operator" />);
fireEvent.change(screen.getByLabelText(/Gateway batch limit/), { target: { value: "2" } });
fireEvent.click(screen.getByRole("button", { name: "Preview changes" }));
await screen.findByText("Review proposed revision");
expect(screen.getByLabelText("I reviewed the impact and authorize this change.")).toBeDisabled();
expect(screen.getByRole("button", { name: "Apply reviewed changes" })).toBeDisabled();
});
it("explains field bounds and the queue timeout dependency beside the control", () => {
const queueTimeout = {
...catalog.settings[0], key: "gateway_queue_timeout_seconds", display_name: "Gateway queue timeout",
value: 30, validation_rules: { minimum: 1, maximum: 900, less_than_setting: "gateway_request_timeout_seconds" },
};
const requestTimeout = {
...catalog.settings[0], key: "gateway_request_timeout_seconds", display_name: "Gateway request timeout",
value: 45, validation_rules: { minimum: 5, maximum: 900 },
};
render(<ConfigurationEditor catalog={{ ...catalog, settings: [queueTimeout, requestTimeout] }} onApplied={vi.fn()} />);
const input = screen.getByLabelText(/Gateway queue timeout/);
expect(input).toHaveAttribute("aria-describedby", "validation-editor-gateway_queue_timeout_seconds");
expect(screen.getByText("Allowed: at least 1 and at most 900. Must be less than Gateway request timeout."))
.toBeInTheDocument();
});
it("requires preview and explicit confirmation before applying the reviewed values", async () => {
const proposed = vi.spyOn(api, "configurationPreview").mockResolvedValue(preview);
const apply = vi.spyOn(api, "configurationApply").mockResolvedValue({ revision: 1, status: "accepted_for_new_requests", acknowledged_by: "control_plane.serving", preview_digest: preview.preview_digest });
const refreshed = vi.fn().mockResolvedValue(undefined);
render(<ConfigurationEditor catalog={catalog} onApplied={refreshed} />);
expect(screen.getByRole("button", { name: "Preview changes" })).toBeDisabled();
fireEvent.change(screen.getByLabelText(/Gateway batch limit/), { target: { value: "2" } });
fireEvent.click(screen.getByRole("button", { name: "Preview changes" }));
expect(await screen.findByText("Review proposed revision")).toBeInTheDocument();
expect(screen.getByText("Pydantic default → Operator override")).toBeInTheDocument();
expect(proposed).toHaveBeenCalledWith({ expected_revision: 0, changes: { gateway_max_batch_size: 2 } });
expect(apply).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Apply reviewed changes" })).toBeDisabled();
fireEvent.click(screen.getByLabelText("I reviewed the impact and authorize this change."));
fireEvent.click(screen.getByRole("button", { name: "Apply reviewed changes" }));
expect(await screen.findByText(/Revision 1 accepted for new serving requests/)).toBeInTheDocument();
expect(refreshed).toHaveBeenCalledOnce();
});
it("discards a preview after edits and after an external revision changes", async () => {
vi.spyOn(api, "configurationPreview").mockResolvedValue(preview);
const { rerender } = render(<ConfigurationEditor catalog={catalog} onApplied={vi.fn()} />);
fireEvent.change(screen.getByLabelText(/Gateway batch limit/), { target: { value: "2" } });
fireEvent.click(screen.getByRole("button", { name: "Preview changes" }));
await screen.findByText("Review proposed revision");
fireEvent.change(screen.getByLabelText(/Gateway batch limit/), { target: { value: "3" } });
expect(screen.queryByText("Review proposed revision")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Preview changes" }));
await screen.findByText("Review proposed revision");
rerender(<ConfigurationEditor catalog={{ ...catalog, revision: 1 }} onApplied={vi.fn()} />);
expect(screen.queryByText("Review proposed revision")).not.toBeInTheDocument();
});
it("reports conflicts without reporting application or retaining confirmation", async () => {
vi.spyOn(api, "configurationPreview").mockResolvedValue(preview);
vi.spyOn(api, "configurationApply").mockRejectedValue(new Error("Configuration changed"));
render(<ConfigurationEditor catalog={catalog} onApplied={vi.fn()} />);
fireEvent.change(screen.getByLabelText(/Gateway batch limit/), { target: { value: "2" } });
fireEvent.click(screen.getByRole("button", { name: "Preview changes" }));
await screen.findByText("Review proposed revision");
fireEvent.click(screen.getByLabelText("I reviewed the impact and authorize this change."));
fireEvent.click(screen.getByRole("button", { name: "Apply reviewed changes" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Configuration changed");
expect(screen.queryByText(/accepted for new serving requests/)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Apply reviewed changes" })).not.toBeInTheDocument();
});
it("previews import and rollback without automatically applying either", async () => {
const change = { expected_revision: 0, changes: { gateway_max_batch_size: 2 } };
const imported = vi.spyOn(api, "configurationImportPreview").mockResolvedValue({ change, preview });
const history = vi.spyOn(api, "configurationHistory").mockResolvedValue([]);
const apply = vi.spyOn(api, "configurationApply");
render(<ConfigurationEditor catalog={catalog} onApplied={vi.fn()} />);
fireEvent.click(screen.getByText("Export and import safe overrides"));
fireEvent.change(screen.getByLabelText("Configuration import"), { target: { value: "bad JSON" } });
fireEvent.click(screen.getByRole("button", { name: "Preview import" }));
expect(await screen.findByRole("alert")).toHaveTextContent("valid JSON");
expect(imported).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText("Configuration import"), { target: { value: "{}" } });
fireEvent.click(screen.getByRole("button", { name: "Preview import" }));
await screen.findByText("Review proposed revision");
expect(apply).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("Revision history and rollback"));
fireEvent.click(screen.getByRole("button", { name: "Load revision history" }));
await waitFor(() => expect(history).toHaveBeenCalledOnce());
expect(await screen.findByText("No operator revisions yet.")).toBeInTheDocument();
});
});