diff --git a/ui/litellm-dashboard/src/components/SearchTools/CreateSearchTools.tsx b/ui/litellm-dashboard/src/components/SearchTools/CreateSearchTools.tsx index 18eaf5e493..49b57c2a88 100644 --- a/ui/litellm-dashboard/src/components/SearchTools/CreateSearchTools.tsx +++ b/ui/litellm-dashboard/src/components/SearchTools/CreateSearchTools.tsx @@ -1,14 +1,14 @@ -import React, { useState } from "react"; -import { Modal, Tooltip, Form, Select, Input, Typography } from "antd"; -import { InfoCircleOutlined } from "@ant-design/icons"; -import { Button, TextInput } from "@tremor/react"; -import { createSearchTool, fetchAvailableSearchProviders } from "../networking"; -import { SearchTool, AvailableSearchProvider } from "./types"; import { isAdminRole } from "@/utils/roles"; -import NotificationsManager from "../molecules/notifications_manager"; +import { InfoCircleOutlined } from "@ant-design/icons"; import { useQuery } from "@tanstack/react-query"; -import SearchConnectionTest from "./SearchConnectionTest"; +import { Button, TextInput } from "@tremor/react"; +import { Form, Input, Modal, Select, Tooltip, Typography } from "antd"; import Image from "next/image"; +import React, { useState } from "react"; +import NotificationsManager from "../molecules/notifications_manager"; +import { createSearchTool, fetchAvailableSearchProviders } from "../networking"; +import SearchConnectionTest from "./SearchConnectionTest"; +import { AvailableSearchProvider, SearchTool } from "./types"; const { TextArea } = Input; diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchConnectionTest.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchConnectionTest.tsx index e18d314591..a8472cb1f8 100644 --- a/ui/litellm-dashboard/src/components/SearchTools/SearchConnectionTest.tsx +++ b/ui/litellm-dashboard/src/components/SearchTools/SearchConnectionTest.tsx @@ -1,8 +1,8 @@ +import { InfoCircleOutlined, WarningOutlined } from "@ant-design/icons"; +import { Button, Divider, Typography } from "antd"; import React, { useEffect, useState } from "react"; -import { testSearchToolConnection } from "../networking"; -import { Button, Typography, Divider } from "antd"; -import { WarningOutlined, InfoCircleOutlined } from "@ant-design/icons"; import NotificationsManager from "../molecules/notifications_manager"; +import { testSearchToolConnection } from "../networking"; const { Text } = Typography; diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchToolColumn.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchToolColumn.tsx index 86b0bde4d0..e741bd06a9 100644 --- a/ui/litellm-dashboard/src/components/SearchTools/SearchToolColumn.tsx +++ b/ui/litellm-dashboard/src/components/SearchTools/SearchToolColumn.tsx @@ -1,7 +1,7 @@ -import { ColumnsType } from "antd/es/table"; -import { SearchTool } from "./types"; import { Tag } from "antd"; +import { ColumnsType } from "antd/es/table"; import TableIconActionButton from "../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; +import { SearchTool } from "./types"; export const searchToolColumns = ( onView: (searchToolId: string) => void, diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.test.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.test.tsx new file mode 100644 index 0000000000..bb04a04a99 --- /dev/null +++ b/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.test.tsx @@ -0,0 +1,278 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SearchToolView } from "./SearchToolView"; +import { AvailableSearchProvider, SearchTool } from "./types"; + +vi.mock("@/utils/dataUtils", () => ({ + copyToClipboard: vi.fn().mockResolvedValue(true), +})); + +vi.mock("./SearchToolTester", () => ({ + SearchToolTester: ({ searchToolName, accessToken }: { searchToolName: string; accessToken: string }) => ( +
+ Search Tool Tester for {searchToolName} + Access Token: {accessToken} +
+ ), +})); + +describe("SearchToolView", () => { + const mockSearchTool: SearchTool = { + search_tool_id: "test-tool-id-123", + search_tool_name: "Test Search Tool", + litellm_params: { + search_provider: "perplexity", + api_key: "sk-test-key", + }, + search_tool_info: { + description: "Test description", + }, + created_at: "2024-01-15T10:30:00Z", + }; + + const mockAvailableProviders: AvailableSearchProvider[] = [ + { + provider_name: "perplexity", + ui_friendly_name: "Perplexity AI", + }, + { + provider_name: "tavily", + ui_friendly_name: "Tavily Search", + }, + ]; + + const defaultProps = { + searchTool: mockSearchTool, + onBack: vi.fn(), + isEditing: false, + accessToken: "test-token", + availableProviders: mockAvailableProviders, + }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { copyToClipboard } = await import("@/utils/dataUtils"); + vi.mocked(copyToClipboard).mockResolvedValue(true); + }); + + it("should render", () => { + render(); + expect(screen.getByText("Test Search Tool")).toBeInTheDocument(); + }); + + it("should display search tool name", () => { + render(); + expect(screen.getByText("Test Search Tool")).toBeInTheDocument(); + }); + + it("should display search tool ID", () => { + render(); + expect(screen.getByText("test-tool-id-123")).toBeInTheDocument(); + }); + + it("should display provider name using UI-friendly name when available", () => { + render(); + expect(screen.getByText("Perplexity AI")).toBeInTheDocument(); + }); + + it("should display provider name using provider_name when UI-friendly name is not available", () => { + const searchToolWithoutProvider: SearchTool = { + ...mockSearchTool, + litellm_params: { + search_provider: "unknown-provider", + }, + }; + + render( + , + ); + expect(screen.getByText("unknown-provider")).toBeInTheDocument(); + }); + + it("should display masked API key when API key is set", () => { + render(); + expect(screen.getByText("****")).toBeInTheDocument(); + }); + + it("should display 'Not set' when API key is not set", () => { + const searchToolWithoutApiKey: SearchTool = { + ...mockSearchTool, + litellm_params: { + search_provider: "perplexity", + }, + }; + + render( + , + ); + expect(screen.getByText("Not set")).toBeInTheDocument(); + }); + + it("should display formatted created_at date", () => { + render(); + const dateText = screen.getByText(/2024-01-15/); + expect(dateText).toBeInTheDocument(); + }); + + it("should display 'Unknown' when created_at is not set", () => { + const searchToolWithoutDate: SearchTool = { + ...mockSearchTool, + created_at: undefined, + }; + + render( + , + ); + expect(screen.getByText("Unknown")).toBeInTheDocument(); + }); + + it("should display description when search_tool_info.description is provided", () => { + render(); + expect(screen.getByText("Test description")).toBeInTheDocument(); + }); + + it("should not display description card when search_tool_info.description is not provided", () => { + const searchToolWithoutDescription: SearchTool = { + ...mockSearchTool, + search_tool_info: {}, + }; + + render( + , + ); + expect(screen.queryByText("Description")).not.toBeInTheDocument(); + }); + + it("should call onBack when back button is clicked", async () => { + const user = userEvent.setup({ delay: null }); + const onBack = vi.fn(); + render(); + + const backButton = screen.getByRole("button", { name: /back to all search tools/i }); + await user.click(backButton); + + expect(onBack).toHaveBeenCalledTimes(1); + }); + + it("should copy search tool name to clipboard when copy button is clicked", async () => { + const user = userEvent.setup({ delay: null }); + const { copyToClipboard } = await import("@/utils/dataUtils"); + render(); + + const toolNameContainer = screen.getByText("Test Search Tool").closest("div"); + expect(toolNameContainer).toBeInTheDocument(); + + const copyButtons = within(toolNameContainer!).getAllByRole("button"); + const nameCopyButton = copyButtons.find((button) => { + return button.querySelector("svg") !== null; + }); + + expect(nameCopyButton).toBeInTheDocument(); + await user.click(nameCopyButton!); + + await waitFor(() => { + expect(copyToClipboard).toHaveBeenCalledWith("Test Search Tool"); + }); + }); + + it("should copy search tool ID to clipboard when copy button is clicked", async () => { + const user = userEvent.setup({ delay: null }); + const { copyToClipboard } = await import("@/utils/dataUtils"); + render(); + + const toolIdContainer = screen.getByText("test-tool-id-123").closest("div"); + expect(toolIdContainer).toBeInTheDocument(); + + const copyButtons = within(toolIdContainer!).getAllByRole("button"); + const idCopyButton = copyButtons.find((button) => { + return button.querySelector("svg") !== null; + }); + + expect(idCopyButton).toBeInTheDocument(); + await user.click(idCopyButton!); + + await waitFor(() => { + expect(copyToClipboard).toHaveBeenCalledWith("test-tool-id-123"); + }); + }); + + it("should show check icon after copying search tool name", async () => { + const user = userEvent.setup({ delay: null }); + const { copyToClipboard } = await import("@/utils/dataUtils"); + vi.mocked(copyToClipboard).mockResolvedValue(true); + + render(); + + const toolNameContainer = screen.getByText("Test Search Tool").closest("div"); + const copyButtons = within(toolNameContainer!).getAllByRole("button"); + const nameCopyButton = copyButtons.find((button) => { + return button.querySelector("svg") !== null; + }); + + expect(nameCopyButton).toBeInTheDocument(); + + const initialSvg = nameCopyButton!.querySelector("svg"); + expect(initialSvg).toBeInTheDocument(); + + await user.click(nameCopyButton!); + + await waitFor(() => { + const updatedSvg = nameCopyButton!.querySelector("svg"); + expect(updatedSvg).toBeInTheDocument(); + expect(nameCopyButton).toHaveClass("text-green-600"); + }); + }); + + + it("should not show check icon when copy fails", async () => { + const user = userEvent.setup({ delay: null }); + const { copyToClipboard } = await import("@/utils/dataUtils"); + vi.mocked(copyToClipboard).mockResolvedValue(false); + + render(); + + const toolNameContainer = screen.getByText("Test Search Tool").closest("div"); + const copyButtons = within(toolNameContainer!).getAllByRole("button"); + const nameCopyButton = copyButtons.find((button) => { + return button.querySelector("svg") !== null; + }); + + expect(nameCopyButton).toBeInTheDocument(); + await user.click(nameCopyButton!); + + await waitFor(() => { + expect(copyToClipboard).toHaveBeenCalledWith("Test Search Tool"); + }, { timeout: 3000 }); + + expect(nameCopyButton).not.toHaveClass("text-green-600"); + }); + + it("should render SearchToolTester when accessToken is provided", () => { + render(); + expect(screen.getByTestId("search-tool-tester")).toBeInTheDocument(); + expect(screen.getByText(/Search Tool Tester for Test Search Tool/)).toBeInTheDocument(); + }); + + it("should not render SearchToolTester when accessToken is null", () => { + render(); + expect(screen.queryByTestId("search-tool-tester")).not.toBeInTheDocument(); + }); + + it("should pass correct props to SearchToolTester", () => { + render(); + expect(screen.getByText("Access Token: test-token")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.tsx index c07041471e..ad88acd127 100644 --- a/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.tsx +++ b/ui/litellm-dashboard/src/components/SearchTools/SearchToolView.tsx @@ -1,11 +1,11 @@ -import React, { useState } from "react"; -import { ArrowLeftIcon } from "@heroicons/react/outline"; -import { Title, Card, Button, Text, Grid } from "@tremor/react"; -import { SearchTool, AvailableSearchProvider } from "./types"; import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; -import { CheckIcon, CopyIcon } from "lucide-react"; +import { ArrowLeftIcon } from "@heroicons/react/outline"; +import { Button, Card, Grid, Text, Title } from "@tremor/react"; import { Button as AntdButton } from "antd"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import React, { useState } from "react"; import { SearchToolTester } from "./SearchToolTester"; +import { AvailableSearchProvider, SearchTool } from "./types"; interface SearchToolViewProps { searchTool: SearchTool; @@ -54,8 +54,8 @@ export const SearchToolView: React.FC = ({ icon={copiedStates["search-tool-name"] ? : } onClick={() => copyToClipboard(searchTool.search_tool_name, "search-tool-name")} className={`left-2 z-10 transition-all duration-200 ${copiedStates["search-tool-name"] - ? "text-green-600 bg-green-50 border-green-200" - : "text-gray-500 hover:text-gray-700 hover:bg-gray-100" + ? "text-green-600 bg-green-50 border-green-200" + : "text-gray-500 hover:text-gray-700 hover:bg-gray-100" }`} /> @@ -67,8 +67,8 @@ export const SearchToolView: React.FC = ({ icon={copiedStates["search-tool-id"] ? : } onClick={() => copyToClipboard(searchTool.search_tool_id, "search-tool-id")} className={`left-2 z-10 transition-all duration-200 ${copiedStates["search-tool-id"] - ? "text-green-600 bg-green-50 border-green-200" - : "text-gray-500 hover:text-gray-700 hover:bg-gray-100" + ? "text-green-600 bg-green-50 border-green-200" + : "text-gray-500 hover:text-gray-700 hover:bg-gray-100" }`} /> diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchTools.test.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchTools.test.tsx new file mode 100644 index 0000000000..f1f7bf8ab4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/SearchTools/SearchTools.test.tsx @@ -0,0 +1,234 @@ +import * as roles from "@/utils/roles"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as networking from "../networking"; +import SearchTools from "./SearchTools"; +import { AvailableSearchProvider, SearchTool } from "./types"; + +vi.mock("../networking", () => ({ + fetchSearchTools: vi.fn(), + updateSearchTool: vi.fn(), + deleteSearchTool: vi.fn(), + fetchAvailableSearchProviders: vi.fn(), +})); + +vi.mock("@/utils/roles", () => ({ + isAdminRole: vi.fn(), +})); + +vi.mock("./SearchToolView", () => ({ + SearchToolView: ({ searchTool, onBack }: { searchTool: SearchTool; onBack: () => void }) => ( +
+
Search Tool View: {searchTool.search_tool_name}
+ +
+ ), +})); + +vi.mock("./CreateSearchTools", () => ({ + default: ({ + isModalVisible, + setModalVisible, + }: { + isModalVisible: boolean; + setModalVisible: (visible: boolean) => void; + }) => + isModalVisible ? ( +
+ +
+ ) : null, +})); + +vi.mock("../common_components/DeleteResourceModal", () => ({ + default: ({ + isOpen, + onOk, + onCancel, + }: { + isOpen: boolean; + onOk: () => void; + onCancel: () => void; + }) => + isOpen ? ( +
+ + +
+ ) : null, +})); + +const mockSearchTools: SearchTool[] = [ + { + search_tool_id: "tool-1", + search_tool_name: "Perplexity Search", + litellm_params: { + search_provider: "perplexity", + api_key: "sk-test-key", + }, + search_tool_info: { + description: "Test description", + }, + created_at: "2024-01-15T10:30:00Z", + }, + { + search_tool_id: "tool-2", + search_tool_name: "Tavily Search", + litellm_params: { + search_provider: "tavily", + }, + created_at: "2024-01-16T10:30:00Z", + }, +]; + +const mockAvailableProviders: AvailableSearchProvider[] = [ + { + provider_name: "perplexity", + ui_friendly_name: "Perplexity AI", + }, + { + provider_name: "tavily", + ui_friendly_name: "Tavily Search", + }, +]; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +describe("SearchTools", () => { + const defaultProps = { + accessToken: "test-token", + userRole: "Admin", + userID: "user-1", + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(networking.fetchSearchTools).mockResolvedValue({ search_tools: mockSearchTools }); + vi.mocked(networking.fetchAvailableSearchProviders).mockResolvedValue({ providers: mockAvailableProviders }); + vi.mocked(roles.isAdminRole).mockReturnValue(true); + }); + + it("should render", async () => { + render(, { wrapper: createWrapper() }); + await waitFor(() => { + expect(screen.getByText("Search Tools")).toBeInTheDocument(); + }); + }); + + it("should display missing authentication parameters message when accessToken is missing", () => { + render(, { wrapper: createWrapper() }); + expect(screen.getByText("Missing required authentication parameters.")).toBeInTheDocument(); + }); + + it("should display missing authentication parameters message when userRole is missing", () => { + render(, { wrapper: createWrapper() }); + expect(screen.getByText("Missing required authentication parameters.")).toBeInTheDocument(); + }); + + it("should display missing authentication parameters message when userID is missing", () => { + render(, { wrapper: createWrapper() }); + expect(screen.getByText("Missing required authentication parameters.")).toBeInTheDocument(); + }); + + it("should display search tools table with tools", async () => { + render(, { wrapper: createWrapper() }); + await waitFor(() => { + expect(screen.getByText("Perplexity Search")).toBeInTheDocument(); + }); + expect(screen.getAllByText("Tavily Search").length).toBeGreaterThan(0); + }); + + it("should display empty state when no search tools are available", async () => { + vi.mocked(networking.fetchSearchTools).mockResolvedValue({ search_tools: [] }); + + render(, { wrapper: createWrapper() }); + await waitFor(() => { + expect(screen.getByText("No search tools configured")).toBeInTheDocument(); + }); + }); + + it("should show Add New Search Tool button when user is admin", async () => { + render(, { wrapper: createWrapper() }); + await waitFor(() => { + expect(screen.getByRole("button", { name: /add new search tool/i })).toBeInTheDocument(); + }); + }); + + it("should not show Add New Search Tool button when user is not admin", async () => { + vi.mocked(roles.isAdminRole).mockReturnValue(false); + + render(, { wrapper: createWrapper() }); + await waitFor(() => { + expect(screen.getByText("Search Tools")).toBeInTheDocument(); + }); + expect(screen.queryByRole("button", { name: /add new search tool/i })).not.toBeInTheDocument(); + }); + + it("should open create modal when Add New Search Tool button is clicked", async () => { + const user = userEvent.setup({ delay: null }); + render(, { wrapper: createWrapper() }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /add new search tool/i })).toBeInTheDocument(); + }); + + const addButton = screen.getByRole("button", { name: /add new search tool/i }); + await user.click(addButton); + + expect(screen.getByTestId("create-search-tool-modal")).toBeInTheDocument(); + }); + + it("should navigate to tool view when tool ID is clicked", async () => { + const user = userEvent.setup({ delay: null }); + render(, { wrapper: createWrapper() }); + + await waitFor(() => { + expect(screen.getByText("Perplexity Search")).toBeInTheDocument(); + }); + + const toolIdButton = screen.getByRole("button", { name: /tool-1/i }); + await user.click(toolIdButton); + + await waitFor(() => { + expect(screen.getByTestId("search-tool-view")).toBeInTheDocument(); + }); + expect(screen.getByText(/Search Tool View: Perplexity Search/i)).toBeInTheDocument(); + }); + + it("should navigate back from tool view to table", async () => { + const user = userEvent.setup({ delay: null }); + render(, { wrapper: createWrapper() }); + + await waitFor(() => { + expect(screen.getByText("Perplexity Search")).toBeInTheDocument(); + }); + + const toolIdButton = screen.getByRole("button", { name: /tool-1/i }); + await user.click(toolIdButton); + + await waitFor(() => { + expect(screen.getByTestId("search-tool-view")).toBeInTheDocument(); + }); + + const backButton = screen.getByRole("button", { name: /back/i }); + await user.click(backButton); + + await waitFor(() => { + expect(screen.queryByTestId("search-tool-view")).not.toBeInTheDocument(); + expect(screen.getByText("Perplexity Search")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx index ba42ea22cd..dd2033fc18 100644 --- a/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx +++ b/ui/litellm-dashboard/src/components/SearchTools/SearchTools.tsx @@ -1,21 +1,21 @@ -import React, { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { Modal, Form, Input, Select, Table, Spin } from "antd"; +import { isAdminRole } from "@/utils/roles"; import { LoadingOutlined } from "@ant-design/icons"; -import { Button, Title, Text } from "@tremor/react"; -import { searchToolColumns } from "./SearchToolColumn"; +import { useQuery } from "@tanstack/react-query"; +import { Button, Text, Title } from "@tremor/react"; +import { Form, Input, Modal, Select, Spin, Table } from "antd"; +import React, { useState } from "react"; +import DeleteResourceModal from "../common_components/DeleteResourceModal"; +import NotificationsManager from "../molecules/notifications_manager"; import { - fetchSearchTools, - updateSearchTool, deleteSearchTool, fetchAvailableSearchProviders, + fetchSearchTools, + updateSearchTool, } from "../networking"; -import { SearchTool, AvailableSearchProvider } from "./types"; -import { isAdminRole } from "@/utils/roles"; -import NotificationsManager from "../molecules/notifications_manager"; -import { SearchToolView } from "./SearchToolView"; import CreateSearchTool from "./CreateSearchTools"; -import DeleteResourceModal from "../common_components/DeleteResourceModal"; +import { searchToolColumns } from "./SearchToolColumn"; +import { SearchToolView } from "./SearchToolView"; +import { AvailableSearchProvider, SearchTool } from "./types"; interface SearchToolsProps { accessToken: string | null;