promptlooper/frontend/src/pages/AdminPage.test.tsx

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",
);
});
});
});