mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-23 20:26:28 +00:00
Merge pull request #15196 from BerriAI/litellm_staging_10_04_2025
Litellm staging 10 04 2025
This commit is contained in:
@@ -43,7 +43,7 @@ def _set_object_metadata_field(
|
||||
value: Value to set for the field
|
||||
"""
|
||||
if field_name in LiteLLM_ManagementEndpoint_MetadataFields_Premium:
|
||||
_premium_user_check()
|
||||
_premium_user_check(field_name)
|
||||
object_data.metadata = object_data.metadata or {}
|
||||
object_data.metadata[field_name] = value
|
||||
|
||||
|
||||
@@ -903,7 +903,7 @@ def prepare_metadata_fields(
|
||||
if k in LiteLLM_ManagementEndpoint_MetadataFields_Premium:
|
||||
from litellm.proxy.utils import _premium_user_check
|
||||
|
||||
_premium_user_check()
|
||||
_premium_user_check(k)
|
||||
casted_metadata[k] = v
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -3575,17 +3575,22 @@ def handle_exception_on_proxy(e: Exception) -> ProxyException:
|
||||
)
|
||||
|
||||
|
||||
def _premium_user_check():
|
||||
def _premium_user_check(feature:str=None):
|
||||
"""
|
||||
Raises an HTTPException if the user is not a premium user
|
||||
"""
|
||||
from litellm.proxy.proxy_server import premium_user
|
||||
|
||||
if feature:
|
||||
detail_msg = f"This feature is only available for LiteLLM Enterprise users: {feature}. {CommonProxyErrors.not_premium_user.value}"
|
||||
else:
|
||||
detail_msg = f"This feature is only available for LiteLLM Enterprise users. {CommonProxyErrors.not_premium_user.value}"
|
||||
|
||||
if not premium_user:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": f"This feature is only available for LiteLLM Enterprise users. {CommonProxyErrors.not_premium_user.value}"
|
||||
"error": detail_msg
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import SpendLogsTable from "@/components/view_logs"
|
||||
import ModelHubTable from "@/components/model_hub_table"
|
||||
import NewUsagePage from "@/components/new_usage"
|
||||
import APIRef from "@/components/api_ref"
|
||||
import ChatUI from "@/components/chat_ui"
|
||||
import ChatUI from "@/components/chat_ui/ChatUI"
|
||||
import Sidebar from "@/components/leftnav"
|
||||
import Usage from "@/components/usage"
|
||||
import CacheDashboard from "@/components/cache_dashboard"
|
||||
|
||||
+37
-33
@@ -25,34 +25,34 @@ import {
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { message, Select, Spin, Typography, Tooltip, Input, Upload, Modal, Button } from "antd";
|
||||
import { makeOpenAIChatCompletionRequest } from "./chat_ui/llm_calls/chat_completion";
|
||||
import { makeOpenAIImageGenerationRequest } from "./chat_ui/llm_calls/image_generation";
|
||||
import { makeOpenAIImageEditsRequest } from "./chat_ui/llm_calls/image_edits";
|
||||
import { makeOpenAIResponsesRequest } from "./chat_ui/llm_calls/responses_api";
|
||||
import { makeAnthropicMessagesRequest } from "./chat_ui/llm_calls/anthropic_messages";
|
||||
import { fetchAvailableModels, ModelGroup } from "./chat_ui/llm_calls/fetch_models";
|
||||
import { fetchAvailableMCPTools } from "./chat_ui/llm_calls/fetch_mcp_tools";
|
||||
import type { MCPTool } from "./chat_ui/llm_calls/fetch_mcp_tools";
|
||||
import { litellmModeMapping, ModelMode, EndpointType, getEndpointType } from "./chat_ui/mode_endpoint_mapping";
|
||||
import { makeOpenAIChatCompletionRequest } from "./llm_calls/chat_completion";
|
||||
import { makeOpenAIImageGenerationRequest } from "./llm_calls/image_generation";
|
||||
import { makeOpenAIImageEditsRequest } from "./llm_calls/image_edits";
|
||||
import { makeOpenAIResponsesRequest } from "./llm_calls/responses_api";
|
||||
import { makeAnthropicMessagesRequest } from "./llm_calls/anthropic_messages";
|
||||
import { fetchAvailableModels, ModelGroup } from "./llm_calls/fetch_models";
|
||||
import { fetchAvailableMCPTools } from "./llm_calls/fetch_mcp_tools";
|
||||
import type { MCPTool } from "./llm_calls/fetch_mcp_tools";
|
||||
import { litellmModeMapping, ModelMode, EndpointType, getEndpointType } from "./mode_endpoint_mapping";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { coy } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import EndpointSelector from "./chat_ui/EndpointSelector";
|
||||
import TagSelector from "./tag_management/TagSelector";
|
||||
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
|
||||
import GuardrailSelector from "./guardrails/GuardrailSelector";
|
||||
import { determineEndpointType } from "./chat_ui/EndpointUtils";
|
||||
import { generateCodeSnippet } from "./chat_ui/CodeSnippets";
|
||||
import { MessageType } from "./chat_ui/types";
|
||||
import ReasoningContent from "./chat_ui/ReasoningContent";
|
||||
import ResponseMetrics, { TokenUsage } from "./chat_ui/ResponseMetrics";
|
||||
import ResponsesImageUpload from "./chat_ui/ResponsesImageUpload";
|
||||
import ResponsesImageRenderer from "./chat_ui/ResponsesImageRenderer";
|
||||
import { convertImageToBase64, createMultimodalMessage, createDisplayMessage } from "./chat_ui/ResponsesImageUtils";
|
||||
import ChatImageUpload from "./chat_ui/ChatImageUpload";
|
||||
import ChatImageRenderer from "./chat_ui/ChatImageRenderer";
|
||||
import { createChatMultimodalMessage, createChatDisplayMessage } from "./chat_ui/ChatImageUtils";
|
||||
import SessionManagement from "./chat_ui/SessionManagement";
|
||||
import MCPEventsDisplay, { MCPEvent } from "./chat_ui/MCPEventsDisplay";
|
||||
import EndpointSelector from "./EndpointSelector";
|
||||
import TagSelector from "../tag_management/TagSelector";
|
||||
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
|
||||
import GuardrailSelector from "../guardrails/GuardrailSelector";
|
||||
import { determineEndpointType } from "./EndpointUtils";
|
||||
import { generateCodeSnippet } from "./CodeSnippets";
|
||||
import { MessageType } from "./types";
|
||||
import ReasoningContent from "./ReasoningContent";
|
||||
import ResponseMetrics, { TokenUsage } from "./ResponseMetrics";
|
||||
import ResponsesImageUpload from "./ResponsesImageUpload";
|
||||
import ResponsesImageRenderer from "./ResponsesImageRenderer";
|
||||
import { convertImageToBase64, createMultimodalMessage, createDisplayMessage } from "./ResponsesImageUtils";
|
||||
import ChatImageUpload from "./ChatImageUpload";
|
||||
import ChatImageRenderer from "./ChatImageRenderer";
|
||||
import { createChatMultimodalMessage, createChatDisplayMessage } from "./ChatImageUtils";
|
||||
import SessionManagement from "./SessionManagement";
|
||||
import MCPEventsDisplay, { MCPEvent } from "./MCPEventsDisplay";
|
||||
import {
|
||||
SendOutlined,
|
||||
ApiOutlined,
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
FilePdfOutlined,
|
||||
ArrowUpOutlined
|
||||
} from "@ant-design/icons";
|
||||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Dragger } = Upload;
|
||||
@@ -282,13 +282,17 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
||||
);
|
||||
|
||||
console.log("Fetched models:", uniqueModels);
|
||||
|
||||
if (uniqueModels.length > 0) {
|
||||
setModelInfo(uniqueModels);
|
||||
if (!selectedModel) {
|
||||
setSelectedModel(uniqueModels[0].model_group);
|
||||
}
|
||||
|
||||
setModelInfo(uniqueModels);
|
||||
|
||||
// check for selection overlap or empty model list
|
||||
const hasSelection = uniqueModels.some(m => m.model_group === selectedModel);
|
||||
if (!uniqueModels.length) {
|
||||
setSelectedModel(undefined);
|
||||
} else if (!hasSelection) {
|
||||
setSelectedModel(uniqueModels[0].model_group);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error fetching model info:", error);
|
||||
}
|
||||
@@ -9,13 +9,15 @@ interface GuardrailSelectorProps {
|
||||
value?: string[];
|
||||
className?: string;
|
||||
accessToken: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const GuardrailSelector: React.FC<GuardrailSelectorProps> = ({
|
||||
onChange,
|
||||
value,
|
||||
className,
|
||||
accessToken
|
||||
accessToken,
|
||||
disabled
|
||||
}) => {
|
||||
const [guardrails, setGuardrails] = useState<Guardrail[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -51,7 +53,8 @@ const GuardrailSelector: React.FC<GuardrailSelectorProps> = ({
|
||||
<div>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Select guardrails"
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? "Setting guardrails is a premium feature." : "Select guardrails"}
|
||||
onChange={handleGuardrailChange}
|
||||
value={value}
|
||||
loading={loading}
|
||||
|
||||
@@ -2514,10 +2514,10 @@ export const allTagNamesCall = async (accessToken: String) => {
|
||||
export const allEndUsersCall = async (accessToken: String) => {
|
||||
try {
|
||||
let url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/global/all_end_users`
|
||||
: `/global/all_end_users`;
|
||||
? `${proxyBaseUrl}/customer/list`
|
||||
: `/customer/list`;
|
||||
|
||||
console.log("in global/all_end_users call", url);
|
||||
console.log("in customer/list", url);
|
||||
const response = await fetch(`${url}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
// KeyInfoView.premium-guard.test.tsx
|
||||
import React from "react"
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
|
||||
|
||||
// ---- Hoisted shared mocks (safe to use inside vi.mock factories) ----
|
||||
const { keyUpdateCallMock, keyDeleteCallMock } = vi.hoisted(() => {
|
||||
return {
|
||||
keyUpdateCallMock: vi.fn().mockResolvedValue({}),
|
||||
keyDeleteCallMock: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
})
|
||||
|
||||
// ---- Module mocks ----
|
||||
|
||||
// Networking: wire the hoisted fns so we can assert calls later
|
||||
vi.mock("../networking", () => {
|
||||
return {
|
||||
keyUpdateCall: (...args: any[]) => keyUpdateCallMock(...args),
|
||||
keyDeleteCall: (...args: any[]) => keyDeleteCallMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
// Notifications
|
||||
vi.mock("../molecules/notifications_manager", () => {
|
||||
const Notifications = {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
fromBackend: vi.fn(),
|
||||
}
|
||||
return { default: Notifications }
|
||||
})
|
||||
|
||||
// Roles: ensure 'admin' has write access
|
||||
vi.mock("../../utils/roles", () => ({
|
||||
rolesWithWriteAccess: ["admin"],
|
||||
}))
|
||||
|
||||
// Helpers used in rendering
|
||||
vi.mock("@/utils/dataUtils", () => ({
|
||||
copyToClipboard: async () => true,
|
||||
formatNumberWithCommas: (n: any) => String(n),
|
||||
}))
|
||||
vi.mock("../key_info_utils", () => ({
|
||||
extractLoggingSettings: () => ({}),
|
||||
formatMetadataForDisplay: (m: any) => JSON.stringify(m, null, 2),
|
||||
}))
|
||||
vi.mock("../callback_info_helpers", () => ({
|
||||
callback_map: {},
|
||||
mapInternalToDisplayNames: (x: any) => x,
|
||||
mapDisplayToInternalNames: (x: any) => x,
|
||||
}))
|
||||
vi.mock("../shared/errorUtils", () => ({
|
||||
parseErrorMessage: (e: any) => String(e),
|
||||
}))
|
||||
|
||||
// Tremor components -> async factory, local React import, and named passthroughs
|
||||
vi.mock("@tremor/react", async () => {
|
||||
const React = await import("react")
|
||||
|
||||
const makeNamedPassthrough = (tag: any, name: string) => {
|
||||
function Named(props: any) {
|
||||
const { children, ...rest } = props
|
||||
return React.createElement(tag, rest, children)
|
||||
}
|
||||
;(Named as any).displayName = name
|
||||
return Named
|
||||
}
|
||||
|
||||
const Card = makeNamedPassthrough("div", "Card")
|
||||
const Text = makeNamedPassthrough("span", "Text")
|
||||
const Grid = makeNamedPassthrough("div", "Grid")
|
||||
const Col = makeNamedPassthrough("div", "Col")
|
||||
const TabGroup = makeNamedPassthrough("div", "TabGroup")
|
||||
const TabList = makeNamedPassthrough("div", "TabList")
|
||||
const TabPanels = makeNamedPassthrough("div", "TabPanels")
|
||||
const TabPanel = makeNamedPassthrough("div", "TabPanel")
|
||||
const Title = makeNamedPassthrough("h1", "Title")
|
||||
const Badge = makeNamedPassthrough("span", "Badge")
|
||||
|
||||
function Button(props: any) {
|
||||
const { children, onClick, ...rest } = props
|
||||
return React.createElement("button", { onClick, ...rest }, children)
|
||||
}
|
||||
;(Button as any).displayName = "Button"
|
||||
|
||||
function Tab(props: any) {
|
||||
const { children, ...rest } = props
|
||||
return React.createElement("button", { ...rest }, children)
|
||||
}
|
||||
;(Tab as any).displayName = "Tab"
|
||||
|
||||
function TextInput(props: any) {
|
||||
return React.createElement("input", { ...props })
|
||||
}
|
||||
;(TextInput as any).displayName = "TextInput"
|
||||
|
||||
function TremorSelect(props: any) {
|
||||
return React.createElement("select", { ...props })
|
||||
}
|
||||
;(TremorSelect as any).displayName = "TremorSelect"
|
||||
|
||||
return {
|
||||
Card,
|
||||
Text,
|
||||
Button,
|
||||
Grid,
|
||||
Col,
|
||||
Tab,
|
||||
TabList,
|
||||
TabGroup,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Title,
|
||||
Badge,
|
||||
TextInput,
|
||||
Select: TremorSelect,
|
||||
}
|
||||
})
|
||||
|
||||
// antd bits -> async factory & local React
|
||||
vi.mock("antd", async () => {
|
||||
const React = await import("react")
|
||||
|
||||
const Form = { useForm: () => [{}] }
|
||||
|
||||
function Input(props: any) {
|
||||
return React.createElement("input", { ...props })
|
||||
}
|
||||
;(Input as any).displayName = "AntdInput"
|
||||
|
||||
function InputNumber(props: any) {
|
||||
return React.createElement("input", { ...props })
|
||||
}
|
||||
;(InputNumber as any).displayName = "AntdInputNumber"
|
||||
|
||||
function Select(props: any) {
|
||||
return React.createElement("select", { ...props })
|
||||
}
|
||||
;(Select as any).displayName = "AntdSelect"
|
||||
|
||||
function Tooltip({ children }: any) {
|
||||
return React.createElement(React.Fragment, null, children)
|
||||
}
|
||||
;(Tooltip as any).displayName = "AntdTooltip"
|
||||
|
||||
function Button(props: any) {
|
||||
const { children, onClick, ...rest } = props
|
||||
return React.createElement("button", { onClick, ...rest }, children)
|
||||
}
|
||||
;(Button as any).displayName = "AntdButton"
|
||||
|
||||
return { Form, Input, InputNumber, Select, Tooltip, Button }
|
||||
})
|
||||
|
||||
// Icons -> async factory & local React
|
||||
vi.mock("@heroicons/react/outline", async () => {
|
||||
const React = await import("react")
|
||||
function ArrowLeftIcon() {
|
||||
return React.createElement("span")
|
||||
}
|
||||
;(ArrowLeftIcon as any).displayName = "ArrowLeftIcon"
|
||||
function TrashIcon() {
|
||||
return React.createElement("span")
|
||||
}
|
||||
;(TrashIcon as any).displayName = "TrashIcon"
|
||||
function RefreshIcon() {
|
||||
return React.createElement("span")
|
||||
}
|
||||
;(RefreshIcon as any).displayName = "RefreshIcon"
|
||||
return { ArrowLeftIcon, TrashIcon, RefreshIcon }
|
||||
})
|
||||
|
||||
vi.mock("lucide-react", async () => {
|
||||
const React = await import("react")
|
||||
function CopyIcon() {
|
||||
return React.createElement("span")
|
||||
}
|
||||
;(CopyIcon as any).displayName = "CopyIcon"
|
||||
function CheckIcon() {
|
||||
return React.createElement("span")
|
||||
}
|
||||
;(CheckIcon as any).displayName = "CheckIcon"
|
||||
return { CopyIcon, CheckIcon }
|
||||
})
|
||||
|
||||
// Heavy children -> async factories & local React
|
||||
vi.mock("../organisms/regenerate_key_modal", async () => {
|
||||
const React = await import("react")
|
||||
function RegenerateKeyModal() {
|
||||
return null
|
||||
}
|
||||
;(RegenerateKeyModal as any).displayName = "RegenerateKeyModal"
|
||||
return { RegenerateKeyModal }
|
||||
})
|
||||
vi.mock("../object_permissions_view", async () => {
|
||||
const React = await import("react")
|
||||
function ObjectPermissionsView() {
|
||||
return null
|
||||
}
|
||||
;(ObjectPermissionsView as any).displayName = "ObjectPermissionsView"
|
||||
return { __esModule: true, default: ObjectPermissionsView }
|
||||
})
|
||||
vi.mock("../logging_settings_view", async () => {
|
||||
const React = await import("react")
|
||||
function LoggingSettingsView() {
|
||||
return null
|
||||
}
|
||||
;(LoggingSettingsView as any).displayName = "LoggingSettingsView"
|
||||
return { __esModule: true, default: LoggingSettingsView }
|
||||
})
|
||||
vi.mock("../common_components/AutoRotationView", async () => {
|
||||
const React = await import("react")
|
||||
function AutoRotationView() {
|
||||
return null
|
||||
}
|
||||
;(AutoRotationView as any).displayName = "AutoRotationView"
|
||||
return { __esModule: true, default: AutoRotationView }
|
||||
})
|
||||
|
||||
// KeyEditView mock: triggers onSubmit with our injected form values
|
||||
vi.mock("./key_edit_view", async () => {
|
||||
const React = await import("react")
|
||||
function KeyEditView(props: any) {
|
||||
return React.createElement(
|
||||
"div",
|
||||
null,
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
onClick: () =>
|
||||
props.onSubmit((globalThis as any).__TEST_FORM_VALUES ?? {}),
|
||||
},
|
||||
"Mock Submit"
|
||||
)
|
||||
)
|
||||
}
|
||||
;(KeyEditView as any).displayName = "KeyEditViewMock"
|
||||
return { KeyEditView }
|
||||
})
|
||||
|
||||
// ---- SUT import AFTER mocks ----
|
||||
import KeyInfoView from "./key_info_view"
|
||||
|
||||
// ---- Test data helpers ----
|
||||
const baseKeyData = {
|
||||
token_id: "tok_123",
|
||||
token: "tok_123",
|
||||
key_alias: "My API Key",
|
||||
key_name: "sk-xxxx",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
spend: 0,
|
||||
max_budget: null,
|
||||
tpm_limit: null,
|
||||
rpm_limit: null,
|
||||
models: [] as string[],
|
||||
metadata: {} as Record<string, any>,
|
||||
object_permission: {} as Record<string, any>,
|
||||
auto_rotate: false,
|
||||
rotation_interval: null as any,
|
||||
last_rotation_at: null as any,
|
||||
key_rotation_at: null as any,
|
||||
next_rotation_at: null as any,
|
||||
}
|
||||
|
||||
const renderView = (premiumUser: boolean) =>
|
||||
render(
|
||||
<KeyInfoView
|
||||
keyId="tok_123"
|
||||
onClose={() => {}}
|
||||
keyData={baseKeyData as any}
|
||||
onKeyDataUpdate={() => {}}
|
||||
accessToken="access_abc"
|
||||
userID="user_1"
|
||||
userRole="admin"
|
||||
teams={[]}
|
||||
premiumUser={premiumUser}
|
||||
setAccessToken={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(globalThis as any).__TEST_FORM_VALUES = undefined
|
||||
})
|
||||
|
||||
// ---- Tests ----
|
||||
describe("KeyInfoView handleKeyUpdate premium guard", () => {
|
||||
it("removes guardrails & prompts for non-premium users and prevents metadata.guardrails", async () => {
|
||||
renderView(false) // premiumUser = false
|
||||
|
||||
fireEvent.click(screen.getByText("Edit Settings"))
|
||||
|
||||
;(globalThis as any).__TEST_FORM_VALUES = {
|
||||
token: "tok_123",
|
||||
guardrails: ["gr-1", "gr-2"],
|
||||
prompts: ["fast", "safe"],
|
||||
metadata: {}, // object form (not JSON string)
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByText("Mock Submit"))
|
||||
|
||||
await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled())
|
||||
|
||||
const [sentAccessToken, sentPayload] = keyUpdateCallMock.mock.calls[0]
|
||||
expect(sentAccessToken).toBe("access_abc")
|
||||
|
||||
expect("guardrails" in sentPayload).toBe(false)
|
||||
expect("prompts" in sentPayload).toBe(false)
|
||||
expect(sentPayload.metadata?.guardrails).toBeUndefined()
|
||||
expect(sentPayload.key).toBe("tok_123")
|
||||
})
|
||||
|
||||
it("preserves guardrails & prompts for premium users and includes metadata.guardrails", async () => {
|
||||
renderView(true) // premiumUser = true
|
||||
|
||||
fireEvent.click(screen.getByText("Edit Settings"))
|
||||
|
||||
;(globalThis as any).__TEST_FORM_VALUES = {
|
||||
token: "tok_123",
|
||||
guardrails: ["gr-1"],
|
||||
prompts: ["fast"],
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByText("Mock Submit"))
|
||||
|
||||
await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled())
|
||||
|
||||
const [, sentPayload] = keyUpdateCallMock.mock.calls[0]
|
||||
|
||||
expect(sentPayload.guardrails).toEqual(["gr-1"])
|
||||
expect(sentPayload.prompts).toEqual(["fast"])
|
||||
expect(sentPayload.metadata?.guardrails).toEqual(["gr-1"])
|
||||
expect(sentPayload.key).toBe("tok_123")
|
||||
})
|
||||
})
|
||||
@@ -137,8 +137,8 @@ export function KeyEditView({
|
||||
token: keyData.token || keyData.token_id,
|
||||
budget_duration: getBudgetDuration(keyData.budget_duration),
|
||||
metadata: formatMetadataForDisplay(keyData.metadata),
|
||||
guardrails: keyData.metadata?.guardrails || [],
|
||||
prompts: keyData.metadata?.prompts || [],
|
||||
guardrails: keyData.metadata?.guardrails,
|
||||
prompts: keyData.metadata?.prompts,
|
||||
vector_stores: keyData.object_permission?.vector_stores || [],
|
||||
mcp_servers_and_groups: {
|
||||
servers: keyData.object_permission?.mcp_servers || [],
|
||||
@@ -158,8 +158,8 @@ export function KeyEditView({
|
||||
token: keyData.token || keyData.token_id,
|
||||
budget_duration: getBudgetDuration(keyData.budget_duration),
|
||||
metadata: formatMetadataForDisplay(keyData.metadata),
|
||||
guardrails: keyData.metadata?.guardrails || [],
|
||||
prompts: keyData.metadata?.prompts || [],
|
||||
guardrails: keyData.metadata?.guardrails,
|
||||
prompts: keyData.metadata?.prompts,
|
||||
vector_stores: keyData.object_permission?.vector_stores || [],
|
||||
mcp_servers_and_groups: {
|
||||
servers: keyData.object_permission?.mcp_servers || [],
|
||||
@@ -240,7 +240,7 @@ export function KeyEditView({
|
||||
|
||||
<Form.Item label="Guardrails" name="guardrails">
|
||||
{ accessToken &&
|
||||
<GuardrailSelector onChange={(v) => {form.setFieldValue("guardrails", v)}} accessToken={accessToken} />
|
||||
<GuardrailSelector onChange={(v) => {form.setFieldValue("guardrails", v)}} accessToken={accessToken} disabled={!premiumUser}/>
|
||||
}
|
||||
</Form.Item>
|
||||
|
||||
|
||||
@@ -109,6 +109,12 @@ export default function KeyInfoView({
|
||||
const currentKey = formValues.token
|
||||
formValues.key = currentKey
|
||||
|
||||
// Guard premium features
|
||||
if (!premiumUser) {
|
||||
delete formValues.guardrails;
|
||||
delete formValues.prompts;
|
||||
}
|
||||
|
||||
// Handle object_permission updates
|
||||
if (formValues.vector_stores !== undefined) {
|
||||
formValues.object_permission = {
|
||||
|
||||
@@ -635,7 +635,7 @@ const ModelDashboard: React.FC<ModelDashboardProps> = ({
|
||||
|
||||
let all_end_users_data = await allEndUsersCall(accessToken)
|
||||
|
||||
setAllEndUsers(all_end_users_data?.end_users)
|
||||
setAllEndUsers(all_end_users_data?.map((u: any) => u.user_id))
|
||||
|
||||
const routerSettingsInfo = await getCallbacksCall(accessToken, userID, userRole)
|
||||
|
||||
|
||||
@@ -419,7 +419,8 @@ export default function SpendLogsTable({
|
||||
searchFn: async (searchText: string) => {
|
||||
if (!accessToken) return []
|
||||
const data = await allEndUsersCall(accessToken)
|
||||
const users = data?.end_users || []
|
||||
// data if set, is a list of objects, with key = user_id
|
||||
const users = data?.map((u: any) => u.user_id) || []
|
||||
const filtered = users.filter((u: string) => u.toLowerCase().includes(searchText.toLowerCase()))
|
||||
return filtered.map((u: string) => ({ label: u, value: u }))
|
||||
},
|
||||
|
||||
@@ -55,7 +55,13 @@ export function useLogFilterLogic({
|
||||
}), []);
|
||||
|
||||
const [filters, setFilters] = useState<LogFilterState>(defaultFilters);
|
||||
const [filteredLogs, setFilteredLogs] = useState<PaginatedResponse>(logs);
|
||||
const [backendFilteredLogs, setBackendFilteredLogs] = useState<PaginatedResponse>({
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
total_pages: 0
|
||||
});
|
||||
const lastSearchTimestamp = useRef(0);
|
||||
const performSearch = useCallback(async (filters: LogFilterState, page = 1) => {
|
||||
if (!accessToken) return;
|
||||
@@ -87,7 +93,7 @@ export function useLogFilterLogic({
|
||||
);
|
||||
|
||||
if (currentTimestamp === lastSearchTimestamp.current && response.data) {
|
||||
setFilteredLogs(response);
|
||||
setBackendFilteredLogs(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error searching users:", error);
|
||||
@@ -113,34 +119,36 @@ export function useLogFilterLogic({
|
||||
});
|
||||
const allKeyAliases = queryAllKeysQuery.data || []
|
||||
|
||||
// Apply filters to keys whenever logs or filters change
|
||||
useEffect(() => {
|
||||
// Determine when backend filters are active (server-side filtering)
|
||||
const hasBackendFilters = useMemo(() => (
|
||||
!!(
|
||||
filters[FILTER_KEYS.KEY_ALIAS] ||
|
||||
filters[FILTER_KEYS.KEY_HASH] ||
|
||||
filters[FILTER_KEYS.REQUEST_ID] ||
|
||||
filters[FILTER_KEYS.USER_ID] ||
|
||||
filters[FILTER_KEYS.END_USER]
|
||||
)
|
||||
), [filters]);
|
||||
|
||||
// Compute client-side filtered logs directly from incoming logs and filters
|
||||
const clientDerivedFilteredLogs: PaginatedResponse = useMemo(() => {
|
||||
if (!logs || !logs.data) {
|
||||
setFilteredLogs({
|
||||
return {
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
total_pages: 0
|
||||
});
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
// Only do client-side filtering if no backend filters are active
|
||||
const hasBackendFilters =
|
||||
filters[FILTER_KEYS.KEY_ALIAS] ||
|
||||
filters[FILTER_KEYS.KEY_HASH] ||
|
||||
filters[FILTER_KEYS.REQUEST_ID] ||
|
||||
filters[FILTER_KEYS.USER_ID] ||
|
||||
filters[FILTER_KEYS.END_USER];
|
||||
|
||||
// If backend filters are on, don't perform client-side filtering here
|
||||
if (hasBackendFilters) {
|
||||
// Backend is handling filtering, don't override the results
|
||||
return;
|
||||
return logs;
|
||||
}
|
||||
|
||||
|
||||
let filteredData = [...logs.data];
|
||||
|
||||
|
||||
if (filters[FILTER_KEYS.TEAM_ID]) {
|
||||
filteredData = filteredData.filter(
|
||||
log => log.team_id === filters[FILTER_KEYS.TEAM_ID]
|
||||
@@ -163,7 +171,7 @@ export function useLogFilterLogic({
|
||||
log => log.model === filters[FILTER_KEYS.MODEL]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (filters[FILTER_KEYS.KEY_HASH]) {
|
||||
filteredData = filteredData.filter(
|
||||
log => log.api_key === filters[FILTER_KEYS.KEY_HASH]
|
||||
@@ -175,21 +183,33 @@ export function useLogFilterLogic({
|
||||
log => log.end_user === filters[FILTER_KEYS.END_USER]
|
||||
);
|
||||
}
|
||||
|
||||
const newFilteredLogs: PaginatedResponse = {
|
||||
|
||||
return {
|
||||
data: filteredData,
|
||||
total: logs.total,
|
||||
page: logs.page,
|
||||
page_size: logs.page_size,
|
||||
total_pages: logs.total_pages,
|
||||
};
|
||||
|
||||
if (JSON.stringify(newFilteredLogs) !== JSON.stringify(filteredLogs)) {
|
||||
setFilteredLogs(newFilteredLogs);
|
||||
}
|
||||
}, [logs, filters, filteredLogs, accessToken]);
|
||||
}, [logs, filters, hasBackendFilters]);
|
||||
|
||||
|
||||
// Choose which filtered logs to expose: backend result when active, otherwise client-derived
|
||||
const filteredLogs: PaginatedResponse = useMemo(() => {
|
||||
if (hasBackendFilters) {
|
||||
// Prefer backend result if present; otherwise fall back to latest logs
|
||||
if (backendFilteredLogs && backendFilteredLogs.data && backendFilteredLogs.data.length > 0) {
|
||||
return backendFilteredLogs;
|
||||
}
|
||||
return logs || {
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
total_pages: 0
|
||||
};
|
||||
}
|
||||
return clientDerivedFilteredLogs;
|
||||
}, [hasBackendFilters, backendFilteredLogs, clientDerivedFilteredLogs, logs]);
|
||||
|
||||
// Fetch all teams and users for potential filter dropdowns (optional, can be adapted)
|
||||
const { data: allTeams } = useQuery<Team[], Error>({
|
||||
@@ -231,6 +251,15 @@ export function useLogFilterLogic({
|
||||
// Reset filters state
|
||||
setFilters(defaultFilters);
|
||||
|
||||
// Clear backend filtered logs to ensure fresh render
|
||||
setBackendFilteredLogs({
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
total_pages: 0
|
||||
});
|
||||
|
||||
// Reset selections
|
||||
debouncedSearch(defaultFilters, 1);
|
||||
};
|
||||
|
||||
@@ -1,43 +1,79 @@
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { message } from "antd";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager"
|
||||
import { message } from "antd"
|
||||
|
||||
export function updateExistingKeys<Source extends Object>(target: Source, source: Object): Source {
|
||||
const clonedTarget = structuredClone(target)
|
||||
|
||||
export function updateExistingKeys<Source extends Object>(
|
||||
target: Source,
|
||||
source: Object
|
||||
): Source {
|
||||
const clonedTarget = structuredClone(target);
|
||||
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (key in clonedTarget) {
|
||||
(clonedTarget as any)[key] = value;
|
||||
;(clonedTarget as any)[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return clonedTarget;
|
||||
return clonedTarget
|
||||
}
|
||||
|
||||
export const formatNumberWithCommas = (value: number | null | undefined, decimals: number = 0): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return '-';
|
||||
return "-"
|
||||
}
|
||||
return value.toLocaleString('en-US', {
|
||||
return value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
export const copyToClipboard = async (
|
||||
text: string | null | undefined,
|
||||
messageText: string = "Copied to clipboard"
|
||||
messageText: string = "Copied to clipboard",
|
||||
): Promise<boolean> => {
|
||||
if (!text) return false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
NotificationsManager.success(messageText);
|
||||
return true;
|
||||
} catch (err) {
|
||||
NotificationsManager.fromBackend("Failed to copy to clipboard");
|
||||
console.error("Failed to copy: ", err);
|
||||
return false;
|
||||
if (!text) return false
|
||||
|
||||
// Check if clipboard API is available
|
||||
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
NotificationsManager.success(messageText)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error("Clipboard API failed: ", err)
|
||||
// Fall back to legacy method
|
||||
return fallbackCopyToClipboard(text, messageText)
|
||||
}
|
||||
} else {
|
||||
// Use fallback method when clipboard API is not available
|
||||
return fallbackCopyToClipboard(text, messageText)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback method using document.execCommand (deprecated but widely supported)
|
||||
const fallbackCopyToClipboard = (text: string, messageText: string): boolean => {
|
||||
try {
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = text
|
||||
|
||||
// Make the textarea invisible
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.left = "-999999px"
|
||||
textArea.style.top = "-999999px"
|
||||
textArea.setAttribute("readonly", "")
|
||||
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
|
||||
const successful = document.execCommand("copy")
|
||||
document.body.removeChild(textArea)
|
||||
|
||||
if (successful) {
|
||||
NotificationsManager.success(messageText)
|
||||
return true
|
||||
} else {
|
||||
throw new Error("execCommand failed")
|
||||
}
|
||||
} catch (err) {
|
||||
NotificationsManager.fromBackend("Failed to copy to clipboard")
|
||||
console.error("Failed to copy: ", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"
|
||||
import { copyToClipboard, formatNumberWithCommas, updateExistingKeys } from "../../src/utils/dataUtils"
|
||||
|
||||
// Mock NotificationsManager
|
||||
vi.mock("../../src/components/molecules/notifications_manager", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
fromBackend: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Import the mocked module
|
||||
import NotificationsManager from "../../src/components/molecules/notifications_manager"
|
||||
const mockNotificationsManager = vi.mocked(NotificationsManager)
|
||||
|
||||
describe("dataUtils", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset document.execCommand mock
|
||||
delete (document as any).execCommand
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("updateExistingKeys", () => {
|
||||
it("should update only existing keys in target object", () => {
|
||||
const target = { a: 1, b: 2, c: 3 }
|
||||
const source = { a: 10, b: 20, d: 40 }
|
||||
|
||||
const result = updateExistingKeys(target, source)
|
||||
|
||||
expect(result).toEqual({ a: 10, b: 20, c: 3 })
|
||||
expect(result).not.toBe(target) // Should be a clone
|
||||
})
|
||||
|
||||
it("should not modify original target object", () => {
|
||||
const target = { a: 1, b: 2 }
|
||||
const source = { a: 10, c: 30 }
|
||||
|
||||
updateExistingKeys(target, source)
|
||||
|
||||
expect(target).toEqual({ a: 1, b: 2 }) // Original unchanged
|
||||
})
|
||||
|
||||
it("should handle empty source object", () => {
|
||||
const target = { a: 1, b: 2 }
|
||||
const source = {}
|
||||
|
||||
const result = updateExistingKeys(target, source)
|
||||
|
||||
expect(result).toEqual({ a: 1, b: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatNumberWithCommas", () => {
|
||||
it("should format numbers with commas", () => {
|
||||
expect(formatNumberWithCommas(1234567)).toBe("1,234,567")
|
||||
expect(formatNumberWithCommas(1000)).toBe("1,000")
|
||||
expect(formatNumberWithCommas(123)).toBe("123")
|
||||
})
|
||||
|
||||
it("should handle decimals", () => {
|
||||
expect(formatNumberWithCommas(1234.5678, 2)).toBe("1,234.57")
|
||||
expect(formatNumberWithCommas(1000.123, 3)).toBe("1,000.123")
|
||||
})
|
||||
|
||||
it("should handle null and undefined values", () => {
|
||||
expect(formatNumberWithCommas(null)).toBe("-")
|
||||
expect(formatNumberWithCommas(undefined)).toBe("-")
|
||||
})
|
||||
|
||||
it("should handle zero", () => {
|
||||
expect(formatNumberWithCommas(0)).toBe("0")
|
||||
expect(formatNumberWithCommas(0, 2)).toBe("0.00")
|
||||
})
|
||||
})
|
||||
|
||||
describe("copyToClipboard", () => {
|
||||
describe("when Clipboard API is available (HTTPS scenario)", () => {
|
||||
beforeEach(() => {
|
||||
// Mock modern Clipboard API
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should use navigator.clipboard.writeText when available", async () => {
|
||||
const result = await copyToClipboard("test text")
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("test text")
|
||||
expect(mockNotificationsManager.success).toHaveBeenCalledWith("Copied to clipboard")
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should use custom message when provided", async () => {
|
||||
await copyToClipboard("test text", "Custom message")
|
||||
|
||||
expect(mockNotificationsManager.success).toHaveBeenCalledWith("Custom message")
|
||||
})
|
||||
|
||||
it("should return false for null/undefined text", async () => {
|
||||
expect(await copyToClipboard(null)).toBe(false)
|
||||
expect(await copyToClipboard(undefined)).toBe(false)
|
||||
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should fall back to execCommand when clipboard API fails", async () => {
|
||||
// Make clipboard API fail
|
||||
navigator.clipboard.writeText = vi.fn().mockRejectedValue(new Error("Permission denied"))
|
||||
|
||||
// Mock successful execCommand
|
||||
document.execCommand = vi.fn().mockReturnValue(true)
|
||||
|
||||
// Mock DOM methods
|
||||
const mockTextArea = {
|
||||
value: "",
|
||||
style: {},
|
||||
setAttribute: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
select: vi.fn(),
|
||||
}
|
||||
document.createElement = vi.fn().mockReturnValue(mockTextArea)
|
||||
document.body.appendChild = vi.fn()
|
||||
document.body.removeChild = vi.fn()
|
||||
|
||||
const result = await copyToClipboard("test text")
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("test text")
|
||||
expect(document.execCommand).toHaveBeenCalledWith("copy")
|
||||
expect(mockNotificationsManager.success).toHaveBeenCalledWith("Copied to clipboard")
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("when Clipboard API is not available (HTTP scenario)", () => {
|
||||
beforeEach(() => {
|
||||
// Mock HTTP scenario - no clipboard API
|
||||
Object.assign(navigator, {
|
||||
clipboard: undefined,
|
||||
})
|
||||
|
||||
// Mock successful execCommand
|
||||
document.execCommand = vi.fn().mockReturnValue(true)
|
||||
|
||||
// Mock DOM methods
|
||||
const mockTextArea = {
|
||||
value: "",
|
||||
style: {},
|
||||
setAttribute: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
select: vi.fn(),
|
||||
}
|
||||
document.createElement = vi.fn().mockReturnValue(mockTextArea)
|
||||
document.body.appendChild = vi.fn()
|
||||
document.body.removeChild = vi.fn()
|
||||
})
|
||||
|
||||
it("should fall back to execCommand when clipboard API is not available", async () => {
|
||||
const result = await copyToClipboard("test text")
|
||||
|
||||
expect(document.createElement).toHaveBeenCalledWith("textarea")
|
||||
expect(document.execCommand).toHaveBeenCalledWith("copy")
|
||||
expect(mockNotificationsManager.success).toHaveBeenCalledWith("Copied to clipboard")
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should set textarea properties correctly", async () => {
|
||||
const mockTextArea = {
|
||||
value: "",
|
||||
style: {},
|
||||
setAttribute: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
select: vi.fn(),
|
||||
}
|
||||
document.createElement = vi.fn().mockReturnValue(mockTextArea)
|
||||
|
||||
await copyToClipboard("test text")
|
||||
|
||||
expect(mockTextArea.value).toBe("test text")
|
||||
expect(mockTextArea.style.position).toBe("fixed")
|
||||
expect(mockTextArea.style.left).toBe("-999999px")
|
||||
expect(mockTextArea.style.top).toBe("-999999px")
|
||||
expect(mockTextArea.setAttribute).toHaveBeenCalledWith("readonly", "")
|
||||
expect(mockTextArea.focus).toHaveBeenCalled()
|
||||
expect(mockTextArea.select).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle execCommand failure", async () => {
|
||||
document.execCommand = vi.fn().mockReturnValue(false)
|
||||
|
||||
const result = await copyToClipboard("test text")
|
||||
|
||||
expect(document.execCommand).toHaveBeenCalledWith("copy")
|
||||
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to copy to clipboard")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle DOM manipulation errors", async () => {
|
||||
document.createElement = vi.fn().mockImplementation(() => {
|
||||
throw new Error("DOM error")
|
||||
})
|
||||
|
||||
const result = await copyToClipboard("test text")
|
||||
|
||||
expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to copy to clipboard")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should clean up textarea element after successful copy", async () => {
|
||||
const mockTextArea = {
|
||||
value: "",
|
||||
style: {},
|
||||
setAttribute: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
select: vi.fn(),
|
||||
}
|
||||
document.createElement = vi.fn().mockReturnValue(mockTextArea)
|
||||
|
||||
await copyToClipboard("test text")
|
||||
|
||||
expect(document.body.appendChild).toHaveBeenCalledWith(mockTextArea)
|
||||
expect(document.body.removeChild).toHaveBeenCalledWith(mockTextArea)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
beforeEach(() => {
|
||||
// Mock scenario where navigator exists but clipboard is null
|
||||
Object.assign(navigator, {
|
||||
clipboard: null,
|
||||
})
|
||||
|
||||
document.execCommand = vi.fn().mockReturnValue(true)
|
||||
const mockTextArea = {
|
||||
value: "",
|
||||
style: {},
|
||||
setAttribute: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
select: vi.fn(),
|
||||
}
|
||||
document.createElement = vi.fn().mockReturnValue(mockTextArea)
|
||||
document.body.appendChild = vi.fn()
|
||||
document.body.removeChild = vi.fn()
|
||||
})
|
||||
|
||||
it("should handle navigator.clipboard being null", async () => {
|
||||
const result = await copyToClipboard("test text")
|
||||
|
||||
expect(document.execCommand).toHaveBeenCalledWith("copy")
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle navigator.clipboard.writeText being undefined", async () => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: {}, // clipboard exists but writeText doesn't
|
||||
})
|
||||
|
||||
const result = await copyToClipboard("test text")
|
||||
|
||||
expect(document.execCommand).toHaveBeenCalledWith("copy")
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useLogFilterLogic } from "../../src/components/view_logs/log_filter_logic";
|
||||
|
||||
// Minimal mocks to avoid real network during hook init
|
||||
vi.mock("../../src/components/key_team_helpers/filter_helpers", () => ({
|
||||
fetchAllKeyAliases: vi.fn().mockResolvedValue([]),
|
||||
fetchAllTeams: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const createQueryClient = () => new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
|
||||
function Harness({ logs }: { logs: any }) {
|
||||
const { filteredLogs } = useLogFilterLogic({
|
||||
logs,
|
||||
accessToken: "token",
|
||||
startTime: "2025-01-01 00:00:00",
|
||||
endTime: "2025-01-02 00:00:00",
|
||||
pageSize: 50,
|
||||
isCustomDate: true,
|
||||
setCurrentPage: () => {},
|
||||
userID: "user-1",
|
||||
userRole: "admin",
|
||||
});
|
||||
|
||||
return <div data-testid="count">{filteredLogs.data.length}</div>;
|
||||
}
|
||||
|
||||
describe("useLogFilterLogic (minimal)", () => {
|
||||
it("useLogFilterLogic minimal: updates filteredLogs when logs change", async () => {
|
||||
const qc = createQueryClient();
|
||||
const logsA = { data: [{ request_id: "a" }], total: 1, page: 1, page_size: 50, total_pages: 1 };
|
||||
const logsB = { data: [{ request_id: "a" }, { request_id: "b" }], total: 2, page: 1, page_size: 50, total_pages: 1 };
|
||||
|
||||
const { rerender } = render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Harness logs={logsA} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId("count")).toHaveTextContent("1");
|
||||
|
||||
rerender(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Harness logs={logsB} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId("count")).toHaveTextContent("2");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user