mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
Merge pull request #18618 from BerriAI/litellm_ui_unit_test_coverage
[Infra] UI - Unit Tests: Adding Tests to Increase Coverage
This commit is contained in:
commit
280085edaa
8 changed files with 1965 additions and 10 deletions
|
|
@ -0,0 +1,620 @@
|
|||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, Mock } from "vitest";
|
||||
import EditSSOSettingsModal from "./EditSSOSettingsModal";
|
||||
import { useSSOSettings } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
|
||||
import { useEditSSOSettings } from "@/app/(dashboard)/hooks/sso/useEditSSOSettings";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { parseErrorMessage } from "@/components/shared/errorUtils";
|
||||
import { processSSOSettingsPayload } from "../utils";
|
||||
|
||||
// Constants
|
||||
const SSO_PROVIDERS = {
|
||||
GOOGLE: "google",
|
||||
MICROSOFT: "microsoft",
|
||||
OKTA: "okta",
|
||||
AUTH0: "auth0",
|
||||
GENERIC: "generic",
|
||||
} as const;
|
||||
|
||||
const TEST_DATA = {
|
||||
MODAL_TITLE: "Edit SSO Settings",
|
||||
MODAL_WIDTH: "800",
|
||||
SUCCESS_MESSAGE: "SSO settings updated successfully",
|
||||
ERROR_MESSAGE_PREFIX: "Failed to save SSO settings:",
|
||||
BUTTON_TEXT: {
|
||||
CANCEL: "Cancel",
|
||||
SAVE: "Save",
|
||||
SAVING: "Saving...",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const TEST_IDS = {
|
||||
MODAL: "modal",
|
||||
BUTTON: "button",
|
||||
BASE_SSO_FORM: "base-sso-form",
|
||||
TRIGGER_FORM_SUBMIT: "trigger-form-submit",
|
||||
} as const;
|
||||
|
||||
// Mock form instance
|
||||
const mockForm = {
|
||||
resetFields: vi.fn(),
|
||||
setFieldsValue: vi.fn(),
|
||||
getFieldsValue: vi.fn(),
|
||||
submit: vi.fn(),
|
||||
};
|
||||
|
||||
// Types
|
||||
type SSOData = {
|
||||
values: Record<string, any>;
|
||||
} & Record<string, any>;
|
||||
|
||||
type SSOSettingsHookReturn = {
|
||||
data: SSOData | null;
|
||||
isLoading: boolean;
|
||||
error: any;
|
||||
};
|
||||
|
||||
type EditSSOSettingsHookReturn = {
|
||||
mutateAsync: ReturnType<typeof vi.fn>;
|
||||
isPending: boolean;
|
||||
};
|
||||
|
||||
// Test data factories
|
||||
const createSSOData = (overrides: Record<string, any> = {}): SSOData => ({
|
||||
values: {
|
||||
user_email: "test@example.com",
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
const createGoogleSSOData = (overrides: Record<string, any> = {}) =>
|
||||
createSSOData({
|
||||
google_client_id: "test-google-id",
|
||||
google_client_secret: "test-google-secret",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMicrosoftSSOData = (overrides: Record<string, any> = {}) =>
|
||||
createSSOData({
|
||||
microsoft_client_id: "test-microsoft-id",
|
||||
microsoft_client_secret: "test-microsoft-secret",
|
||||
microsoft_tenant: "test-tenant",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createGenericSSOData = (overrides: Record<string, any> = {}) =>
|
||||
createSSOData({
|
||||
generic_client_id: "test-generic-id",
|
||||
generic_client_secret: "test-generic-secret",
|
||||
generic_authorization_endpoint: overrides.authorization_endpoint || "https://custom.example.com/oauth",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createRoleMappingsSSOData = (overrides: Record<string, any> = {}) =>
|
||||
createGoogleSSOData({
|
||||
role_mappings: {
|
||||
group_claim: "groups",
|
||||
default_role: "internal_user",
|
||||
roles: {
|
||||
proxy_admin: overrides.proxy_admin || ["admin-group"],
|
||||
proxy_admin_viewer: overrides.proxy_admin_viewer || ["viewer-group"],
|
||||
internal_user: overrides.internal_user || ["user-group"],
|
||||
internal_user_viewer: overrides.internal_user_viewer || ["readonly-group"],
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Mock utilities
|
||||
const createMockHooks = (): {
|
||||
useSSOSettings: SSOSettingsHookReturn;
|
||||
useEditSSOSettings: EditSSOSettingsHookReturn;
|
||||
} => ({
|
||||
useSSOSettings: {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
},
|
||||
useEditSSOSettings: {
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
},
|
||||
});
|
||||
|
||||
vi.mock("antd", () => ({
|
||||
Modal: ({ children, open, title, footer, onCancel, width, ...props }: any) => (
|
||||
<div data-testid={TEST_IDS.MODAL} data-open={open} data-title={title} data-width={width} {...props}>
|
||||
<div data-testid="modal-content">{children}</div>
|
||||
<div data-testid="modal-footer">{footer}</div>
|
||||
<button data-testid="modal-cancel" onClick={onCancel} />
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, onClick, loading, disabled, ...props }: any) => (
|
||||
<button data-testid={TEST_IDS.BUTTON} onClick={onClick} data-loading={loading} disabled={disabled} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Form: {
|
||||
useForm: () => [mockForm],
|
||||
},
|
||||
Space: ({ children, ...props }: any) => (
|
||||
<div data-testid="space" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./BaseSSOSettingsForm", () => ({
|
||||
default: ({ form, onFormSubmit }: any) => (
|
||||
<div data-testid={TEST_IDS.BASE_SSO_FORM}>
|
||||
<button data-testid={TEST_IDS.TRIGGER_FORM_SUBMIT} onClick={() => onFormSubmit({ testField: "testValue" })}>
|
||||
Trigger Form Submit
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/sso/useSSOSettings", () => ({
|
||||
useSSOSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/sso/useEditSSOSettings", () => ({
|
||||
useEditSSOSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/molecules/notifications_manager", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
fromBackend: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shared/errorUtils", () => ({
|
||||
parseErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../utils", () => ({
|
||||
processSSOSettingsPayload: vi.fn(),
|
||||
}));
|
||||
|
||||
// Test helpers
|
||||
const setupMocks = (
|
||||
overrides: Partial<{
|
||||
useSSOSettings: Partial<SSOSettingsHookReturn>;
|
||||
useEditSSOSettings: Partial<EditSSOSettingsHookReturn>;
|
||||
}> = {},
|
||||
) => {
|
||||
const defaultMocks = createMockHooks();
|
||||
const mocks = {
|
||||
useSSOSettings: { ...defaultMocks.useSSOSettings, ...overrides.useSSOSettings },
|
||||
useEditSSOSettings: { ...defaultMocks.useEditSSOSettings, ...overrides.useEditSSOSettings },
|
||||
};
|
||||
|
||||
(useSSOSettings as Mock).mockReturnValue(mocks.useSSOSettings);
|
||||
(useEditSSOSettings as Mock).mockReturnValue(mocks.useEditSSOSettings);
|
||||
|
||||
return mocks;
|
||||
};
|
||||
|
||||
const renderComponent = (props: Partial<React.ComponentProps<typeof EditSSOSettingsModal>> = {}) => {
|
||||
const defaultProps = {
|
||||
isVisible: true,
|
||||
onCancel: vi.fn(),
|
||||
onSuccess: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
...render(<EditSSOSettingsModal {...defaultProps} {...props} />),
|
||||
mockOnCancel: defaultProps.onCancel,
|
||||
mockOnSuccess: defaultProps.onSuccess,
|
||||
};
|
||||
};
|
||||
|
||||
const getButtons = () => screen.getAllByTestId(TEST_IDS.BUTTON);
|
||||
const getCancelButton = () => getButtons()[0];
|
||||
const getSaveButton = () => getButtons()[1];
|
||||
|
||||
describe("EditSSOSettingsModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("renders without crashing", () => {
|
||||
expect(() => renderComponent()).not.toThrow();
|
||||
});
|
||||
|
||||
it("displays modal with correct configuration", () => {
|
||||
renderComponent();
|
||||
|
||||
const modal = screen.getByTestId(TEST_IDS.MODAL);
|
||||
expect(modal).toHaveAttribute("data-open", "true");
|
||||
expect(modal).toHaveAttribute("data-title", TEST_DATA.MODAL_TITLE);
|
||||
expect(modal).toHaveAttribute("data-width", TEST_DATA.MODAL_WIDTH);
|
||||
});
|
||||
|
||||
it("displays modal as closed when not visible", () => {
|
||||
renderComponent({ isVisible: false });
|
||||
|
||||
const modal = screen.getByTestId(TEST_IDS.MODAL);
|
||||
expect(modal).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Footer Actions", () => {
|
||||
it("renders cancel and save buttons", () => {
|
||||
renderComponent();
|
||||
|
||||
const buttons = getButtons();
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(buttons[0]).toHaveTextContent(TEST_DATA.BUTTON_TEXT.CANCEL);
|
||||
expect(buttons[1]).toHaveTextContent(TEST_DATA.BUTTON_TEXT.SAVE);
|
||||
});
|
||||
|
||||
it("calls onCancel and resets form when cancel button is clicked", () => {
|
||||
const { mockOnCancel } = renderComponent();
|
||||
|
||||
fireEvent.click(getCancelButton());
|
||||
|
||||
expect(mockForm.resetFields).toHaveBeenCalled();
|
||||
expect(mockOnCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls form.submit when save button is clicked", () => {
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(getSaveButton());
|
||||
|
||||
expect(mockForm.submit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("Loading States", () => {
|
||||
it("disables cancel button during submission", () => {
|
||||
setupMocks({
|
||||
useEditSSOSettings: { mutateAsync: vi.fn(), isPending: true },
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(getCancelButton()).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows loading state on save button during submission", () => {
|
||||
setupMocks({
|
||||
useEditSSOSettings: { mutateAsync: vi.fn(), isPending: true },
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(getSaveButton()).toHaveAttribute("data-loading", "true");
|
||||
expect(getSaveButton()).toHaveTextContent(TEST_DATA.BUTTON_TEXT.SAVING);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form Submission", () => {
|
||||
const formValues = { testField: "testValue" };
|
||||
const processedPayload = { processed: "payload" };
|
||||
|
||||
beforeEach(() => {
|
||||
(processSSOSettingsPayload as any).mockReturnValue(processedPayload);
|
||||
});
|
||||
|
||||
it("processes form values and submits successfully", async () => {
|
||||
const mockMutateAsync = vi.fn().mockImplementation((payload, options) => {
|
||||
options.onSuccess();
|
||||
return Promise.resolve({ success: true });
|
||||
});
|
||||
|
||||
setupMocks({
|
||||
useEditSSOSettings: { mutateAsync: mockMutateAsync, isPending: false },
|
||||
});
|
||||
|
||||
const { mockOnSuccess } = renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
|
||||
|
||||
expect(processSSOSettingsPayload).toHaveBeenCalledWith(formValues);
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
processedPayload,
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows success notification and calls onSuccess callback", async () => {
|
||||
const mockMutateAsync = vi.fn().mockImplementation((payload, options) => {
|
||||
options.onSuccess();
|
||||
return Promise.resolve({ success: true });
|
||||
});
|
||||
|
||||
setupMocks({
|
||||
useEditSSOSettings: { mutateAsync: mockMutateAsync, isPending: false },
|
||||
});
|
||||
|
||||
const { mockOnSuccess } = renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
|
||||
|
||||
expect(NotificationsManager.success).toHaveBeenCalledWith(TEST_DATA.SUCCESS_MESSAGE);
|
||||
expect(mockOnSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles submission errors gracefully", async () => {
|
||||
const error = new Error("Submission failed");
|
||||
const mockMutateAsync = vi.fn().mockImplementation((payload, options) => {
|
||||
options.onError(error);
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
setupMocks({
|
||||
useEditSSOSettings: { mutateAsync: mockMutateAsync, isPending: false },
|
||||
});
|
||||
|
||||
(parseErrorMessage as any).mockReturnValue("Parsed error message");
|
||||
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
|
||||
|
||||
expect(parseErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(
|
||||
`${TEST_DATA.ERROR_MESSAGE_PREFIX} Parsed error message`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form Initialization", () => {
|
||||
describe("Provider Detection", () => {
|
||||
const testProviderDetection = (testName: string, ssoData: SSOData, expectedProvider: string) => {
|
||||
it(`detects ${testName} provider`, async () => {
|
||||
setupMocks({
|
||||
useSSOSettings: { data: ssoData, isLoading: false, error: null },
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
|
||||
sso_provider: expectedProvider,
|
||||
...ssoData.values,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
testProviderDetection("Google", createGoogleSSOData(), SSO_PROVIDERS.GOOGLE);
|
||||
|
||||
testProviderDetection("Microsoft", createMicrosoftSSOData(), SSO_PROVIDERS.MICROSOFT);
|
||||
|
||||
testProviderDetection(
|
||||
"Okta",
|
||||
createGenericSSOData({
|
||||
authorization_endpoint: "https://okta.example.com/oauth2/authorize",
|
||||
}),
|
||||
SSO_PROVIDERS.OKTA,
|
||||
);
|
||||
|
||||
testProviderDetection(
|
||||
"Auth0 (detected as Okta)",
|
||||
createGenericSSOData({
|
||||
authorization_endpoint: "https://auth0.example.com/authorize",
|
||||
}),
|
||||
SSO_PROVIDERS.OKTA, // Auth0 URLs are detected as Okta provider
|
||||
);
|
||||
|
||||
testProviderDetection("generic", createGenericSSOData(), SSO_PROVIDERS.GENERIC);
|
||||
});
|
||||
|
||||
describe("Role Mappings", () => {
|
||||
it("processes role mappings with all roles assigned", async () => {
|
||||
const ssoData = createRoleMappingsSSOData();
|
||||
|
||||
setupMocks({
|
||||
useSSOSettings: { data: ssoData, isLoading: false, error: null },
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
|
||||
sso_provider: SSO_PROVIDERS.GOOGLE,
|
||||
...ssoData.values,
|
||||
use_role_mappings: true,
|
||||
group_claim: "groups",
|
||||
default_role: "internal_user",
|
||||
proxy_admin_teams: "admin-group",
|
||||
admin_viewer_teams: "viewer-group",
|
||||
internal_user_teams: "user-group",
|
||||
internal_viewer_teams: "readonly-group",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("handles empty role mapping arrays", async () => {
|
||||
const ssoData = createRoleMappingsSSOData({
|
||||
proxy_admin: [],
|
||||
proxy_admin_viewer: [],
|
||||
internal_user_viewer: [],
|
||||
});
|
||||
|
||||
setupMocks({
|
||||
useSSOSettings: { data: ssoData, isLoading: false, error: null },
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
|
||||
sso_provider: SSO_PROVIDERS.GOOGLE,
|
||||
...ssoData.values,
|
||||
use_role_mappings: true,
|
||||
group_claim: "groups",
|
||||
default_role: "internal_user",
|
||||
proxy_admin_teams: "",
|
||||
admin_viewer_teams: "",
|
||||
internal_user_teams: "user-group",
|
||||
internal_viewer_teams: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Initialization Guards", () => {
|
||||
it("resets form before setting values", async () => {
|
||||
const ssoData = createGoogleSSOData();
|
||||
|
||||
setupMocks({
|
||||
useSSOSettings: { data: ssoData, isLoading: false, error: null },
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForm.resetFields).toHaveBeenCalled();
|
||||
expect(mockForm.setFieldsValue).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("skips initialization when modal is not visible", () => {
|
||||
const ssoData = createGoogleSSOData();
|
||||
|
||||
setupMocks({
|
||||
useSSOSettings: { data: ssoData, isLoading: false, error: null },
|
||||
});
|
||||
|
||||
renderComponent({ isVisible: false });
|
||||
|
||||
expect(mockForm.setFieldsValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips initialization when SSO data is unavailable", () => {
|
||||
setupMocks({
|
||||
useSSOSettings: { data: null, isLoading: false, error: null },
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(mockForm.setFieldsValue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("handles form submission errors with undefined error message", async () => {
|
||||
const error = new Error("Network error");
|
||||
const mockMutateAsync = vi.fn().mockImplementation((payload, options) => {
|
||||
options.onError(error);
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
setupMocks({
|
||||
useEditSSOSettings: { mutateAsync: mockMutateAsync, isPending: false },
|
||||
});
|
||||
|
||||
(parseErrorMessage as any).mockReturnValue(undefined);
|
||||
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
|
||||
|
||||
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith(`${TEST_DATA.ERROR_MESSAGE_PREFIX} undefined`);
|
||||
});
|
||||
|
||||
it("handles form submission with malformed data", async () => {
|
||||
const mockMutateAsync = vi.fn().mockImplementation((payload, options) => {
|
||||
options.onError(new Error("Invalid data"));
|
||||
return Promise.reject(new Error("Invalid data"));
|
||||
});
|
||||
|
||||
setupMocks({
|
||||
useEditSSOSettings: { mutateAsync: mockMutateAsync, isPending: false },
|
||||
});
|
||||
|
||||
(processSSOSettingsPayload as any).mockImplementation(() => {
|
||||
throw new Error("Processing failed");
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
|
||||
|
||||
expect(processSSOSettingsPayload).toHaveBeenCalled();
|
||||
expect(mockMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("handles role mappings with undefined roles object", async () => {
|
||||
const ssoData = createGoogleSSOData({
|
||||
role_mappings: {
|
||||
group_claim: "groups",
|
||||
default_role: "internal_user",
|
||||
// roles is undefined
|
||||
},
|
||||
});
|
||||
|
||||
setupMocks({
|
||||
useSSOSettings: { data: ssoData, isLoading: false, error: null },
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
|
||||
sso_provider: SSO_PROVIDERS.GOOGLE,
|
||||
...ssoData.values,
|
||||
use_role_mappings: true,
|
||||
group_claim: "groups",
|
||||
default_role: "internal_user",
|
||||
proxy_admin_teams: "",
|
||||
admin_viewer_teams: "",
|
||||
internal_user_teams: "",
|
||||
internal_viewer_teams: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("handles provider detection with partial SSO data", async () => {
|
||||
const ssoData = createSSOData({
|
||||
// Only has generic fields, no specific provider identifiers
|
||||
generic_client_id: "test-id",
|
||||
generic_authorization_endpoint: "https://unknown.provider.com/auth",
|
||||
});
|
||||
|
||||
setupMocks({
|
||||
useSSOSettings: { data: ssoData, isLoading: false, error: null },
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForm.setFieldsValue).toHaveBeenCalledWith({
|
||||
sso_provider: SSO_PROVIDERS.GENERIC,
|
||||
...ssoData.values,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("handles form submission when processing throws error", async () => {
|
||||
setupMocks({
|
||||
useEditSSOSettings: { mutateAsync: vi.fn(), isPending: false },
|
||||
});
|
||||
|
||||
(processSSOSettingsPayload as any).mockImplementation(() => {
|
||||
throw new Error("Processing error");
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByTestId(TEST_IDS.TRIGGER_FORM_SUBMIT));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(processSSOSettingsPayload).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -88,17 +88,22 @@ const EditSSOSettingsModal: React.FC<EditSSOSettingsModalProps> = ({ isVisible,
|
|||
|
||||
// Enhanced form submission handler
|
||||
const handleFormSubmit = async (formValues: Record<string, any>) => {
|
||||
const payload = processSSOSettingsPayload(formValues);
|
||||
try {
|
||||
const payload = processSSOSettingsPayload(formValues);
|
||||
|
||||
await mutateAsync(payload, {
|
||||
onSuccess: () => {
|
||||
NotificationsManager.success("SSO settings updated successfully");
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
NotificationsManager.fromBackend("Failed to save SSO settings: " + parseErrorMessage(error));
|
||||
},
|
||||
});
|
||||
await mutateAsync(payload, {
|
||||
onSuccess: () => {
|
||||
NotificationsManager.success("SSO settings updated successfully");
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
NotificationsManager.fromBackend("Failed to save SSO settings: " + parseErrorMessage(error));
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle processing errors gracefully
|
||||
NotificationsManager.fromBackend("Failed to process SSO settings: " + parseErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import SSOSettingsLoadingSkeleton from "./SSOSettingsLoadingSkeleton";
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
Shield: ({ className }: any) => <div data-testid="shield-icon" className={className} />,
|
||||
}));
|
||||
|
||||
// Mock Ant Design components
|
||||
vi.mock("antd", () => ({
|
||||
Card: ({ children, ...props }: any) => (
|
||||
<div data-testid="card" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Descriptions: Object.assign(
|
||||
({ children, bordered, column, ...props }: any) => (
|
||||
<div data-testid="descriptions" data-bordered={bordered} data-column={JSON.stringify(column)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
{
|
||||
Item: ({ children, label, ...props }: any) => (
|
||||
<div data-testid="descriptions-item" {...props}>
|
||||
<div data-testid="descriptions-item-label">{label}</div>
|
||||
<div data-testid="descriptions-item-content">{children}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
),
|
||||
Typography: {
|
||||
Title: ({ children, level, ...props }: any) => (
|
||||
<div data-testid="typography-title" data-level={level} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Text: ({ children, type, ...props }: any) => (
|
||||
<div data-testid="typography-text" data-type={type} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
Space: ({ children, direction, size, className, ...props }: any) => (
|
||||
<div data-testid="space" data-direction={direction} data-size={size} className={className} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Skeleton: {
|
||||
Button: ({ active, size, style, ...props }: any) => (
|
||||
<div
|
||||
data-testid="skeleton-button"
|
||||
data-active={active}
|
||||
data-size={size}
|
||||
data-style={JSON.stringify(style)}
|
||||
{...props}
|
||||
>
|
||||
Button Skeleton
|
||||
</div>
|
||||
),
|
||||
Node: ({ active, style, ...props }: any) => (
|
||||
<div data-testid="skeleton-node" data-active={active} data-style={JSON.stringify(style)} {...props}>
|
||||
Node Skeleton
|
||||
</div>
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("SSOSettingsLoadingSkeleton", () => {
|
||||
it("should render without crashing", () => {
|
||||
expect(() => render(<SSOSettingsLoadingSkeleton />)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should render Card component", () => {
|
||||
render(<SSOSettingsLoadingSkeleton />);
|
||||
expect(screen.getByTestId("card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render Space component with correct props", () => {
|
||||
render(<SSOSettingsLoadingSkeleton />);
|
||||
const space = screen.getByTestId("space");
|
||||
expect(space).toBeInTheDocument();
|
||||
expect(space).toHaveAttribute("data-direction", "vertical");
|
||||
expect(space).toHaveAttribute("data-size", "large");
|
||||
expect(space).toHaveClass("w-full");
|
||||
});
|
||||
|
||||
describe("Header Section", () => {
|
||||
it("should render Shield icon", () => {
|
||||
render(<SSOSettingsLoadingSkeleton />);
|
||||
const shieldIcon = screen.getByTestId("shield-icon");
|
||||
expect(shieldIcon).toBeInTheDocument();
|
||||
expect(shieldIcon).toHaveClass("w-6 h-6 text-gray-400");
|
||||
});
|
||||
|
||||
it("should render title with correct text and level", () => {
|
||||
render(<SSOSettingsLoadingSkeleton />);
|
||||
const title = screen.getByTestId("typography-title");
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(title).toHaveAttribute("data-level", "3");
|
||||
expect(title).toHaveTextContent("SSO Configuration");
|
||||
});
|
||||
|
||||
it("should render subtitle text", () => {
|
||||
render(<SSOSettingsLoadingSkeleton />);
|
||||
const text = screen.getByTestId("typography-text");
|
||||
expect(text).toBeInTheDocument();
|
||||
expect(text).toHaveAttribute("data-type", "secondary");
|
||||
expect(text).toHaveTextContent("Manage Single Sign-On authentication settings");
|
||||
});
|
||||
|
||||
it("should render two skeleton buttons with correct styles", () => {
|
||||
render(<SSOSettingsLoadingSkeleton />);
|
||||
const buttons = screen.getAllByTestId("skeleton-button");
|
||||
expect(buttons).toHaveLength(2);
|
||||
|
||||
// First button
|
||||
expect(buttons[0]).toHaveAttribute("data-active", "true");
|
||||
expect(buttons[0]).toHaveAttribute("data-size", "default");
|
||||
expect(buttons[0]).toHaveAttribute("data-style", JSON.stringify({ width: 170, height: 32 }));
|
||||
|
||||
// Second button
|
||||
expect(buttons[1]).toHaveAttribute("data-active", "true");
|
||||
expect(buttons[1]).toHaveAttribute("data-size", "default");
|
||||
expect(buttons[1]).toHaveAttribute("data-style", JSON.stringify({ width: 190, height: 32 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Descriptions Table", () => {
|
||||
it("should render Descriptions component with bordered prop", () => {
|
||||
render(<SSOSettingsLoadingSkeleton />);
|
||||
const descriptions = screen.getByTestId("descriptions");
|
||||
expect(descriptions).toBeInTheDocument();
|
||||
expect(descriptions).toHaveAttribute("data-bordered", "true");
|
||||
});
|
||||
|
||||
it("should apply correct column configuration", () => {
|
||||
render(<SSOSettingsLoadingSkeleton />);
|
||||
const descriptions = screen.getByTestId("descriptions");
|
||||
const expectedColumn = {
|
||||
xxl: 1,
|
||||
xl: 1,
|
||||
lg: 1,
|
||||
md: 1,
|
||||
sm: 1,
|
||||
xs: 1,
|
||||
};
|
||||
expect(descriptions).toHaveAttribute("data-column", JSON.stringify(expectedColumn));
|
||||
});
|
||||
|
||||
it("should render exactly 5 description items", () => {
|
||||
render(<SSOSettingsLoadingSkeleton />);
|
||||
const items = screen.getAllByTestId("descriptions-item");
|
||||
expect(items).toHaveLength(5);
|
||||
});
|
||||
|
||||
describe("Description Items Structure", () => {
|
||||
it("should render exactly 10 skeleton nodes total", () => {
|
||||
render(<SSOSettingsLoadingSkeleton />);
|
||||
const skeletonNodes = screen.getAllByTestId("skeleton-node");
|
||||
expect(skeletonNodes).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("should render 5 skeleton nodes for labels with width 80", () => {
|
||||
render(<SSOSettingsLoadingSkeleton />);
|
||||
const skeletonNodes = screen.getAllByTestId("skeleton-node");
|
||||
|
||||
const labelNodes = skeletonNodes.filter(
|
||||
(node) => node.getAttribute("data-style") === JSON.stringify({ width: 80, height: 16 }),
|
||||
);
|
||||
expect(labelNodes).toHaveLength(5);
|
||||
|
||||
labelNodes.forEach((node) => {
|
||||
expect(node).toHaveAttribute("data-active", "true");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render skeleton nodes for content with correct widths", () => {
|
||||
render(<SSOSettingsLoadingSkeleton />);
|
||||
const skeletonNodes = screen.getAllByTestId("skeleton-node");
|
||||
|
||||
// Expected content widths: [100, 200, 250, 180, 220]
|
||||
const expectedWidths = [100, 200, 250, 180, 220];
|
||||
expectedWidths.forEach((width) => {
|
||||
const contentNode = skeletonNodes.find(
|
||||
(node) => node.getAttribute("data-style") === JSON.stringify({ width, height: 16 }),
|
||||
);
|
||||
expect(contentNode).toBeInTheDocument();
|
||||
expect(contentNode).toHaveAttribute("data-active", "true");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility and Structure", () => {
|
||||
it("should have proper semantic structure", () => {
|
||||
render(<SSOSettingsLoadingSkeleton />);
|
||||
// Card contains Space
|
||||
const card = screen.getByTestId("card");
|
||||
const space = screen.getByTestId("space");
|
||||
expect(card).toContainElement(space);
|
||||
|
||||
// Space contains header section and descriptions
|
||||
const descriptions = screen.getByTestId("descriptions");
|
||||
expect(space).toContainElement(descriptions);
|
||||
});
|
||||
|
||||
it("should render all skeleton elements as active", () => {
|
||||
render(<SSOSettingsLoadingSkeleton />);
|
||||
const skeletonNodes = screen.getAllByTestId("skeleton-node");
|
||||
const skeletonButtons = screen.getAllByTestId("skeleton-button");
|
||||
|
||||
skeletonNodes.forEach((node) => {
|
||||
expect(node).toHaveAttribute("data-active", "true");
|
||||
});
|
||||
|
||||
skeletonButtons.forEach((button) => {
|
||||
expect(button).toHaveAttribute("data-active", "true");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,522 @@
|
|||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { VectorStore } from "./types";
|
||||
import VectorStoreSelector from "./VectorStoreSelector";
|
||||
|
||||
// Mock dependencies
|
||||
const mockVectorStoreListCall = vi.fn();
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
vectorStoreListCall: (...args: any[]) => mockVectorStoreListCall(...args),
|
||||
}));
|
||||
|
||||
// Mock antd Select component
|
||||
vi.mock("antd", () => ({
|
||||
Select: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import the mocked Select
|
||||
import { Select as MockedSelect } from "antd";
|
||||
|
||||
// Configure the mock to render a simple div with data attributes
|
||||
(MockedSelect as any).mockImplementation((props: any) => {
|
||||
const {
|
||||
onChange,
|
||||
value,
|
||||
placeholder,
|
||||
loading,
|
||||
className,
|
||||
disabled,
|
||||
options,
|
||||
mode,
|
||||
showSearch,
|
||||
optionFilterProp,
|
||||
style,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="vector-store-select"
|
||||
data-loading={loading}
|
||||
data-disabled={disabled}
|
||||
data-mode={mode}
|
||||
data-show-search={showSearch}
|
||||
data-option-filter-prop={optionFilterProp}
|
||||
data-placeholder={placeholder}
|
||||
data-value={value !== undefined ? JSON.stringify(value) : undefined}
|
||||
data-options={JSON.stringify(options)}
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={(e: any) => {
|
||||
// For testing purposes, allow simulating different selection behaviors
|
||||
// The test can control this by setting data attributes on the element
|
||||
const testSelection = e.target.getAttribute("data-test-selection");
|
||||
if (testSelection && onChange) {
|
||||
onChange(JSON.parse(testSelection));
|
||||
} else if (onChange && options?.length > 0) {
|
||||
// Default behavior: select first option
|
||||
onChange([options[0].value]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options?.map((opt: any) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
data-option-value={opt.value}
|
||||
data-option-label={opt.label}
|
||||
data-option-title={opt.title}
|
||||
data-testid={`option-${opt.value}`}
|
||||
>
|
||||
{opt.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Test helpers
|
||||
const mockOnChange = vi.fn();
|
||||
const mockAccessToken = "test-token";
|
||||
|
||||
const mockVectorStores: VectorStore[] = [
|
||||
{
|
||||
vector_store_id: "store-1",
|
||||
custom_llm_provider: "openai",
|
||||
vector_store_name: "My Store",
|
||||
vector_store_description: "A test store",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
vector_store_id: "store-2",
|
||||
custom_llm_provider: "azure",
|
||||
vector_store_name: "Another Store",
|
||||
vector_store_description: "Another test store",
|
||||
created_at: "2024-01-02T00:00:00Z",
|
||||
updated_at: "2024-01-02T00:00:00Z",
|
||||
},
|
||||
{
|
||||
vector_store_id: "store-3",
|
||||
custom_llm_provider: "pg_vector",
|
||||
// No vector_store_name to test fallback to vector_store_id
|
||||
vector_store_description: "Store without name",
|
||||
created_at: "2024-01-03T00:00:00Z",
|
||||
updated_at: "2024-01-03T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
onChange: mockOnChange,
|
||||
accessToken: mockAccessToken,
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
const renderComponent = (props = {}) => {
|
||||
return render(<VectorStoreSelector {...defaultProps} {...props} />);
|
||||
};
|
||||
|
||||
const waitForDataFetch = async () => {
|
||||
await waitFor(() => {
|
||||
expect(mockVectorStoreListCall).toHaveBeenCalled();
|
||||
});
|
||||
};
|
||||
|
||||
const getSelectElement = () => screen.getByTestId("vector-store-select");
|
||||
|
||||
const getOptionElements = () =>
|
||||
screen.getAllByTestId(/^vector-store-select/).filter((el) => el.hasAttribute("data-option-value"));
|
||||
|
||||
describe("VectorStoreSelector", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockVectorStoreListCall.mockResolvedValue({
|
||||
data: mockVectorStores,
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should render the select component", () => {
|
||||
renderComponent();
|
||||
expect(getSelectElement()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with default placeholder", () => {
|
||||
renderComponent();
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-placeholder", "Select vector stores");
|
||||
});
|
||||
|
||||
it("should render with custom placeholder", () => {
|
||||
renderComponent({ placeholder: "Choose stores" });
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-placeholder", "Choose stores");
|
||||
});
|
||||
|
||||
it("should apply custom className", () => {
|
||||
renderComponent({ className: "custom-class" });
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveClass("custom-class");
|
||||
});
|
||||
|
||||
it("should render with disabled state", () => {
|
||||
renderComponent({ disabled: true });
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-disabled", "true");
|
||||
});
|
||||
|
||||
it("should render with enabled state by default", () => {
|
||||
renderComponent();
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-disabled", "false");
|
||||
});
|
||||
|
||||
it("should render with multiple mode", () => {
|
||||
renderComponent();
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-mode", "multiple");
|
||||
});
|
||||
|
||||
it("should render with showSearch enabled", () => {
|
||||
renderComponent();
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-show-search", "true");
|
||||
});
|
||||
|
||||
it("should render with optionFilterProp set to label", () => {
|
||||
renderComponent();
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-option-filter-prop", "label");
|
||||
});
|
||||
|
||||
it("should render with full width style", () => {
|
||||
renderComponent();
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveStyle({ width: "100%" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data fetching", () => {
|
||||
it("should fetch vector stores on mount when accessToken is provided", async () => {
|
||||
renderComponent();
|
||||
await waitFor(() => {
|
||||
expect(mockVectorStoreListCall).toHaveBeenCalledWith(mockAccessToken);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not fetch vector stores when accessToken is falsy", () => {
|
||||
const { rerender } = render(<VectorStoreSelector {...defaultProps} accessToken="" />);
|
||||
expect(mockVectorStoreListCall).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<VectorStoreSelector {...defaultProps} accessToken={null as any} />);
|
||||
expect(mockVectorStoreListCall).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<VectorStoreSelector {...defaultProps} accessToken={undefined as any} />);
|
||||
expect(mockVectorStoreListCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fetch vector stores again when accessToken changes", async () => {
|
||||
const { rerender } = render(<VectorStoreSelector {...defaultProps} accessToken="token-1" />);
|
||||
await waitFor(() => {
|
||||
expect(mockVectorStoreListCall).toHaveBeenCalledWith("token-1");
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
rerender(<VectorStoreSelector {...defaultProps} accessToken="token-2" />);
|
||||
await waitFor(() => {
|
||||
expect(mockVectorStoreListCall).toHaveBeenCalledWith("token-2");
|
||||
});
|
||||
});
|
||||
|
||||
it("should set loading state while fetching", async () => {
|
||||
let resolvePromise: (value: any) => void;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
mockVectorStoreListCall.mockReturnValue(promise);
|
||||
|
||||
renderComponent();
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-loading", "true");
|
||||
|
||||
resolvePromise!({ data: mockVectorStores });
|
||||
await waitFor(() => {
|
||||
expect(select).toHaveAttribute("data-loading", "false");
|
||||
});
|
||||
});
|
||||
|
||||
it("should clear loading state after successful fetch", async () => {
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-loading", "false");
|
||||
});
|
||||
|
||||
it("should clear loading state after failed fetch", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mockVectorStoreListCall.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-loading", "false");
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Options rendering", () => {
|
||||
it("should render vector store options after successful fetch", async () => {
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
expect(screen.getByText("My Store (store-1)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Another Store (store-2)")).toBeInTheDocument();
|
||||
expect(screen.getByText("store-3 (store-3)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should use vector_store_name when available for label", async () => {
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
const option1 = screen.getByText("My Store (store-1)");
|
||||
expect(option1).toBeInTheDocument();
|
||||
expect(option1).toHaveAttribute("data-option-title", "A test store");
|
||||
});
|
||||
|
||||
it("should fallback to vector_store_id when vector_store_name is missing", async () => {
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
const option3 = screen.getByText("store-3 (store-3)");
|
||||
expect(option3).toBeInTheDocument();
|
||||
// When vector_store_name is missing, title uses vector_store_description if available, otherwise vector_store_id
|
||||
expect(option3).toHaveAttribute("data-option-title", "Store without name");
|
||||
});
|
||||
|
||||
it("should use vector_store_description as title when available", async () => {
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
const option1 = screen.getByText("My Store (store-1)");
|
||||
expect(option1).toHaveAttribute("data-option-title", "A test store");
|
||||
});
|
||||
|
||||
it("should fallback to vector_store_id as title when vector_store_description is missing", async () => {
|
||||
const storesWithoutDescription: VectorStore[] = [
|
||||
{
|
||||
vector_store_id: "store-no-desc",
|
||||
custom_llm_provider: "openai",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
mockVectorStoreListCall.mockResolvedValueOnce({
|
||||
data: storesWithoutDescription,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
const option = screen.getByText("store-no-desc (store-no-desc)");
|
||||
expect(option).toHaveAttribute("data-option-title", "store-no-desc");
|
||||
});
|
||||
|
||||
it("should use vector_store_id as option value", async () => {
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
const option1 = screen.getByText("My Store (store-1)");
|
||||
expect(option1).toHaveAttribute("data-option-value", "store-1");
|
||||
});
|
||||
|
||||
it("should handle empty vector stores array", async () => {
|
||||
mockVectorStoreListCall.mockResolvedValueOnce({
|
||||
data: [],
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
const options = getOptionElements();
|
||||
expect(options.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle response without data property", async () => {
|
||||
mockVectorStoreListCall.mockResolvedValueOnce({});
|
||||
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
const options = getOptionElements();
|
||||
expect(options.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Value prop", () => {
|
||||
it("should set initial value when value prop is provided", async () => {
|
||||
renderComponent({ value: ["store-1", "store-2"] });
|
||||
await waitForDataFetch();
|
||||
|
||||
const select = getSelectElement();
|
||||
const dataValue = select.getAttribute("data-value");
|
||||
expect(dataValue).toBe(JSON.stringify(["store-1", "store-2"]));
|
||||
});
|
||||
|
||||
it("should handle empty value array", async () => {
|
||||
renderComponent({ value: [] });
|
||||
await waitForDataFetch();
|
||||
|
||||
const select = getSelectElement();
|
||||
const dataValue = select.getAttribute("data-value");
|
||||
expect(dataValue).toBe(JSON.stringify([]));
|
||||
});
|
||||
|
||||
it("should handle undefined value", async () => {
|
||||
renderComponent({ value: undefined });
|
||||
await waitForDataFetch();
|
||||
|
||||
const select = getSelectElement();
|
||||
const dataValue = select.getAttribute("data-value");
|
||||
expect(dataValue).toBeNull(); // undefined value results in no data-value attribute
|
||||
});
|
||||
});
|
||||
|
||||
describe("onChange callback", () => {
|
||||
it("should call onChange when selection changes", async () => {
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
const select = getSelectElement();
|
||||
// Simulate selecting store-1 by setting test data attribute
|
||||
select.setAttribute("data-test-selection", '["store-1"]');
|
||||
fireEvent.click(select);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(["store-1"]);
|
||||
});
|
||||
|
||||
it("should call onChange with multiple selected values", async () => {
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
const select = getSelectElement();
|
||||
// Simulate selecting multiple values
|
||||
select.setAttribute("data-test-selection", '["store-1", "store-2"]');
|
||||
fireEvent.click(select);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(["store-1", "store-2"]);
|
||||
});
|
||||
|
||||
it("should call onChange when deselecting options", async () => {
|
||||
renderComponent({ value: ["store-1", "store-2"] });
|
||||
await waitForDataFetch();
|
||||
|
||||
const select = getSelectElement();
|
||||
// Simulate deselecting store-1
|
||||
select.setAttribute("data-test-selection", '["store-2"]');
|
||||
fireEvent.click(select);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(["store-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling", () => {
|
||||
it("should handle fetch errors gracefully", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const error = new Error("Network error");
|
||||
mockVectorStoreListCall.mockRejectedValueOnce(error);
|
||||
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Error fetching vector stores:", error);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not crash when fetch throws non-Error", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mockVectorStoreListCall.mockRejectedValueOnce("String error");
|
||||
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Error fetching vector stores:", "String error");
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should continue to work after error", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mockVectorStoreListCall.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
// Component should still render
|
||||
expect(getSelectElement()).toBeInTheDocument();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle vector stores with all optional fields missing", async () => {
|
||||
const minimalStores: VectorStore[] = [
|
||||
{
|
||||
vector_store_id: "minimal-store",
|
||||
custom_llm_provider: "openai",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
mockVectorStoreListCall.mockResolvedValueOnce({
|
||||
data: minimalStores,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
expect(screen.getByText("minimal-store (minimal-store)")).toBeInTheDocument();
|
||||
const option = screen.getByText("minimal-store (minimal-store)");
|
||||
expect(option).toHaveAttribute("data-option-title", "minimal-store");
|
||||
});
|
||||
|
||||
it("should handle very long vector store names", async () => {
|
||||
const longNameStores: VectorStore[] = [
|
||||
{
|
||||
vector_store_id: "store-long",
|
||||
custom_llm_provider: "openai",
|
||||
vector_store_name: "A".repeat(200),
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
mockVectorStoreListCall.mockResolvedValueOnce({
|
||||
data: longNameStores,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
const expectedLabel = `${"A".repeat(200)} (store-long)`;
|
||||
expect(screen.getByText(expectedLabel)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle special characters in vector store names", async () => {
|
||||
const specialCharStores: VectorStore[] = [
|
||||
{
|
||||
vector_store_id: "store-special",
|
||||
custom_llm_provider: "openai",
|
||||
vector_store_name: 'Store & Co. <Test> "Quotes"',
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
mockVectorStoreListCall.mockResolvedValueOnce({
|
||||
data: specialCharStores,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
expect(screen.getByText(/Store & Co\. <Test> "Quotes"/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import VectorStoreTable from "./VectorStoreTable";
|
||||
import { VectorStore } from "./types";
|
||||
|
||||
// Mock dependencies
|
||||
const mockGetProviderLogoAndName = vi.fn();
|
||||
const mockTableIconActionButton = vi.fn();
|
||||
|
||||
vi.mock("../provider_info_helpers", () => ({
|
||||
getProviderLogoAndName: (...args: any[]) => mockGetProviderLogoAndName(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton", () => ({
|
||||
default: (props: any) => {
|
||||
mockTableIconActionButton(props);
|
||||
return (
|
||||
<button
|
||||
data-testid={`action-button-${props.variant.toLowerCase()}`}
|
||||
data-tooltip={props.tooltipText}
|
||||
onClick={props.onClick}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{props.variant}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock Tremor components to avoid complex styling issues
|
||||
vi.mock("@tremor/react", () => ({
|
||||
Table: ({ children, ...props }: any) => <table {...props}>{children}</table>,
|
||||
TableHead: ({ children, ...props }: any) => <thead {...props}>{children}</thead>,
|
||||
TableBody: ({ children, ...props }: any) => <tbody {...props}>{children}</tbody>,
|
||||
TableRow: ({ children, ...props }: any) => <tr {...props}>{children}</tr>,
|
||||
TableHeaderCell: ({ children, ...props }: any) => <th {...props}>{children}</th>,
|
||||
TableCell: ({ children, ...props }: any) => <td {...props}>{children}</td>,
|
||||
}));
|
||||
|
||||
// Mock antd Tooltip
|
||||
vi.mock("antd", () => ({
|
||||
Tooltip: ({ children, title }: any) => (
|
||||
<div data-testid="tooltip" data-title={title}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock Heroicons
|
||||
vi.mock("@heroicons/react/outline", () => ({
|
||||
ChevronDownIcon: (props: any) => <div data-testid="chevron-down" {...props} />,
|
||||
ChevronUpIcon: (props: any) => <div data-testid="chevron-up" {...props} />,
|
||||
SwitchVerticalIcon: (props: any) => <div data-testid="switch-vertical" {...props} />,
|
||||
}));
|
||||
|
||||
// Test data
|
||||
const mockVectorStores: VectorStore[] = [
|
||||
{
|
||||
vector_store_id: "short-id",
|
||||
custom_llm_provider: "openai",
|
||||
vector_store_name: "My OpenAI Store",
|
||||
vector_store_description: "A store for OpenAI vectors",
|
||||
created_at: "2024-01-15T10:30:00Z",
|
||||
updated_at: "2024-01-15T11:00:00Z",
|
||||
created_by: "user-1",
|
||||
updated_by: "user-1",
|
||||
},
|
||||
{
|
||||
vector_store_id: "very-long-vector-store-id-that-should-be-truncated",
|
||||
custom_llm_provider: "azure",
|
||||
vector_store_name: undefined, // Test missing name
|
||||
vector_store_description: "A store for Azure vectors with a very long description that should show a tooltip",
|
||||
created_at: "2024-01-10T09:15:00Z",
|
||||
updated_at: "2024-01-12T14:20:00Z",
|
||||
},
|
||||
{
|
||||
vector_store_id: "store-3",
|
||||
custom_llm_provider: "pg_vector",
|
||||
vector_store_name: "PostgreSQL Store",
|
||||
vector_store_description: undefined, // Test missing description
|
||||
created_at: "2024-01-05T08:00:00Z",
|
||||
updated_at: "2024-01-08T16:45:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
// Mock functions
|
||||
const mockOnView = vi.fn();
|
||||
const mockOnEdit = vi.fn();
|
||||
const mockOnDelete = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
data: mockVectorStores,
|
||||
onView: mockOnView,
|
||||
onEdit: mockOnEdit,
|
||||
onDelete: mockOnDelete,
|
||||
};
|
||||
|
||||
// Helper function to render component
|
||||
const renderComponent = (props = {}) => {
|
||||
return render(<VectorStoreTable {...defaultProps} {...props} />);
|
||||
};
|
||||
|
||||
describe("VectorStoreTable", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default mock returns for getProviderLogoAndName
|
||||
mockGetProviderLogoAndName.mockImplementation((provider: string) => {
|
||||
const providerMap: Record<string, { displayName: string; logo: string }> = {
|
||||
openai: { displayName: "OpenAI", logo: "/openai-logo.png" },
|
||||
azure: { displayName: "Azure", logo: "/azure-logo.png" },
|
||||
pg_vector: { displayName: "PostgreSQL Vector", logo: "/pg-logo.png" },
|
||||
};
|
||||
return providerMap[provider] || { displayName: provider, logo: "" };
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should render the table with data", () => {
|
||||
renderComponent();
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render table headers", () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText("Vector Store ID")).toBeInTheDocument();
|
||||
expect(screen.getByText("Name")).toBeInTheDocument();
|
||||
expect(screen.getByText("Description")).toBeInTheDocument();
|
||||
expect(screen.getByText("Provider")).toBeInTheDocument();
|
||||
expect(screen.getByText("Created At")).toBeInTheDocument();
|
||||
expect(screen.getByText("Updated At")).toBeInTheDocument();
|
||||
// Check that we have the expected number of header cells (6 data + 1 actions)
|
||||
const headers = screen.getAllByRole("columnheader");
|
||||
expect(headers).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("should render all vector store rows", () => {
|
||||
renderComponent();
|
||||
expect(screen.getAllByRole("row")).toHaveLength(mockVectorStores.length + 1); // +1 for header row
|
||||
});
|
||||
|
||||
it("should render empty state when no data", () => {
|
||||
renderComponent({ data: [] });
|
||||
expect(screen.getByText("No vector stores found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vector Store ID Column", () => {
|
||||
it("should render short vector store IDs fully", () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText("short-id")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should truncate long vector store IDs", () => {
|
||||
renderComponent();
|
||||
// Check that the truncated text is rendered (first 15 chars + ...)
|
||||
const truncatedText = "very-long-vecto...";
|
||||
expect(screen.getByText(truncatedText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should make vector store ID clickable", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
const idButton = screen.getByText("short-id");
|
||||
await user.click(idButton);
|
||||
expect(mockOnView).toHaveBeenCalledWith("short-id");
|
||||
});
|
||||
|
||||
it("should have correct styling for vector store ID button", () => {
|
||||
renderComponent();
|
||||
const idButton = screen.getByText("short-id").closest("button");
|
||||
expect(idButton).toHaveClass("font-mono", "text-blue-500", "bg-blue-50", "hover:bg-blue-100");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Name Column", () => {
|
||||
it("should render vector store name", () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText("My OpenAI Store")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render fallback for missing name", () => {
|
||||
renderComponent();
|
||||
const fallbackElements = screen.getAllByText("-");
|
||||
expect(fallbackElements.length).toBe(2); // One for missing name, one for missing description
|
||||
});
|
||||
|
||||
it("should wrap name in tooltip", () => {
|
||||
renderComponent();
|
||||
const tooltips = screen.getAllByTestId("tooltip");
|
||||
const nameTooltip = tooltips.find((t) => t.getAttribute("data-title") === "My OpenAI Store");
|
||||
expect(nameTooltip).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Description Column", () => {
|
||||
it("should render vector store description", () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText("A store for OpenAI vectors")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render fallback for missing description", () => {
|
||||
renderComponent();
|
||||
const fallbackElements = screen.getAllByText("-");
|
||||
expect(fallbackElements.length).toBe(2); // One for missing name, one for missing description
|
||||
});
|
||||
|
||||
it("should wrap description in tooltip", () => {
|
||||
renderComponent();
|
||||
const tooltips = screen.getAllByTestId("tooltip");
|
||||
const descTooltip = tooltips.find(
|
||||
(t) =>
|
||||
t.getAttribute("data-title") ===
|
||||
"A store for Azure vectors with a very long description that should show a tooltip",
|
||||
);
|
||||
expect(descTooltip).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Provider Column", () => {
|
||||
it("should render provider display name", () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText("OpenAI")).toBeInTheDocument();
|
||||
expect(screen.getByText("Azure")).toBeInTheDocument();
|
||||
expect(screen.getByText("PostgreSQL Vector")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render provider logo when available", () => {
|
||||
renderComponent();
|
||||
const logos = screen.getAllByRole("img");
|
||||
expect(logos).toHaveLength(3); // All providers have logos in our mock
|
||||
expect(logos[0]).toHaveAttribute("src", "/openai-logo.png");
|
||||
expect(logos[0]).toHaveAttribute("alt", "OpenAI");
|
||||
});
|
||||
|
||||
it("should call getProviderLogoAndName for each provider", () => {
|
||||
renderComponent();
|
||||
expect(mockGetProviderLogoAndName).toHaveBeenCalledWith("openai");
|
||||
expect(mockGetProviderLogoAndName).toHaveBeenCalledWith("azure");
|
||||
expect(mockGetProviderLogoAndName).toHaveBeenCalledWith("pg_vector");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Date Columns", () => {
|
||||
it("should render created at dates", () => {
|
||||
renderComponent();
|
||||
const dateElements = screen.getAllByText(/1\/\d+\/2024/);
|
||||
expect(dateElements.length).toBe(6); // 3 created_at + 3 updated_at dates
|
||||
});
|
||||
|
||||
it("should render updated at dates", () => {
|
||||
renderComponent();
|
||||
const dateElements = screen.getAllByText(/1\/\d+\/2024/);
|
||||
expect(dateElements.length).toBe(6); // 3 created_at + 3 updated_at dates
|
||||
});
|
||||
});
|
||||
|
||||
describe("Actions Column", () => {
|
||||
it("should render edit and delete action buttons for each row", () => {
|
||||
renderComponent();
|
||||
expect(screen.getAllByTestId("action-button-edit")).toHaveLength(mockVectorStores.length);
|
||||
expect(screen.getAllByTestId("action-button-delete")).toHaveLength(mockVectorStores.length);
|
||||
});
|
||||
|
||||
it("should call onEdit when edit button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
const editButtons = screen.getAllByTestId("action-button-edit");
|
||||
await user.click(editButtons[0]);
|
||||
expect(mockOnEdit).toHaveBeenCalledWith("short-id");
|
||||
});
|
||||
|
||||
it("should call onDelete when delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
const deleteButtons = screen.getAllByTestId("action-button-delete");
|
||||
await user.click(deleteButtons[0]);
|
||||
expect(mockOnDelete).toHaveBeenCalledWith("short-id");
|
||||
});
|
||||
|
||||
it("should pass correct props to TableIconActionButton", () => {
|
||||
renderComponent();
|
||||
expect(mockTableIconActionButton).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "Edit",
|
||||
tooltipText: "Edit vector store",
|
||||
onClick: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(mockTableIconActionButton).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "Delete",
|
||||
tooltipText: "Delete vector store",
|
||||
onClick: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sorting", () => {
|
||||
it("should initialize with created_at descending sort", () => {
|
||||
renderComponent();
|
||||
// The table should initialize with sorting state
|
||||
expect(screen.getByTestId("chevron-down")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render sort icons for sortable columns", () => {
|
||||
renderComponent();
|
||||
// Should have sort icons for Created At and Updated At columns
|
||||
const sortIcons = screen.getAllByTestId(/^chevron-(up|down)$|^switch-vertical$/);
|
||||
expect(sortIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should make header cells clickable for sorting", () => {
|
||||
renderComponent();
|
||||
const headerCells = screen.getAllByRole("columnheader");
|
||||
const sortableHeaders = headerCells.filter((cell) => cell.textContent !== "");
|
||||
expect(sortableHeaders.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should show ascending icon when sorted ascending", () => {
|
||||
renderComponent();
|
||||
// Initially shows descending, but we can test the logic by checking the icons are present
|
||||
expect(screen.getByTestId("chevron-down")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Styling and Layout", () => {
|
||||
it("should apply correct CSS classes to table container", () => {
|
||||
renderComponent();
|
||||
const tableContainer = screen.getByRole("table").parentElement?.parentElement;
|
||||
expect(tableContainer).toHaveClass("rounded-lg", "custom-border", "relative");
|
||||
});
|
||||
|
||||
it("should apply overflow styling to table wrapper", () => {
|
||||
renderComponent();
|
||||
const tableWrapper = screen.getByRole("table").parentElement;
|
||||
expect(tableWrapper).toHaveClass("overflow-x-auto");
|
||||
});
|
||||
|
||||
it("should apply sticky styling to actions column", () => {
|
||||
renderComponent();
|
||||
const headerCells = screen.getAllByRole("columnheader");
|
||||
const actionsHeader = headerCells[headerCells.length - 1];
|
||||
expect(actionsHeader).toHaveClass("sticky", "right-0", "bg-white");
|
||||
});
|
||||
|
||||
it("should apply sticky styling to action cells", () => {
|
||||
renderComponent();
|
||||
const rows = screen.getAllByRole("row").slice(1); // Skip header row
|
||||
rows.forEach((row) => {
|
||||
const cells = row.querySelectorAll("td");
|
||||
const lastCell = cells[cells.length - 1];
|
||||
expect(lastCell).toHaveClass("sticky", "right-0", "bg-white");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Table Row Styling", () => {
|
||||
it("should apply correct height to table rows", () => {
|
||||
renderComponent();
|
||||
const rows = screen.getAllByRole("row").slice(1); // Skip header row
|
||||
rows.forEach((row) => {
|
||||
expect(row).toHaveClass("h-8");
|
||||
});
|
||||
});
|
||||
|
||||
it("should apply correct cell padding and styling", () => {
|
||||
renderComponent();
|
||||
const cells = screen.getAllByRole("cell");
|
||||
cells.forEach((cell) => {
|
||||
expect(cell).toHaveClass("py-0.5", "max-h-8", "overflow-hidden", "text-ellipsis", "whitespace-nowrap");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Empty State", () => {
|
||||
it("should render single row with centered message when no data", () => {
|
||||
renderComponent({ data: [] });
|
||||
const rows = screen.getAllByRole("row");
|
||||
expect(rows).toHaveLength(2); // Header + empty state row
|
||||
expect(screen.getByText("No vector stores found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should span all columns in empty state", () => {
|
||||
renderComponent({ data: [] });
|
||||
const emptyCell = screen.getByText("No vector stores found").closest("td");
|
||||
expect(emptyCell).toHaveAttribute("colSpan", "7"); // 6 data columns + 1 actions column
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data Edge Cases", () => {
|
||||
it("should handle vector stores with minimal data", () => {
|
||||
const minimalData: VectorStore[] = [
|
||||
{
|
||||
vector_store_id: "minimal",
|
||||
custom_llm_provider: "test",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
renderComponent({ data: minimalData });
|
||||
expect(screen.getByText("minimal")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("-")).toHaveLength(2); // Name and description fallbacks
|
||||
});
|
||||
|
||||
it("should handle single vector store", () => {
|
||||
const singleData = [mockVectorStores[0]];
|
||||
renderComponent({ data: singleData });
|
||||
expect(screen.getAllByRole("row")).toHaveLength(2); // Header + 1 data row
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -44,6 +44,78 @@ describe("cookieUtils", () => {
|
|||
|
||||
expect(getCookie("token")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return early when window is undefined (server-side rendering)", () => {
|
||||
const originalWindow = global.window;
|
||||
const originalDocument = global.document;
|
||||
|
||||
// Mock server-side environment
|
||||
delete (global as any).window;
|
||||
delete (global as any).document;
|
||||
|
||||
// This should not throw an error and should return early
|
||||
expect(() => clearTokenCookies()).not.toThrow();
|
||||
|
||||
// Restore globals
|
||||
global.window = originalWindow;
|
||||
global.document = originalDocument;
|
||||
});
|
||||
|
||||
it("should return early when document is undefined (server-side rendering)", () => {
|
||||
const originalDocument = global.document;
|
||||
|
||||
// Mock server-side environment where document is undefined
|
||||
delete (global as any).document;
|
||||
|
||||
// This should not throw an error and should return early
|
||||
expect(() => clearTokenCookies()).not.toThrow();
|
||||
|
||||
// Restore globals
|
||||
global.document = originalDocument;
|
||||
});
|
||||
|
||||
it("should add current path directory to paths array when different from root and /ui", () => {
|
||||
// Mock window.location.pathname using vi.stubGlobal
|
||||
const originalLocation = window.location;
|
||||
vi.stubGlobal('location', { ...originalLocation, pathname: '/custom/path/page.html' });
|
||||
|
||||
// Spy on document.cookie to verify the paths being used
|
||||
const cookieSpy = vi.spyOn(document, 'cookie', 'set');
|
||||
|
||||
clearTokenCookies();
|
||||
|
||||
// Verify that cookies were cleared for /custom/path/ path
|
||||
expect(cookieSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('path=/custom/path/')
|
||||
);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should not add current path directory when it's already in paths array", () => {
|
||||
// Mock window.location.pathname using vi.stubGlobal
|
||||
const originalLocation = window.location;
|
||||
vi.stubGlobal('location', { ...originalLocation, pathname: '/' });
|
||||
|
||||
// Spy on document.cookie to count calls
|
||||
const cookieSpy = vi.spyOn(document, 'cookie', 'set');
|
||||
|
||||
clearTokenCookies();
|
||||
|
||||
// Count how many times each path was used
|
||||
const rootPathCalls = cookieSpy.mock.calls.filter(call =>
|
||||
call[0].includes('path=/;') || call[0].includes('path=/ ')
|
||||
);
|
||||
const uiPathCalls = cookieSpy.mock.calls.filter(call =>
|
||||
call[0].includes('path=/ui;') || call[0].includes('path=/ui ')
|
||||
);
|
||||
|
||||
// Should have calls for root and /ui paths, but not duplicate root
|
||||
expect(rootPathCalls.length).toBeGreaterThan(0);
|
||||
expect(uiPathCalls.length).toBeGreaterThan(0);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCookie", () => {
|
||||
|
|
|
|||
78
ui/litellm-dashboard/src/utils/proxyUtils.test.ts
Normal file
78
ui/litellm-dashboard/src/utils/proxyUtils.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fetchProxySettings } from "./proxyUtils";
|
||||
import { getProxyUISettings } from "@/components/networking";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getProxyUISettings: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("fetchProxySettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should return null when accessToken is null", async () => {
|
||||
const result = await fetchProxySettings(null);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(getProxyUISettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return null when accessToken is undefined", async () => {
|
||||
const result = await fetchProxySettings(undefined as any);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(getProxyUISettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return proxy settings when getProxyUISettings succeeds", async () => {
|
||||
const mockProxySettings = { someSetting: "value", anotherSetting: 123 };
|
||||
const accessToken = "test-token";
|
||||
|
||||
vi.mocked(getProxyUISettings).mockResolvedValue(mockProxySettings);
|
||||
|
||||
const result = await fetchProxySettings(accessToken);
|
||||
|
||||
expect(result).toEqual(mockProxySettings);
|
||||
expect(getProxyUISettings).toHaveBeenCalledOnce();
|
||||
expect(getProxyUISettings).toHaveBeenCalledWith(accessToken);
|
||||
});
|
||||
|
||||
it("should return null and log error when getProxyUISettings throws", async () => {
|
||||
const accessToken = "test-token";
|
||||
const mockError = new Error("Network error");
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
vi.mocked(getProxyUISettings).mockRejectedValue(mockError);
|
||||
|
||||
const result = await fetchProxySettings(accessToken);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(getProxyUISettings).toHaveBeenCalledOnce();
|
||||
expect(getProxyUISettings).toHaveBeenCalledWith(accessToken);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Error fetching proxy settings:", mockError);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should return null and log error when getProxyUISettings throws a string", async () => {
|
||||
const accessToken = "test-token";
|
||||
const mockError = "String error";
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
vi.mocked(getProxyUISettings).mockRejectedValue(mockError);
|
||||
|
||||
const result = await fetchProxySettings(accessToken);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(getProxyUISettings).toHaveBeenCalledOnce();
|
||||
expect(getProxyUISettings).toHaveBeenCalledWith(accessToken);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Error fetching proxy settings:", mockError);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,15 @@ describe("formatLabel", () => {
|
|||
it("should format label", () => {
|
||||
expect(formatLabel("test_label")).toBe("Test Label");
|
||||
});
|
||||
|
||||
it("should return empty string when text is empty string", () => {
|
||||
expect(formatLabel("")).toBe("");
|
||||
});
|
||||
|
||||
it("should return the same value when text is falsy", () => {
|
||||
expect(formatLabel(null as any)).toBe(null);
|
||||
expect(formatLabel(undefined as any)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateString", () => {
|
||||
|
|
@ -26,4 +35,16 @@ describe("formItemValidateJSON", () => {
|
|||
it("should reject with an error message for invalid JSON", async () => {
|
||||
await expect(formItemValidateJSON({}, "invalid JSON")).rejects.toBe("Please enter valid JSON");
|
||||
});
|
||||
|
||||
it("should resolve when value is empty string", async () => {
|
||||
await expect(formItemValidateJSON({}, "")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should resolve when value is null", async () => {
|
||||
await expect(formItemValidateJSON({}, null as any)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should resolve when value is undefined", async () => {
|
||||
await expect(formItemValidateJSON({}, undefined as any)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue