480 lines
14 KiB
TypeScript
480 lines
14 KiB
TypeScript
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import AdminPage from "./AdminPage";
|
|
import * as client from "../api/client";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock data
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MOCK_SETTINGS = {
|
|
guest_access_enabled: false,
|
|
default_endpoint_id: "ep1",
|
|
token_budget_daily: 100000,
|
|
token_budget_monthly: 2000000,
|
|
api_keys: [
|
|
{
|
|
id: "key1",
|
|
label: "CI Pipeline",
|
|
prefix: "pl_abc",
|
|
created_at: "2026-04-01T10:00:00Z",
|
|
},
|
|
],
|
|
};
|
|
|
|
const MOCK_STATS = {
|
|
total_runs: 1234,
|
|
total_experiments: 56,
|
|
total_projects: 8,
|
|
cache_entries: 890,
|
|
cache_hit_rate: 0.73,
|
|
storage_bytes: 52428800,
|
|
tokens_spent: 456789,
|
|
};
|
|
|
|
const MOCK_ENDPOINTS: client.EndpointResponse[] = [
|
|
{ id: "ep1", name: "OpenAI", url: "https://api.openai.com", default_model: "gpt-4" },
|
|
{ id: "ep2", name: "Local vLLM", url: "http://localhost:8080", default_model: null },
|
|
];
|
|
|
|
const MOCK_WEBHOOKS: client.WebhookResponse[] = [
|
|
{
|
|
id: "wh1",
|
|
event_type: "run.completed",
|
|
url: "https://example.com/hook",
|
|
headers: null,
|
|
is_active: true,
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function mockAllApis() {
|
|
vi.spyOn(client.admin, "getSettings").mockResolvedValue(
|
|
MOCK_SETTINGS as unknown as Record<string, unknown>,
|
|
);
|
|
vi.spyOn(client.admin, "getStats").mockResolvedValue(
|
|
MOCK_STATS as unknown as Record<string, unknown>,
|
|
);
|
|
vi.spyOn(client.admin, "updateSettings").mockResolvedValue(
|
|
{} as Record<string, unknown>,
|
|
);
|
|
vi.spyOn(client.endpoints, "list").mockResolvedValue({
|
|
items: MOCK_ENDPOINTS,
|
|
total: MOCK_ENDPOINTS.length,
|
|
});
|
|
vi.spyOn(client.webhooks, "list").mockResolvedValue({
|
|
items: MOCK_WEBHOOKS,
|
|
total: MOCK_WEBHOOKS.length,
|
|
});
|
|
vi.spyOn(client.webhooks, "create").mockResolvedValue({
|
|
id: "wh2",
|
|
event_type: "run.started",
|
|
url: "https://new.com/hook",
|
|
headers: null,
|
|
is_active: true,
|
|
});
|
|
vi.spyOn(client.webhooks, "delete").mockResolvedValue(undefined as never);
|
|
}
|
|
|
|
function renderAdmin() {
|
|
return render(
|
|
<MemoryRouter initialEntries={["/admin"]}>
|
|
<AdminPage />
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("AdminPage", () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("shows loading state initially", () => {
|
|
vi.spyOn(client.admin, "getSettings").mockReturnValue(
|
|
new Promise(() => {}),
|
|
);
|
|
vi.spyOn(client.admin, "getStats").mockReturnValue(new Promise(() => {}));
|
|
vi.spyOn(client.endpoints, "list").mockReturnValue(new Promise(() => {}));
|
|
vi.spyOn(client.webhooks, "list").mockReturnValue(new Promise(() => {}));
|
|
|
|
renderAdmin();
|
|
expect(screen.getByText("Loading admin data...")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows error state on API failure", async () => {
|
|
vi.spyOn(client.admin, "getSettings").mockRejectedValue(
|
|
new client.ApiError(500, "Internal Server Error", {}),
|
|
);
|
|
vi.spyOn(client.admin, "getStats").mockRejectedValue(
|
|
new client.ApiError(500, "Internal Server Error", {}),
|
|
);
|
|
vi.spyOn(client.endpoints, "list").mockRejectedValue(
|
|
new client.ApiError(500, "Internal Server Error", {}),
|
|
);
|
|
vi.spyOn(client.webhooks, "list").mockRejectedValue(
|
|
new client.ApiError(500, "Internal Server Error", {}),
|
|
);
|
|
|
|
renderAdmin();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
});
|
|
expect(screen.getByText(/Failed to load admin data/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows network error message on non-API errors", async () => {
|
|
vi.spyOn(client.admin, "getSettings").mockRejectedValue(
|
|
new Error("fetch failed"),
|
|
);
|
|
vi.spyOn(client.admin, "getStats").mockResolvedValue({});
|
|
vi.spyOn(client.endpoints, "list").mockResolvedValue({
|
|
items: [],
|
|
total: 0,
|
|
});
|
|
vi.spyOn(client.webhooks, "list").mockResolvedValue({
|
|
items: [],
|
|
total: 0,
|
|
});
|
|
|
|
renderAdmin();
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText("Network error. Is the server running?"),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders settings section after loading", async () => {
|
|
mockAllApis();
|
|
renderAdmin();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Settings")).toBeInTheDocument();
|
|
});
|
|
|
|
// Guest access toggle (should be off)
|
|
const toggle = screen.getByTestId("guest-access-toggle");
|
|
expect(toggle).toHaveAttribute("aria-checked", "false");
|
|
|
|
// Default endpoint should be selected
|
|
const endpointSelect = screen.getByTestId(
|
|
"default-endpoint-select",
|
|
) as HTMLSelectElement;
|
|
expect(endpointSelect.value).toBe("ep1");
|
|
|
|
// Token budgets
|
|
const daily = screen.getByTestId("budget-daily") as HTMLInputElement;
|
|
expect(daily.value).toBe("100000");
|
|
const monthly = screen.getByTestId("budget-monthly") as HTMLInputElement;
|
|
expect(monthly.value).toBe("2000000");
|
|
});
|
|
|
|
it("toggles guest access and saves", async () => {
|
|
mockAllApis();
|
|
const user = userEvent.setup();
|
|
renderAdmin();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("guest-access-toggle")).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByTestId("guest-access-toggle"));
|
|
|
|
await waitFor(() => {
|
|
expect(client.admin.updateSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({ guest_access_enabled: true }),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("renders system stats", async () => {
|
|
mockAllApis();
|
|
renderAdmin();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("System Stats")).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByTestId("stat-total-runs")).toHaveTextContent("1,234");
|
|
expect(screen.getByTestId("stat-total-experiments")).toHaveTextContent("56");
|
|
expect(screen.getByTestId("stat-cache-hit-rate")).toHaveTextContent("73.0%");
|
|
expect(screen.getByTestId("stat-tokens-spent")).toHaveTextContent("456,789");
|
|
});
|
|
|
|
it("renders API keys and revoke works", async () => {
|
|
mockAllApis();
|
|
const user = userEvent.setup();
|
|
renderAdmin();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("API Keys")).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByText("CI Pipeline")).toBeInTheDocument();
|
|
expect(screen.getByText(/pl_abc/)).toBeInTheDocument();
|
|
|
|
// Revoke key
|
|
await user.click(screen.getByTestId("revoke-key-key1"));
|
|
|
|
await waitFor(() => {
|
|
expect(client.admin.updateSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: "revoke_api_key",
|
|
key_id: "key1",
|
|
}),
|
|
);
|
|
});
|
|
|
|
// Key should be removed from UI
|
|
await waitFor(() => {
|
|
expect(screen.queryByText("CI Pipeline")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("generates a new API key", async () => {
|
|
mockAllApis();
|
|
vi.spyOn(client.admin, "updateSettings").mockResolvedValue({
|
|
api_key: {
|
|
id: "key2",
|
|
label: "Dev Key",
|
|
prefix: "pl_xyz",
|
|
created_at: "2026-04-07T12:00:00Z",
|
|
},
|
|
} as unknown as Record<string, unknown>);
|
|
|
|
const user = userEvent.setup();
|
|
renderAdmin();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("api-key-label")).toBeInTheDocument();
|
|
});
|
|
|
|
await user.type(screen.getByTestId("api-key-label"), "Dev Key");
|
|
await user.click(screen.getByTestId("generate-key-btn"));
|
|
|
|
await waitFor(() => {
|
|
expect(client.admin.updateSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: "generate_api_key",
|
|
label: "Dev Key",
|
|
}),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Key")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("renders webhooks and supports delete", async () => {
|
|
mockAllApis();
|
|
const user = userEvent.setup();
|
|
renderAdmin();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Webhooks")).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByText("1 webhook configured")).toBeInTheDocument();
|
|
expect(screen.getByText("run.completed")).toBeInTheDocument();
|
|
expect(screen.getByText("https://example.com/hook")).toBeInTheDocument();
|
|
|
|
// Delete webhook
|
|
await user.click(screen.getByTestId("delete-webhook-wh1"));
|
|
|
|
await waitFor(() => {
|
|
expect(client.webhooks.delete).toHaveBeenCalledWith("wh1");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.queryByText("https://example.com/hook"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("creates a new webhook", async () => {
|
|
mockAllApis();
|
|
const user = userEvent.setup();
|
|
renderAdmin();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("add-webhook-btn")).toBeInTheDocument();
|
|
});
|
|
|
|
// Open form
|
|
await user.click(screen.getByTestId("add-webhook-btn"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("webhook-form")).toBeInTheDocument();
|
|
});
|
|
|
|
await user.type(
|
|
screen.getByTestId("webhook-url-input"),
|
|
"https://new.com/hook",
|
|
);
|
|
await user.click(screen.getByTestId("create-webhook-btn"));
|
|
|
|
await waitFor(() => {
|
|
expect(client.webhooks.create).toHaveBeenCalledWith({
|
|
event_type: "run.started",
|
|
url: "https://new.com/hook",
|
|
});
|
|
});
|
|
|
|
// New webhook should appear
|
|
await waitFor(() => {
|
|
expect(screen.getByText("https://new.com/hook")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("retry button reloads data after error", async () => {
|
|
const getSettingsSpy = vi
|
|
.spyOn(client.admin, "getSettings")
|
|
.mockRejectedValueOnce(new client.ApiError(500, "Error", {}))
|
|
.mockResolvedValueOnce(MOCK_SETTINGS as unknown as Record<string, unknown>);
|
|
|
|
vi.spyOn(client.admin, "getStats")
|
|
.mockRejectedValueOnce(new client.ApiError(500, "Error", {}))
|
|
.mockResolvedValueOnce(MOCK_STATS as unknown as Record<string, unknown>);
|
|
vi.spyOn(client.endpoints, "list")
|
|
.mockRejectedValueOnce(new client.ApiError(500, "Error", {}))
|
|
.mockResolvedValueOnce({
|
|
items: MOCK_ENDPOINTS,
|
|
total: MOCK_ENDPOINTS.length,
|
|
});
|
|
vi.spyOn(client.webhooks, "list")
|
|
.mockRejectedValueOnce(new client.ApiError(500, "Error", {}))
|
|
.mockResolvedValueOnce({
|
|
items: MOCK_WEBHOOKS,
|
|
total: MOCK_WEBHOOKS.length,
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
renderAdmin();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByText("Retry"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Settings")).toBeInTheDocument();
|
|
});
|
|
|
|
expect(getSettingsSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("shows empty API keys message when none exist", async () => {
|
|
vi.spyOn(client.admin, "getSettings").mockResolvedValue({
|
|
...MOCK_SETTINGS,
|
|
api_keys: [],
|
|
} as unknown as Record<string, unknown>);
|
|
vi.spyOn(client.admin, "getStats").mockResolvedValue(
|
|
MOCK_STATS as unknown as Record<string, unknown>,
|
|
);
|
|
vi.spyOn(client.endpoints, "list").mockResolvedValue({
|
|
items: MOCK_ENDPOINTS,
|
|
total: MOCK_ENDPOINTS.length,
|
|
});
|
|
vi.spyOn(client.webhooks, "list").mockResolvedValue({
|
|
items: [],
|
|
total: 0,
|
|
});
|
|
|
|
renderAdmin();
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText("No API keys generated yet."),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("updates default endpoint selection", async () => {
|
|
mockAllApis();
|
|
const user = userEvent.setup();
|
|
renderAdmin();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("default-endpoint-select")).toBeInTheDocument();
|
|
});
|
|
|
|
await user.selectOptions(
|
|
screen.getByTestId("default-endpoint-select"),
|
|
"ep2",
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(client.admin.updateSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({ default_endpoint_id: "ep2" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("updates token budget values", async () => {
|
|
mockAllApis();
|
|
const user = userEvent.setup();
|
|
renderAdmin();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("budget-daily")).toBeInTheDocument();
|
|
});
|
|
|
|
const dailyInput = screen.getByTestId("budget-daily");
|
|
await user.clear(dailyInput);
|
|
await user.type(dailyInput, "50000");
|
|
|
|
await waitFor(() => {
|
|
expect(client.admin.updateSettings).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("shows no webhooks configured message when empty", async () => {
|
|
vi.spyOn(client.admin, "getSettings").mockResolvedValue(
|
|
MOCK_SETTINGS as unknown as Record<string, unknown>,
|
|
);
|
|
vi.spyOn(client.admin, "getStats").mockResolvedValue(
|
|
MOCK_STATS as unknown as Record<string, unknown>,
|
|
);
|
|
vi.spyOn(client.endpoints, "list").mockResolvedValue({
|
|
items: MOCK_ENDPOINTS,
|
|
total: MOCK_ENDPOINTS.length,
|
|
});
|
|
vi.spyOn(client.webhooks, "list").mockResolvedValue({
|
|
items: [],
|
|
total: 0,
|
|
});
|
|
|
|
renderAdmin();
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText("No webhooks configured."),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("shows storage usage formatted correctly", async () => {
|
|
mockAllApis();
|
|
renderAdmin();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("stat-storage-usage")).toHaveTextContent(
|
|
"50.0 MB",
|
|
);
|
|
});
|
|
});
|
|
});
|