diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx index 13472a3d1df..e1d02e9352d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx @@ -40,14 +40,23 @@ const renderDashboard = () => , ); +const REQUESTS_CHART_TITLE = "Cache Hits vs API Requests"; +const TOKENS_CHART_TITLE = "Cached Completion Tokens vs Generated Completion Tokens"; + +// Anchored on each chart's own title rather than on a global card count, so +// adding cards elsewhere on the page cannot silently repoint these assertions. +const cardTitled = (title: string): HTMLElement => { + const card = screen.getByText(title).closest('[data-slot="card"]'); + expect(card).not.toBeNull(); + return card as HTMLElement; +}; + const findChartCards = async () => { - await screen.findByText("Cache Hits vs API Requests"); + await screen.findByText(REQUESTS_CHART_TITLE); await waitFor(() => { expect(document.querySelectorAll("path.recharts-rectangle").length).toBeGreaterThan(0); }); - const cards = Array.from(document.querySelectorAll('[data-slot="card"]')); - expect(cards).toHaveLength(2); - return { requestsCard: cards[0] as HTMLElement, tokensCard: cards[1] as HTMLElement }; + return { requestsCard: cardTitled(REQUESTS_CHART_TITLE), tokensCard: cardTitled(TOKENS_CHART_TITLE) }; }; const barFills = (card: HTMLElement) => diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.test.tsx new file mode 100644 index 00000000000..de06ef07ed1 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.test.tsx @@ -0,0 +1,148 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import { CacheHealthTab } from "./cache_health"; + +const healthyResponse = { + status: "healthy", + ping_response: true, + set_cache_response: "success", + litellm_cache_params: JSON.stringify({ type: "redis", supported_call_types: ["acompletion"] }), + health_check_cache_params: JSON.stringify({ + redis_version: "7.2.1", + namespace: "litellm-ns", + connection_kwargs: { host: "redis.internal", port: 6379 }, + }), +}; + +const errorPayload = { + message: "Connection refused", + traceback: "Traceback (most recent call last): ...", + litellm_cache_params: { type: "redis" }, + health_check_cache_params: {}, +}; + +const errorResponse = { error: { message: JSON.stringify(errorPayload) } }; + +const renderTab = (overrides: Partial> = {}) => + renderWithProviders( + , + ); + +describe("CacheHealthTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("offers a health check button and no results before one is run", () => { + renderTab(); + + expect(screen.getByRole("button", { name: "Run Health Check" })).toBeInTheDocument(); + expect(screen.queryByText(/Cache Status:/)).not.toBeInTheDocument(); + }); + + it("runs the health check when the button is clicked", async () => { + const runCachingHealthCheck = vi.fn(); + const user = userEvent.setup(); + renderTab({ runCachingHealthCheck }); + + await user.click(screen.getByRole("button", { name: "Run Health Check" })); + + expect(runCachingHealthCheck).toHaveBeenCalledTimes(1); + }); + + it("shows an in-flight label and disables the button while the check runs", async () => { + const runCachingHealthCheck = vi.fn(() => new Promise(() => {})); + const user = userEvent.setup(); + renderTab({ runCachingHealthCheck }); + + await user.click(screen.getByRole("button", { name: "Run Health Check" })); + + const button = await screen.findByRole("button", { name: "Running Health Check..." }); + expect(button).toBeDisabled(); + }); + + it("reports a healthy cache with its ping and set-cache results", async () => { + renderTab({ healthCheckResponse: healthyResponse }); + + expect(await screen.findByText("Cache Status: healthy")).toBeInTheDocument(); + expect(screen.getByText("Cache Details")).toBeInTheDocument(); + expect(screen.getByText("Ping Response")).toBeInTheDocument(); + expect(screen.getByText("Set Cache Response")).toBeInTheDocument(); + expect(screen.getByText("success")).toBeInTheDocument(); + }); + + it("shows the Redis detail rows when the cache type is redis", async () => { + renderTab({ healthCheckResponse: healthyResponse }); + + expect(await screen.findByText("Redis Details")).toBeInTheDocument(); + expect(screen.getByText("Redis Host")).toBeInTheDocument(); + expect(screen.getByText("redis.internal")).toBeInTheDocument(); + expect(screen.getByText("Redis Port")).toBeInTheDocument(); + expect(screen.getByText("Redis Version")).toBeInTheDocument(); + expect(screen.getByText("7.2.1")).toBeInTheDocument(); + expect(screen.getByText("Namespace")).toBeInTheDocument(); + expect(screen.getByText("litellm-ns")).toBeInTheDocument(); + }); + + it("omits the Redis detail rows for a non-redis cache type", async () => { + renderTab({ + healthCheckResponse: { + status: "healthy", + ping_response: true, + litellm_cache_params: JSON.stringify({ type: "local" }), + health_check_cache_params: JSON.stringify({}), + }, + }); + + expect(await screen.findByText("Cache Status: healthy")).toBeInTheDocument(); + expect(screen.queryByText("Redis Details")).not.toBeInTheDocument(); + }); + + it("surfaces the error message and traceback when the check fails", async () => { + renderTab({ healthCheckResponse: errorResponse }); + + expect(await screen.findByText("Error Details")).toBeInTheDocument(); + expect(screen.getByText("Error Message")).toBeInTheDocument(); + expect(screen.getByText("Connection refused")).toBeInTheDocument(); + expect(screen.getByText("Traceback")).toBeInTheDocument(); + expect(screen.getByText("Cache Status: unhealthy")).toBeInTheDocument(); + }); + + it("still shows the cache details section when the check failed", async () => { + renderTab({ healthCheckResponse: errorResponse }); + + expect(await screen.findByText("Cache Details")).toBeInTheDocument(); + }); + + it("truncates a long value and expands it to the full value on click", async () => { + const longMessage = "M".repeat(120); + const user = userEvent.setup(); + renderTab({ + healthCheckResponse: { + error: { message: JSON.stringify({ message: longMessage, traceback: "short" }) }, + }, + }); + + await screen.findByText("Error Message"); + expect(screen.getByText(`${"M".repeat(50)}...`)).toBeInTheDocument(); + expect(screen.queryByText(longMessage)).not.toBeInTheDocument(); + + await user.click(screen.getAllByRole("button", { name: "▶" })[0]); + + await waitFor(() => { + expect(screen.getByText(longMessage)).toBeInTheDocument(); + }); + }); + + it("offers both the summary and raw response views", async () => { + renderTab({ healthCheckResponse: healthyResponse }); + + expect(await screen.findByText("Summary")).toBeInTheDocument(); + expect(screen.getByText("Raw Response")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.test.tsx new file mode 100644 index 00000000000..76287e6e724 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.test.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import CoordinationRedisTypeSelector from "./CoordinationRedisTypeSelector"; +import { COORDINATION_REDIS_TYPE_DESCRIPTIONS } from "./coordinationRedisFields"; + +describe("CoordinationRedisTypeSelector", () => { + it("labels the control and shows the current selection", () => { + renderWithProviders(); + + expect(screen.getByText("Redis Type")).toBeInTheDocument(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + expect(screen.getByText("Node (Single Instance)")).toBeInTheDocument(); + }); + + it("shows the description for the selected type", () => { + renderWithProviders(); + + expect(screen.getByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.cluster)).toBeInTheDocument(); + }); + + it("switches the description when the selected type changes", () => { + const { rerender } = renderWithProviders(); + expect(screen.getByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.node)).toBeInTheDocument(); + + rerender(); + + expect(screen.getByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.sentinel)).toBeInTheDocument(); + expect(screen.queryByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.node)).not.toBeInTheDocument(); + }); + + it("reports the newly picked type to the caller", async () => { + const onTypeChange = vi.fn(); + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("Cluster")); + + expect(onTypeChange).toHaveBeenCalledTimes(1); + expect(onTypeChange.mock.calls[0][0]).toBe("cluster"); + }); + + it("offers every supported coordination redis type", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("combobox")); + + expect(await screen.findByText("Cluster")).toBeInTheDocument(); + expect(screen.getByText("Sentinel")).toBeInTheDocument(); + expect(screen.getAllByText("Node (Single Instance)").length).toBeGreaterThan(0); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.test.tsx new file mode 100644 index 00000000000..4db75fb1ced --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.test.tsx @@ -0,0 +1,230 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import AiSuggestionModal from "./ai_suggestion_modal"; + +const { suggestPolicyTemplates, modelHubCall, testPolicyTemplate, enrichPolicyTemplateStream } = vi.hoisted(() => ({ + suggestPolicyTemplates: vi.fn(), + modelHubCall: vi.fn(), + testPolicyTemplate: vi.fn(), + enrichPolicyTemplateStream: vi.fn(), +})); + +vi.mock("@/components/networking", () => ({ + suggestPolicyTemplates, + modelHubCall, + testPolicyTemplate, + enrichPolicyTemplateStream, +})); + +const allTemplates = [ + { id: "tpl-pii", title: "PII Protection", description: "Masks PII", guardrails: ["pii-masker"], complexity: "Low" }, + { + id: "tpl-inj", + title: "Injection Defense", + description: "Blocks prompt injection", + guardrails: ["prompt-injection"], + complexity: "Medium", + }, +]; + +const suggestResponse = { + selected_templates: [ + { template_id: "tpl-pii", reason: "Your examples contain SSNs" }, + { template_id: "tpl-inj", reason: "Your examples contain instruction overrides" }, + ], + explanation: "These two cover both risks you described", +}; + +const defaultProps = { + visible: true, + onSelectTemplates: vi.fn(), + onCancel: vi.fn(), + accessToken: "sk-test", + allTemplates, +}; + +const renderModal = (props: Partial = {}) => + renderWithProviders(); + +const pickModel = async (user: ReturnType) => { + await user.click(screen.getByRole("combobox")); + const options = await screen.findAllByText("gpt-5.1"); + await user.click(options[options.length - 1]); +}; + +describe("AiSuggestionModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + modelHubCall.mockResolvedValue({ data: [{ model_group: "gpt-5.1" }, { model_group: "claude-opus-4-8" }] }); + suggestPolicyTemplates.mockResolvedValue(suggestResponse); + }); + + it("renders nothing while closed", () => { + renderModal({ visible: false }); + + expect(screen.queryByText("AI Policy Suggestion")).not.toBeInTheDocument(); + }); + + it("renders the header and prompt copy when opened", async () => { + renderModal(); + + expect(await screen.findByText("AI Policy Suggestion")).toBeInTheDocument(); + expect( + screen.getByText("Describe what you want to block and we'll suggest the best policy templates"), + ).toBeInTheDocument(); + }); + + it("loads the model list when opened", async () => { + renderModal(); + + await waitFor(() => { + expect(modelHubCall).toHaveBeenCalledWith("sk-test"); + }); + }); + + it("keeps Suggest disabled until there is both input and a model", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + expect(screen.getByRole("button", { name: "Suggest Policies" })).toBeDisabled(); + + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + expect(screen.getByRole("button", { name: "Suggest Policies" })).toBeDisabled(); + + await pickModel(user); + expect(screen.getByRole("button", { name: "Suggest Policies" })).not.toBeDisabled(); + }); + + it("sends the examples, description and model to the suggest API", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Ignore all previous instructions/), "my ssn is 123"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + + await waitFor(() => { + expect(suggestPolicyTemplates).toHaveBeenCalledWith("sk-test", ["my ssn is 123"], "block PII", "gpt-5.1"); + }); + }); + + it("adds attack example fields up to the maximum of four", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + const countExamples = () => screen.getAllByRole("textbox").length; + const initial = countExamples(); + + await user.click(screen.getByRole("button", { name: "+ Add another example" })); + expect(countExamples()).toBe(initial + 1); + + await user.click(screen.getByRole("button", { name: "+ Add another example" })); + await user.click(screen.getByRole("button", { name: "+ Add another example" })); + expect(countExamples()).toBe(initial + 3); + expect(screen.queryByRole("button", { name: "+ Add another example" })).not.toBeInTheDocument(); + }); + + it("shows each suggested template with the reason it was picked", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + + expect(await screen.findByText("PII Protection")).toBeInTheDocument(); + expect(screen.getByText("Injection Defense")).toBeInTheDocument(); + expect(screen.getByText("Your examples contain SSNs")).toBeInTheDocument(); + expect(screen.getByText("These two cover both risks you described")).toBeInTheDocument(); + expect(screen.getByText("2 templates matched your requirements")).toBeInTheDocument(); + }); + + it("preselects every suggestion and reflects the count on the confirm button", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + + expect(await screen.findByRole("button", { name: "Use 2 Selected Templates" })).toBeInTheDocument(); + }); + + it("deselecting a suggestion lowers the confirm count", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + + await user.click(await screen.findByText("PII Protection")); + + expect(await screen.findByRole("button", { name: "Use 1 Selected Template" })).toBeInTheDocument(); + }); + + it("hands the selected templates back to the caller", async () => { + const onSelectTemplates = vi.fn(); + const user = userEvent.setup(); + renderModal({ onSelectTemplates }); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + await user.click(await screen.findByRole("button", { name: "Use 2 Selected Templates" })); + + expect(onSelectTemplates).toHaveBeenCalledTimes(1); + expect(onSelectTemplates.mock.calls[0][0].map((t: { id: string }) => t.id)).toEqual(["tpl-pii", "tpl-inj"]); + }); + + it("returns to the input phase from the results phase", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + await user.click(await screen.findByRole("button", { name: "Back" })); + + expect( + await screen.findByText("Describe what you want to block and we'll suggest the best policy templates"), + ).toBeInTheDocument(); + }); + + it("reports an empty result set instead of failing silently", async () => { + suggestPolicyTemplates.mockRejectedValue(new Error("boom")); + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + + expect(await screen.findByText("No matching templates found")).toBeInTheDocument(); + expect(screen.getByText("Try adjusting your examples or description.")).toBeInTheDocument(); + }); + + it("cancels back to the caller", async () => { + const onCancel = vi.fn(); + const user = userEvent.setup(); + renderModal({ onCancel }); + + await screen.findByText("AI Policy Suggestion"); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.test.tsx new file mode 100644 index 00000000000..cb763c2d22f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.test.tsx @@ -0,0 +1,169 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import PipelineFlowBuilder, { PipelineInfoDisplay } from "./pipeline_flow_builder"; +import { GuardrailPipeline, PipelineStep } from "@/components/policies/types"; +import { Guardrail } from "@/components/guardrails/types"; + +vi.mock("@/components/networking"); + +const step = (overrides: Partial = {}): PipelineStep => ({ + guardrail: "pii-masker", + on_pass: "next", + on_fail: "block", + on_error: null, + modify_response_message: null, + ...overrides, +}); + +const pipeline = (steps: PipelineStep[]): GuardrailPipeline => ({ mode: "pre_call", steps }); + +const guardrails = [ + { guardrail_id: "g1", guardrail_name: "pii-masker" }, + { guardrail_id: "g2", guardrail_name: "prompt-injection" }, +] as Guardrail[]; + +describe("PipelineInfoDisplay", () => { + it("renders the trigger card", () => { + renderWithProviders(); + + expect(screen.getByText("TRIGGER")).toBeInTheDocument(); + expect(screen.getByText("Incoming LLM Request")).toBeInTheDocument(); + }); + + it("renders one numbered card per step, naming its guardrail", () => { + renderWithProviders(); + + expect(screen.getByText("Step 1")).toBeInTheDocument(); + expect(screen.getByText("Step 2")).toBeInTheDocument(); + expect(screen.getByText("pii-masker")).toBeInTheDocument(); + expect(screen.getByText("prompt-injection")).toBeInTheDocument(); + expect(screen.getAllByText("GUARDRAIL")).toHaveLength(2); + }); + + it("maps raw action values to their human labels", () => { + renderWithProviders(); + + expect(screen.getByText(/Pass .* Next Step/)).toBeInTheDocument(); + expect(screen.getByText(/On fail .* Block/)).toBeInTheDocument(); + }); + + it("falls back to the on-fail action when no API-failure action is set", () => { + renderWithProviders(); + + expect(screen.getByText(/On API failure .* Block \(same as on fail\)/)).toBeInTheDocument(); + }); + + it("shows an explicit API-failure action when one is set", () => { + renderWithProviders(); + + expect(screen.getByText(/On API failure .* Allow/)).toBeInTheDocument(); + expect(screen.queryByText(/same as on fail/)).not.toBeInTheDocument(); + }); +}); + +describe("PipelineFlowBuilder", () => { + it("renders the trigger and end cards around the steps", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("TRIGGER")).toBeInTheDocument(); + expect(screen.getByText("END")).toBeInTheDocument(); + expect(screen.getByText("Continue to LLM")).toBeInTheDocument(); + }); + + it("labels each decision section of a step", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("ON PASS")).toBeInTheDocument(); + expect(screen.getByText("ON FAIL")).toBeInTheDocument(); + expect(screen.getByText("ON API FAILURE")).toBeInTheDocument(); + }); + + it("inserts a step at the clicked connector", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getAllByRole("button", { name: "Insert step" })[0]); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].steps).toHaveLength(2); + }); + + it("removes the clicked step when more than one exists", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getAllByRole("button", { name: "Delete step" })[0]); + + expect(onChange.mock.calls[0][0].steps).toHaveLength(1); + expect(onChange.mock.calls[0][0].steps[0].guardrail).toBe("prompt-injection"); + }); + + it("disables deletion of the only remaining step", () => { + renderWithProviders( + , + ); + + expect(screen.getByRole("button", { name: "Delete step" })).toBeDisabled(); + }); + + it("offers a custom response field only when the action is modify_response", () => { + const { rerender } = renderWithProviders( + , + ); + expect(screen.queryByPlaceholderText("Enter custom response...")).not.toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByPlaceholderText("Enter custom response...")).toBeInTheDocument(); + }); + + it("reports an edited custom response message", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.type(screen.getByPlaceholderText("Enter custom response..."), "x"); + + expect(onChange.mock.calls[0][0].steps[0].modify_response_message).toBe("x"); + }); + + it("offers a guardrail picker for the step", () => { + renderWithProviders( + , + ); + + // Which control surfaces the selection is a presentation detail; that the step's + // guardrail is the one displayed is covered by the PipelineInfoDisplay tests above. + expect(screen.getByText("Guardrail")).toBeInTheDocument(); + expect(screen.getAllByRole("combobox").length).toBeGreaterThan(0); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/template_parameter_modal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/template_parameter_modal.test.tsx new file mode 100644 index 00000000000..1af45012d54 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/template_parameter_modal.test.tsx @@ -0,0 +1,206 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import TemplateParameterModal from "./template_parameter_modal"; + +const { modelHubCall, enrichPolicyTemplateStream } = vi.hoisted(() => ({ + modelHubCall: vi.fn(), + enrichPolicyTemplateStream: vi.fn(), +})); + +vi.mock("@/components/networking", () => ({ modelHubCall, enrichPolicyTemplateStream })); + +type StreamResult = { competitors: string[]; competitor_variations?: Record }; +type StreamArgs = [ + token: string, + templateId: string, + params: Record, + model: string, + onName: (name: string) => void, + onDone: (result: StreamResult) => void, +]; + +interface TestTemplate { + id: string; + title: string; + llm_enrichment?: { parameter: string }; + parameters: { name: string; label: string; type: string; required: boolean; placeholder?: string }[]; +} + +const plainTemplate: TestTemplate = { + id: "tpl-plain", + title: "Basic Redaction", + parameters: [ + { name: "org_name", label: "Organization Name", type: "string", required: true, placeholder: "e.g. Contoso" }, + { name: "note", label: "Note", type: "string", required: false, placeholder: "optional note" }, + ], +}; + +const enrichmentTemplate: TestTemplate = { + id: "tpl-competitor", + title: "Competitor Blocking", + llm_enrichment: { parameter: "brand_name" }, + parameters: [{ name: "brand_name", label: "Your Brand Name", type: "string", required: true }], +}; + +const defaultProps = { + visible: true, + template: plainTemplate, + onConfirm: vi.fn(), + onCancel: vi.fn(), + accessToken: "sk-test", +}; + +const renderModal = (props: Partial = {}) => + renderWithProviders(); + +describe("TemplateParameterModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + modelHubCall.mockResolvedValue({ data: [{ model_group: "gpt-5.1" }] }); + }); + + it("renders nothing while closed", () => { + renderModal({ visible: false }); + + expect(screen.queryByText("Basic Redaction")).not.toBeInTheDocument(); + }); + + it("shows the template title and purpose when opened", async () => { + renderModal(); + + expect(await screen.findByText("Basic Redaction")).toBeInTheDocument(); + expect(screen.getByText("Configure competitor blocking for your brand")).toBeInTheDocument(); + }); + + it("renders a labelled field per template parameter and marks the required ones", async () => { + renderModal(); + + expect((await screen.findAllByText("Organization Name")).length).toBeGreaterThan(0); + expect(screen.getAllByText("Note").length).toBeGreaterThan(0); + expect(screen.getAllByPlaceholderText("e.g. Contoso").length).toBeGreaterThan(0); + expect(screen.getAllByPlaceholderText("optional note").length).toBeGreaterThan(0); + }); + + it("keeps Continue disabled until every required parameter is filled", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("Basic Redaction"); + expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled(); + + await user.type(screen.getAllByPlaceholderText("e.g. Contoso")[0], "Contoso"); + + expect(screen.getByRole("button", { name: "Continue" })).not.toBeDisabled(); + }); + + it("hands the entered parameters back to the caller", async () => { + const onConfirm = vi.fn(); + const user = userEvent.setup(); + renderModal({ onConfirm }); + + await screen.findByText("Basic Redaction"); + await user.type(screen.getAllByPlaceholderText("e.g. Contoso")[0], "Contoso"); + await user.click(screen.getByRole("button", { name: "Continue" })); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm.mock.calls[0][0]).toEqual({ org_name: "Contoso", note: "" }); + }); + + it("cancels back to the caller", async () => { + const onCancel = vi.fn(); + const user = userEvent.setup(); + renderModal({ onCancel }); + + await screen.findByText("Basic Redaction"); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("offers AI discovery controls for an enrichment template", async () => { + renderModal({ template: enrichmentTemplate }); + + expect(await screen.findByText("Competitor Discovery")).toBeInTheDocument(); + expect(screen.getByText("Your Brand Name")).toBeInTheDocument(); + expect(screen.getByText("Select Model")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Generate Competitor Names/ })).toBeInTheDocument(); + }); + + it("loads the model list for an enrichment template", async () => { + renderModal({ template: enrichmentTemplate }); + + await waitFor(() => { + expect(modelHubCall).toHaveBeenCalledWith("sk-test"); + }); + }); + + it("hides the model picker when competitors are entered manually", async () => { + const user = userEvent.setup(); + renderModal({ template: enrichmentTemplate }); + + await screen.findByText("Competitor Discovery"); + await user.click(screen.getByText("Enter Manually")); + + await waitFor(() => { + expect(screen.queryByText("Select Model")).not.toBeInTheDocument(); + }); + expect(screen.queryByRole("button", { name: /Generate Competitor Names/ })).not.toBeInTheDocument(); + }); + + it("keeps Continue disabled for an enrichment template until competitors exist", async () => { + const user = userEvent.setup(); + renderModal({ template: enrichmentTemplate }); + + await screen.findByText("Competitor Discovery"); + await user.type(screen.getByPlaceholderText("e.g. Acme Airlines"), "Contoso"); + + expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled(); + }); + + it("streams discovered competitor names and enables Continue once they arrive", async () => { + enrichPolicyTemplateStream.mockImplementation(async (...args: StreamArgs) => { + const [, , , , onName, onDone] = args; + onName("Northwind"); + onDone({ competitors: ["Northwind", "Fabrikam"], competitor_variations: {} }); + }); + const user = userEvent.setup(); + renderModal({ template: enrichmentTemplate }); + + await screen.findByText("Competitor Discovery"); + await user.type(screen.getByPlaceholderText("e.g. Acme Airlines"), "Contoso"); + await user.click(screen.getAllByRole("combobox")[0]); + const options = await screen.findAllByText("gpt-5.1"); + await user.click(options[options.length - 1]); + await user.click(screen.getByRole("button", { name: /Generate Competitor Names/ })); + + expect(await screen.findByText("Northwind")).toBeInTheDocument(); + expect(screen.getByText("Fabrikam")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Continue" })).not.toBeDisabled(); + }); + }); + + it("passes the discovered competitors to the caller on confirm", async () => { + enrichPolicyTemplateStream.mockImplementation(async (...args: StreamArgs) => { + const [, , , , , onDone] = args; + onDone({ competitors: ["Northwind"] }); + }); + const onConfirm = vi.fn(); + const user = userEvent.setup(); + renderModal({ template: enrichmentTemplate, onConfirm }); + + await screen.findByText("Competitor Discovery"); + await user.type(screen.getByPlaceholderText("e.g. Acme Airlines"), "Contoso"); + await user.click(screen.getAllByRole("combobox")[0]); + const options = await screen.findAllByText("gpt-5.1"); + await user.click(options[options.length - 1]); + await user.click(screen.getByRole("button", { name: /Generate Competitor Names/ })); + await screen.findByText("Northwind"); + await user.click(screen.getByRole("button", { name: "Continue" })); + + expect(onConfirm).toHaveBeenCalledWith({ brand_name: "Contoso" }, { competitors: ["Northwind"] }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/email_events/email_event_settings.test.tsx b/ui/litellm-dashboard/src/components/email_events/email_event_settings.test.tsx new file mode 100644 index 00000000000..b9737ccd5be --- /dev/null +++ b/ui/litellm-dashboard/src/components/email_events/email_event_settings.test.tsx @@ -0,0 +1,132 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import EmailEventSettings from "./email_event_settings"; + +const { getEmailEventSettings, updateEmailEventSettings, resetEmailEventSettings } = vi.hoisted(() => ({ + getEmailEventSettings: vi.fn(), + updateEmailEventSettings: vi.fn(), + resetEmailEventSettings: vi.fn(), +})); + +vi.mock("@/components/networking", () => ({ + getEmailEventSettings, + updateEmailEventSettings, + resetEmailEventSettings, +})); + +const settingsResponse = { + settings: [ + { event: "Virtual Key Created", enabled: true }, + { event: "New User Invitation", enabled: false }, + ], +}; + +describe("EmailEventSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + getEmailEventSettings.mockResolvedValue(settingsResponse); + updateEmailEventSettings.mockResolvedValue({}); + resetEmailEventSettings.mockResolvedValue({}); + }); + + it("renders the heading and the explanatory copy", async () => { + renderWithProviders(); + + expect(await screen.findByText("Email Notifications")).toBeInTheDocument(); + expect(screen.getByText("Select which events should trigger email notifications.")).toBeInTheDocument(); + }); + + it("renders one checkbox per event, reflecting the persisted enabled state", async () => { + renderWithProviders(); + + await screen.findByText("Virtual Key Created"); + const checkboxes = screen.getAllByRole("checkbox"); + + expect(checkboxes).toHaveLength(2); + expect(checkboxes[0]).toBeChecked(); + expect(checkboxes[1]).not.toBeChecked(); + }); + + it("renders a per-event description", async () => { + renderWithProviders(); + + expect( + await screen.findByText(/An email will be sent to the user when a new virtual key is created/), + ).toBeInTheDocument(); + expect( + screen.getByText(/An email will be sent to the email address of the user when a new user is created/), + ).toBeInTheDocument(); + }); + + it("saves the toggled enabled flags rather than the originally fetched ones", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("Virtual Key Created"); + await user.click(screen.getAllByRole("checkbox")[1]); + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(updateEmailEventSettings).toHaveBeenCalledWith("sk-test", { + settings: [ + { event: "Virtual Key Created", enabled: true }, + { event: "New User Invitation", enabled: true }, + ], + }); + }); + }); + + it("unchecking an enabled event is persisted as disabled", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("Virtual Key Created"); + await user.click(screen.getAllByRole("checkbox")[0]); + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(updateEmailEventSettings).toHaveBeenCalledWith("sk-test", { + settings: [ + { event: "Virtual Key Created", enabled: false }, + { event: "New User Invitation", enabled: false }, + ], + }); + }); + }); + + it("resets to defaults and refetches the settings", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await screen.findByText("Virtual Key Created"); + expect(getEmailEventSettings).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole("button", { name: "Reset to Defaults" })); + + await waitFor(() => { + expect(resetEmailEventSettings).toHaveBeenCalledWith("sk-test"); + }); + await waitFor(() => { + expect(getEmailEventSettings).toHaveBeenCalledTimes(2); + }); + }); + + it("does not render event rows while the fetch is in flight", () => { + getEmailEventSettings.mockReturnValue(new Promise(() => {})); + renderWithProviders(); + + expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save Changes" })).toBeDisabled(); + }); + + it("does not call the API when there is no access token", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(getEmailEventSettings).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/email_settings.test.tsx b/ui/litellm-dashboard/src/components/email_settings.test.tsx new file mode 100644 index 00000000000..bd09ca0abc4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/email_settings.test.tsx @@ -0,0 +1,127 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import EmailSettings from "./email_settings"; + +const { serviceHealthCheck, setCallbacksCall } = vi.hoisted(() => ({ + serviceHealthCheck: vi.fn(), + setCallbacksCall: vi.fn(), +})); + +vi.mock("@/components/networking", () => ({ serviceHealthCheck, setCallbacksCall })); + +vi.mock("./email_events", () => ({ + EmailEventSettings: () =>
email event settings
, +})); + +const alerts = [ + { + name: "email", + variables: { + SMTP_HOST: "smtp.example.com", + SMTP_PORT: "587", + SMTP_PASSWORD: "********", + EMAIL_LOGO_URL: "https://example.com/logo.png", + }, + }, + { name: "slack", variables: { SLACK_WEBHOOK_URL: "https://hooks.example.com" } }, +]; + +const inputNamed = (name: string) => document.querySelector(`input[name="${name}"]`)!; + +describe("EmailSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + setCallbacksCall.mockResolvedValue({}); + serviceHealthCheck.mockResolvedValue({}); + }); + + it("renders the heading and the docs link", () => { + renderWithProviders(); + + expect(screen.getByText("Email Server Settings")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /LiteLLM Docs: email alerts/ })).toHaveAttribute( + "href", + "https://docs.litellm.ai/docs/proxy/email", + ); + }); + + it("renders one named input per email variable and none for other alert types", () => { + renderWithProviders(); + + expect(inputNamed("SMTP_HOST")).toHaveValue("smtp.example.com"); + expect(inputNamed("SMTP_PORT")).toHaveValue("587"); + expect(inputNamed("SMTP_PASSWORD")).toHaveValue("********"); + expect(document.querySelector('input[name="SLACK_WEBHOOK_URL"]')).toBeNull(); + }); + + it("labels each variable and shows its help text", () => { + renderWithProviders(); + + expect(screen.getByText("SMTP_HOST")).toBeInTheDocument(); + expect(screen.getByText(/Enter the SMTP host address/)).toBeInTheDocument(); + expect(screen.getByText(/Enter the SMTP port number/)).toBeInTheDocument(); + }); + + it("submits only the fields the admin actually edited", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.clear(inputNamed("SMTP_HOST")); + await user.type(inputNamed("SMTP_HOST"), "smtp.changed.com"); + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(setCallbacksCall).toHaveBeenCalledWith("sk-test", { + general_settings: { alerting: ["email"] }, + environment_variables: { SMTP_HOST: "smtp.changed.com" }, + }); + }); + }); + + it("does not resubmit an untouched masked value", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(setCallbacksCall).toHaveBeenCalledWith("sk-test", { + general_settings: { alerting: ["email"] }, + environment_variables: {}, + }); + }); + }); + + it("disables the premium-only fields for non-premium users", () => { + renderWithProviders(); + + expect(inputNamed("EMAIL_LOGO_URL")).toBeDisabled(); + expect(inputNamed("SMTP_HOST")).not.toBeDisabled(); + }); + + it("leaves the premium-only fields editable for premium users", () => { + renderWithProviders(); + + expect(inputNamed("EMAIL_LOGO_URL")).not.toBeDisabled(); + }); + + it("triggers a live email health check", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Test Email Alerts" })); + + await waitFor(() => { + expect(serviceHealthCheck).toHaveBeenCalledWith("sk-test", "email"); + }); + }); + + it("renders the email event settings section", () => { + renderWithProviders(); + + expect(screen.getByText("email event settings")).toBeInTheDocument(); + }); +});