From d0bb642fc5fecf088611f5e3e40dc58d912bf705 Mon Sep 17 00:00:00 2001 From: akashseth-ifp Date: Mon, 23 Feb 2026 10:27:00 +0530 Subject: [PATCH] test(web): Added test for model-auth files in header folder (#32358) --- .../add-credential-in-load-balancing.spec.tsx | 99 ++++ .../model-auth/add-custom-model.spec.tsx | 165 ++++++ .../authorized/authorized-item.spec.tsx | 164 ++++++ .../authorized/credential-item.spec.tsx | 88 ++++ .../model-auth/authorized/index.spec.tsx | 486 ++++++++++++++++++ .../model-auth/config-model.spec.tsx | 48 ++ .../model-auth/config-provider.spec.tsx | 70 +++ .../model-auth/credential-selector.spec.tsx | 130 +++++ .../hooks/use-auth-service..spec.tsx | 94 ++++ .../model-auth/hooks/use-auth.spec.tsx | 247 +++++++++ .../hooks/use-credential-data.spec.tsx | 60 +++ .../hooks/use-credential-status.spec.tsx | 56 ++ .../hooks/use-custom-models.spec.tsx | 38 ++ .../hooks/use-model-form-schemas.spec.tsx | 78 +++ .../manage-custom-model-credentials.spec.tsx | 62 +++ ...itch-credential-in-load-balancing.spec.tsx | 130 +++++ 16 files changed, 2015 insertions(+) create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/authorized/authorized-item.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/config-model.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth-service..spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-credential-data.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-credential-status.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-custom-models.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-model-form-schemas.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/manage-custom-model-credentials.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.spec.tsx diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.spec.tsx new file mode 100644 index 0000000000..af0ce9dcf2 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.spec.tsx @@ -0,0 +1,99 @@ +import type { CustomModel, ModelCredential, ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { ConfigurationMethodEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import AddCredentialInLoadBalancing from './add-credential-in-load-balancing' + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({ + Authorized: ({ + renderTrigger, + authParams, + items, + onItemClick, + }: { + renderTrigger: (open?: boolean) => React.ReactNode + authParams?: { onUpdate?: (payload?: unknown, formValues?: Record) => void } + items: Array<{ credentials: Array<{ credential_id: string, credential_name: string }> }> + onItemClick?: (credential: { credential_id: string, credential_name: string }) => void + }) => ( +
+ {renderTrigger(false)} + + +
+ ), +})) + +describe('AddCredentialInLoadBalancing', () => { + const provider = { + provider: 'openai', + allow_custom_token: true, + } as ModelProvider + + const model = { + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + } as CustomModel + + const modelCredential = { + available_credentials: [ + { credential_id: 'cred-1', credential_name: 'Key 1' }, + ], + credentials: {}, + load_balancing: { enabled: false, configs: [] }, + } as ModelCredential + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render add credential label', () => { + render( + , + ) + + expect(screen.getByText(/modelProvider.auth.addCredential/i)).toBeInTheDocument() + }) + + it('should forward update payload when update action happens', () => { + const onUpdate = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Run update' })) + + expect(onUpdate).toHaveBeenCalledWith({ provider: 'x' }, { key: 'value' }) + }) + + it('should call onSelectCredential when user picks a credential', () => { + const onSelectCredential = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Select first' })) + + expect(onSelectCredential).toHaveBeenCalledWith(modelCredential.available_credentials[0]) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.spec.tsx new file mode 100644 index 0000000000..df10270fb3 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.spec.tsx @@ -0,0 +1,165 @@ +import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import AddCustomModel from './add-custom-model' + +// Mock hooks +const mockHandleOpenModalForAddNewCustomModel = vi.fn() +const mockHandleOpenModalForAddCustomModelToModelList = vi.fn() + +vi.mock('./hooks/use-auth', () => ({ + useAuth: (_provider: unknown, _configMethod: unknown, _fixedFields: unknown, options: { mode: string }) => { + if (options.mode === 'config-custom-model') { + return { handleOpenModal: mockHandleOpenModalForAddNewCustomModel } + } + if (options.mode === 'add-custom-model-to-model-list') { + return { handleOpenModal: mockHandleOpenModalForAddCustomModelToModelList } + } + return { handleOpenModal: vi.fn() } + }, +})) + +let mockCanAddedModels: { model: string, model_type: string }[] = [] +vi.mock('./hooks/use-custom-models', () => ({ + useCanAddedModels: () => mockCanAddedModels, +})) + +// Mock components +vi.mock('../model-icon', () => ({ + default: () =>
, +})) + +vi.mock('@remixicon/react', () => ({ + RiAddCircleFill: () =>
, + RiAddLine: () =>
, +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( +
+ {children} +
{popupContent}
+
+ ), +})) + +// Mock portal components to avoid async/jsdom issues (consistent with sibling tests) +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean, onOpenChange: (open: boolean) => void }) => ( +
+ {children} +
+ ), + PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( +
{children}
+ ), + PortalToFollowElemContent: ({ children }: { children: React.ReactNode, open?: boolean }) => { + // In many tests, we need to find elements inside the content even if "closed" in state + // but not yet "removed" from DOM. However, to avoid multiple elements issues, + // we should be careful. + // For AddCustomModel, we need the content to be present when we click a model. + return
{children}
+ }, +})) + +describe('AddCustomModel', () => { + const mockProvider = { + provider: 'openai', + allow_custom_token: true, + } as unknown as ModelProvider + + beforeEach(() => { + vi.clearAllMocks() + mockCanAddedModels = [] + }) + + it('should render the add model button', () => { + render( + , + ) + + expect(screen.getByText(/modelProvider.addModel/)).toBeInTheDocument() + expect(screen.getByTestId('add-circle-icon')).toBeInTheDocument() + }) + + it('should call handleOpenModal directly when no models available and allowed', () => { + mockCanAddedModels = [] + render( + , + ) + + fireEvent.click(screen.getByTestId('portal-trigger')) + expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled() + }) + + it('should show models list when models are available', () => { + mockCanAddedModels = [{ model: 'gpt-4', model_type: 'llm' }] + render( + , + ) + + fireEvent.click(screen.getByTestId('portal-trigger')) + + // The portal should be "open" + expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true') + expect(screen.getByText('gpt-4')).toBeInTheDocument() + expect(screen.getByTestId('model-icon')).toBeInTheDocument() + }) + + it('should call handleOpenModalForAddCustomModelToModelList when clicking a model', () => { + const model = { model: 'gpt-4', model_type: 'llm' } + mockCanAddedModels = [model] + render( + , + ) + + fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByText('gpt-4')) + + expect(mockHandleOpenModalForAddCustomModelToModelList).toHaveBeenCalledWith(undefined, model) + }) + + it('should call handleOpenModalForAddNewCustomModel when clicking "Add New Model" in list', () => { + mockCanAddedModels = [{ model: 'gpt-4', model_type: 'llm' }] + render( + , + ) + + fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByText(/modelProvider.auth.addNewModel/)) + + expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled() + }) + + it('should show tooltip when no models and custom tokens not allowed', () => { + const restrictedProvider = { ...mockProvider, allow_custom_token: false } + mockCanAddedModels = [] + render( + , + ) + + expect(screen.getByTestId('tooltip-mock')).toBeInTheDocument() + expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('portal-trigger')) + expect(mockHandleOpenModalForAddNewCustomModel).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/authorized-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/authorized-item.spec.tsx new file mode 100644 index 0000000000..1445c9e212 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/authorized-item.spec.tsx @@ -0,0 +1,164 @@ +import type { Credential, CustomModelCredential, ModelProvider } from '../../declarations' +import { render, screen } from '@testing-library/react' +import { ModelTypeEnum } from '../../declarations' +import { AuthorizedItem } from './authorized-item' + +vi.mock('../../model-icon', () => ({ + default: ({ modelName }: { modelName: string }) =>
{modelName}
, +})) + +vi.mock('./credential-item', () => ({ + default: ({ credential, onEdit, onDelete, onItemClick }: { + credential: Credential + onEdit?: (credential: Credential) => void + onDelete?: (credential: Credential) => void + onItemClick?: (credential: Credential) => void + }) => ( +
+ {credential.credential_name} + + + +
+ ), +})) + +describe('AuthorizedItem', () => { + const mockProvider: ModelProvider = { + provider: 'openai', + } as ModelProvider + + const mockCredentials: Credential[] = [ + { credential_id: 'cred-1', credential_name: 'API Key 1' }, + { credential_id: 'cred-2', credential_name: 'API Key 2' }, + ] + + const mockModel: CustomModelCredential = { + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render credentials list', () => { + render( + , + ) + + expect(screen.getByTestId('credential-item-cred-1')).toBeInTheDocument() + expect(screen.getByTestId('credential-item-cred-2')).toBeInTheDocument() + expect(screen.getByText('API Key 1')).toBeInTheDocument() + expect(screen.getByText('API Key 2')).toBeInTheDocument() + }) + + it('should render model title when showModelTitle is true', () => { + render( + , + ) + + expect(screen.getByTestId('model-icon')).toBeInTheDocument() + expect(screen.getAllByText('gpt-4')).toHaveLength(2) + }) + + it('should not render model title when showModelTitle is false', () => { + render( + , + ) + + expect(screen.queryByTestId('model-icon')).not.toBeInTheDocument() + }) + + it('should render custom title instead of model name', () => { + render( + , + ) + + expect(screen.getByText('Custom Title')).toBeInTheDocument() + }) + + it('should handle empty credentials array', () => { + const { container } = render( + , + ) + + expect(container.querySelector('[data-testid^="credential-item-"]')).not.toBeInTheDocument() + }) + }) + + describe('Callback Propagation', () => { + it('should pass onEdit callback to credential items', () => { + const onEdit = vi.fn() + + render( + , + ) + + screen.getAllByText('Edit')[0].click() + + expect(onEdit).toHaveBeenCalledWith(mockCredentials[0], mockModel) + }) + + it('should pass onDelete callback to credential items', () => { + const onDelete = vi.fn() + + render( + , + ) + + screen.getAllByText('Delete')[0].click() + + expect(onDelete).toHaveBeenCalledWith(mockCredentials[0], mockModel) + }) + + it('should pass onItemClick callback to credential items', () => { + const onItemClick = vi.fn() + + render( + , + ) + + screen.getAllByText('Click')[0].click() + + expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockModel) + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.spec.tsx new file mode 100644 index 0000000000..d60c985b99 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/credential-item.spec.tsx @@ -0,0 +1,88 @@ +import type { Credential } from '../../declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import CredentialItem from './credential-item' + +vi.mock('@remixicon/react', () => ({ + RiCheckLine: () =>
, + RiDeleteBinLine: () =>
, + RiEqualizer2Line: () =>
, +})) + +vi.mock('@/app/components/header/indicator', () => ({ + default: () =>
, +})) + +describe('CredentialItem', () => { + const credential: Credential = { + credential_id: 'cred-1', + credential_name: 'Test API Key', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render credential text and indicator', () => { + render() + + expect(screen.getByText('Test API Key')).toBeInTheDocument() + expect(screen.getByTestId('indicator')).toBeInTheDocument() + }) + + it('should render enterprise badge for enterprise credential', () => { + render() + + expect(screen.getByText('Enterprise')).toBeInTheDocument() + }) + + it('should call onItemClick when list item is clicked', () => { + const onItemClick = vi.fn() + + render() + + fireEvent.click(screen.getByText('Test API Key')) + + expect(onItemClick).toHaveBeenCalledWith(credential) + }) + + it('should not call onItemClick when credential is unavailable', () => { + const onItemClick = vi.fn() + + render() + + fireEvent.click(screen.getByText('Test API Key')) + + expect(onItemClick).not.toHaveBeenCalled() + }) + + it('should call onEdit and onDelete from action buttons', () => { + const onEdit = vi.fn() + const onDelete = vi.fn() + + render() + + fireEvent.click(screen.getByTestId('edit-icon').closest('button') as HTMLButtonElement) + fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement) + + expect(onEdit).toHaveBeenCalledWith(credential) + expect(onDelete).toHaveBeenCalledWith(credential) + }) + + it('should block delete action for the currently selected credential when delete is disabled', () => { + const onDelete = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement) + + expect(onDelete).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.spec.tsx new file mode 100644 index 0000000000..4789641828 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.spec.tsx @@ -0,0 +1,486 @@ +import type { Credential, CustomModel, ModelProvider } from '../../declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { ConfigurationMethodEnum, ModelTypeEnum } from '../../declarations' +import Authorized from './index' + +const mockHandleOpenModal = vi.fn() +const mockHandleActiveCredential = vi.fn() +const mockOpenConfirmDelete = vi.fn() +const mockCloseConfirmDelete = vi.fn() +const mockHandleConfirmDelete = vi.fn() + +let mockDeleteCredentialId: string | null = null +let mockDoingAction = false + +vi.mock('../hooks', () => ({ + useAuth: () => ({ + openConfirmDelete: mockOpenConfirmDelete, + closeConfirmDelete: mockCloseConfirmDelete, + doingAction: mockDoingAction, + handleActiveCredential: mockHandleActiveCredential, + handleConfirmDelete: mockHandleConfirmDelete, + deleteCredentialId: mockDeleteCredentialId, + handleOpenModal: mockHandleOpenModal, + }), +})) + +let mockPortalOpen = false + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => { + mockPortalOpen = open + return
{children}
+ }, + PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( +
{children}
+ ), + PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { + if (!mockPortalOpen) + return null + return
{children}
+ }, +})) + +vi.mock('@/app/components/base/confirm', () => ({ + default: ({ isShow, onCancel, onConfirm }: { isShow: boolean, onCancel: () => void, onConfirm: () => void }) => { + if (!isShow) + return null + return ( +
+ + +
+ ) + }, +})) + +vi.mock('./authorized-item', () => ({ + default: ({ credentials, model, onEdit, onDelete, onItemClick }: { + credentials: Credential[] + model?: CustomModel + onEdit?: (credential: Credential, model?: CustomModel) => void + onDelete?: (credential: Credential, model?: CustomModel) => void + onItemClick?: (credential: Credential, model?: CustomModel) => void + }) => ( +
+ {credentials.map((cred: Credential) => ( +
+ {cred.credential_name} + + + +
+ ))} +
+ ), +})) + +describe('Authorized', () => { + const mockProvider: ModelProvider = { + provider: 'openai', + allow_custom_token: true, + } as ModelProvider + + const mockCredentials: Credential[] = [ + { credential_id: 'cred-1', credential_name: 'API Key 1' }, + { credential_id: 'cred-2', credential_name: 'API Key 2' }, + ] + + const mockItems = [ + { + model: { + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + }, + credentials: mockCredentials, + }, + ] + + const mockRenderTrigger = (open?: boolean) => ( + + ) + + beforeEach(() => { + vi.clearAllMocks() + mockPortalOpen = false + mockDeleteCredentialId = null + mockDoingAction = false + }) + + describe('Rendering', () => { + it('should render trigger button', () => { + render( + , + ) + + expect(screen.getByText(/Trigger/)).toBeInTheDocument() + }) + + it('should render portal content when open', () => { + render( + , + ) + + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + expect(screen.getByTestId('authorized-item')).toBeInTheDocument() + }) + + it('should not render portal content when closed', () => { + render( + , + ) + + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + + it('should render Add API Key button when not model credential', () => { + render( + , + ) + + expect(screen.getByText(/addApiKey/)).toBeInTheDocument() + }) + + it('should render Add Model Credential button when is model credential', () => { + render( + , + ) + + expect(screen.getByText(/addModelCredential/)).toBeInTheDocument() + }) + + it('should not render add action when hideAddAction is true', () => { + render( + , + ) + + expect(screen.queryByText(/addApiKey/)).not.toBeInTheDocument() + }) + + it('should render popup title when provided', () => { + render( + , + ) + + expect(screen.getByText('Select Credential')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onOpenChange when trigger is clicked in controlled mode', () => { + const onOpenChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('portal-trigger')) + + expect(onOpenChange).toHaveBeenCalledWith(true) + }) + + it('should toggle portal on trigger click', () => { + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByTestId('portal-trigger')) + + rerender( + , + ) + + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + + it('should open modal when triggerOnlyOpenModal is true', () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('portal-trigger')) + + expect(mockHandleOpenModal).toHaveBeenCalled() + }) + + it('should call handleOpenModal when Add API Key is clicked', () => { + render( + , + ) + + fireEvent.click(screen.getByText(/addApiKey/)) + + expect(mockHandleOpenModal).toHaveBeenCalled() + }) + + it('should call handleOpenModal with credential and model when edit is clicked', () => { + render( + , + ) + + fireEvent.click(screen.getAllByText('Edit')[0]) + + expect(mockHandleOpenModal).toHaveBeenCalledWith( + mockCredentials[0], + mockItems[0].model, + ) + }) + + it('should pass current model fields when adding model credential', () => { + render( + , + ) + + fireEvent.click(screen.getByText(/addModelCredential/)) + + expect(mockHandleOpenModal).toHaveBeenCalledWith(undefined, { + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + }) + }) + + it('should call onItemClick when credential is selected', () => { + const onItemClick = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getAllByText('Select')[0]) + + expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model) + }) + + it('should call handleActiveCredential when onItemClick is not provided', () => { + render( + , + ) + + fireEvent.click(screen.getAllByText('Select')[0]) + + expect(mockHandleActiveCredential).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model) + }) + + it('should not call onItemClick when disableItemClick is true', () => { + const onItemClick = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getAllByText('Select')[0]) + + expect(onItemClick).not.toHaveBeenCalled() + }) + }) + + describe('Delete Confirmation', () => { + it('should show confirm dialog when deleteCredentialId is set', () => { + mockDeleteCredentialId = 'cred-1' + + render( + , + ) + + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument() + }) + + it('should not show confirm dialog when deleteCredentialId is null', () => { + render( + , + ) + + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() + }) + + it('should call closeConfirmDelete when cancel is clicked', () => { + mockDeleteCredentialId = 'cred-1' + + render( + , + ) + + fireEvent.click(screen.getByText('Cancel')) + + expect(mockCloseConfirmDelete).toHaveBeenCalled() + }) + + it('should call handleConfirmDelete when confirm is clicked', () => { + mockDeleteCredentialId = 'cred-1' + + render( + , + ) + + fireEvent.click(screen.getByText('Confirm')) + + expect(mockHandleConfirmDelete).toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty items array', () => { + render( + , + ) + + expect(screen.queryByTestId('authorized-item')).not.toBeInTheDocument() + }) + + it('should not render add action when provider does not allow custom token', () => { + const restrictedProvider = { ...mockProvider, allow_custom_token: false } + + render( + , + ) + + expect(screen.queryByText(/addApiKey/)).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/config-model.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/config-model.spec.tsx new file mode 100644 index 0000000000..5ea651e5e9 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/config-model.spec.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import ConfigModel from './config-model' + +// Mock icons +vi.mock('@remixicon/react', () => ({ + RiEqualizer2Line: () =>
, + RiScales3Line: () =>
, +})) + +// Mock Indicator +vi.mock('@/app/components/header/indicator', () => ({ + default: ({ color }: { color: string }) =>
, +})) + +describe('ConfigModel', () => { + it('should render authorization error when loadBalancingInvalid is true', () => { + const onClick = vi.fn() + render() + + expect(screen.getByText(/modelProvider.auth.authorizationError/)).toBeInTheDocument() + expect(screen.getByTestId('scales-icon')).toBeInTheDocument() + expect(screen.getByTestId('indicator-orange')).toBeInTheDocument() + + fireEvent.click(screen.getByText(/modelProvider.auth.authorizationError/)) + expect(onClick).toHaveBeenCalled() + }) + + it('should render credential removed message when credentialRemoved is true', () => { + render() + + expect(screen.getByText(/modelProvider.auth.credentialRemoved/)).toBeInTheDocument() + expect(screen.getByTestId('indicator-red')).toBeInTheDocument() + }) + + it('should render standard config message when no flags enabled', () => { + render() + + expect(screen.getByText(/operation.config/)).toBeInTheDocument() + expect(screen.getByTestId('config-icon')).toBeInTheDocument() + }) + + it('should render config load balancing when loadBalancingEnabled is true', () => { + render() + + expect(screen.getByText(/modelProvider.auth.configLoadBalancing/)).toBeInTheDocument() + expect(screen.getByTestId('scales-icon')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.spec.tsx new file mode 100644 index 0000000000..94a8583313 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.spec.tsx @@ -0,0 +1,70 @@ +import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { render, screen } from '@testing-library/react' +import ConfigProvider from './config-provider' + +const mockUseCredentialStatus = vi.fn() + +vi.mock('./hooks', () => ({ + useCredentialStatus: () => mockUseCredentialStatus(), +})) + +vi.mock('./authorized', () => ({ + default: ({ renderTrigger }: { renderTrigger: () => React.ReactNode }) => ( +
+ {renderTrigger()} +
+ ), +})) + +describe('ConfigProvider', () => { + const baseProvider = { + provider: 'openai', + allow_custom_token: true, + } as ModelProvider + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should show setup label when no credential exists', () => { + mockUseCredentialStatus.mockReturnValue({ + hasCredential: false, + authorized: true, + current_credential_id: '', + current_credential_name: '', + available_credentials: [], + }) + + render() + + expect(screen.getByText(/operation.setup/i)).toBeInTheDocument() + }) + + it('should show config label when credential exists', () => { + mockUseCredentialStatus.mockReturnValue({ + hasCredential: true, + authorized: true, + current_credential_id: 'cred-1', + current_credential_name: 'Key 1', + available_credentials: [], + }) + + render() + + expect(screen.getByText(/operation.config/i)).toBeInTheDocument() + }) + + it('should still render setup label when custom credentials are not allowed', () => { + mockUseCredentialStatus.mockReturnValue({ + hasCredential: false, + authorized: false, + current_credential_id: '', + current_credential_name: '', + available_credentials: [], + }) + + render() + + expect(screen.getByText(/operation.setup/i)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.spec.tsx new file mode 100644 index 0000000000..a522abf7cb --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.spec.tsx @@ -0,0 +1,130 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import CredentialSelector from './credential-selector' + +// Mock components +vi.mock('./authorized/credential-item', () => ({ + default: ({ credential, onItemClick }: { credential: { credential_name: string }, onItemClick: (c: unknown) => void }) => ( +
onItemClick(credential)}> + {credential.credential_name} +
+ ), +})) + +vi.mock('@/app/components/header/indicator', () => ({ + default: () =>
, +})) + +vi.mock('@remixicon/react', () => ({ + RiAddLine: () =>
, + RiArrowDownSLine: () =>
, +})) + +// Mock portal components +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( +
{children}
+ ), + PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( +
{children}
+ ), + PortalToFollowElemContent: ({ children }: { children: React.ReactNode, open?: boolean }) => { + // We should only render children if open or if we want to test they are hidden + // The real component might handle this with CSS or conditional rendering. + // Let's use conditional rendering in the mock to avoid "multiple elements" errors. + return
{children}
+ }, +})) + +describe('CredentialSelector', () => { + const mockCredentials = [ + { credential_id: 'cred-1', credential_name: 'Key 1' }, + { credential_id: 'cred-2', credential_name: 'Key 2' }, + ] + const mockOnSelect = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render selected credential name', () => { + render( + , + ) + + // Use getAllByText and take the first one (the one in the trigger) + expect(screen.getAllByText('Key 1')[0]).toBeInTheDocument() + expect(screen.getByTestId('indicator')).toBeInTheDocument() + }) + + it('should render placeholder when no credential selected', () => { + render( + , + ) + + expect(screen.getByText(/modelProvider.auth.selectModelCredential/)).toBeInTheDocument() + }) + + it('should open portal on click', () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('portal-trigger')) + expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true') + expect(screen.getAllByTestId('credential-item')).toHaveLength(2) + }) + + it('should call onSelect when a credential is clicked', () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByText('Key 2')) + + expect(mockOnSelect).toHaveBeenCalledWith(mockCredentials[1]) + }) + + it('should call onSelect with add new credential data when clicking add button', () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByText(/modelProvider.auth.addNewModelCredential/)) + + expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({ + credential_id: '__add_new_credential', + addNewCredential: true, + })) + }) + + it('should not open portal when disabled', () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('portal-trigger')) + expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false') + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth-service..spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth-service..spec.tsx new file mode 100644 index 0000000000..b9f76d1c3f --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth-service..spec.tsx @@ -0,0 +1,94 @@ +import type { CustomModel } from '../../declarations' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook } from '@testing-library/react' +import { ModelTypeEnum } from '../../declarations' +import { useAuthService, useGetCredential } from './use-auth-service' + +vi.mock('@/service/use-models', () => ({ + useGetProviderCredential: vi.fn(), + useGetModelCredential: vi.fn(), + useAddProviderCredential: vi.fn(), + useEditProviderCredential: vi.fn(), + useDeleteProviderCredential: vi.fn(), + useActiveProviderCredential: vi.fn(), + useAddModelCredential: vi.fn(), + useEditModelCredential: vi.fn(), + useDeleteModelCredential: vi.fn(), + useActiveModelCredential: vi.fn(), +})) + +const { + useGetProviderCredential, + useGetModelCredential, + useAddProviderCredential, + useEditProviderCredential, + useDeleteProviderCredential, + useActiveProviderCredential, + useAddModelCredential, + useEditModelCredential, + useDeleteModelCredential, + useActiveModelCredential, +} = await import('@/service/use-models') + +describe('useAuthService hooks', () => { + let queryClient: QueryClient + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + + beforeEach(() => { + vi.clearAllMocks() + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + const mockMutationReturn = { mutateAsync: vi.fn() } + vi.mocked(useAddProviderCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType) + vi.mocked(useEditProviderCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType) + vi.mocked(useDeleteProviderCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType) + vi.mocked(useActiveProviderCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType) + vi.mocked(useAddModelCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType) + vi.mocked(useEditModelCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType) + vi.mocked(useDeleteModelCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType) + vi.mocked(useActiveModelCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType) + }) + + it('useGetCredential selects correct source and params', () => { + const mockData = { data: 'test' } + vi.mocked(useGetProviderCredential).mockReturnValue(mockData as unknown as ReturnType) + vi.mocked(useGetModelCredential).mockReturnValue(mockData as unknown as ReturnType) + + // Provider case + const { result: providerRes } = renderHook(() => useGetCredential('openai', false, 'cred-123'), { wrapper }) + expect(useGetProviderCredential).toHaveBeenCalledWith(true, 'openai', 'cred-123') + expect(providerRes.current).toBe(mockData) + + // Model case + const mockModel = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } as CustomModel + const { result: modelRes } = renderHook(() => useGetCredential('openai', true, 'cred-123', mockModel, 'src'), { wrapper }) + expect(useGetModelCredential).toHaveBeenCalledWith(true, 'openai', 'cred-123', 'gpt-4', ModelTypeEnum.textGeneration, 'src') + expect(modelRes.current).toBe(mockData) + + // Early return cases + renderHook(() => useGetCredential('openai', false), { wrapper }) + expect(useGetProviderCredential).toHaveBeenCalledWith(false, 'openai', undefined) + + // Branch: isModelCredential true but no id/model + renderHook(() => useGetCredential('openai', true), { wrapper }) + expect(useGetModelCredential).toHaveBeenCalledWith(false, 'openai', undefined, undefined, undefined, undefined) + }) + + it('useAuthService provides correct services for provider and model', () => { + const { result } = renderHook(() => useAuthService('openai'), { wrapper }) + + // Provider services + expect(result.current.getAddCredentialService(false)).toBe(vi.mocked(useAddProviderCredential).mock.results[0].value.mutateAsync) + expect(result.current.getEditCredentialService(false)).toBe(vi.mocked(useEditProviderCredential).mock.results[0].value.mutateAsync) + expect(result.current.getDeleteCredentialService(false)).toBe(vi.mocked(useDeleteProviderCredential).mock.results[0].value.mutateAsync) + expect(result.current.getActiveCredentialService(false)).toBe(vi.mocked(useActiveProviderCredential).mock.results[0].value.mutateAsync) + + // Model services + expect(result.current.getAddCredentialService(true)).toBe(vi.mocked(useAddModelCredential).mock.results[0].value.mutateAsync) + expect(result.current.getEditCredentialService(true)).toBe(vi.mocked(useEditModelCredential).mock.results[0].value.mutateAsync) + expect(result.current.getDeleteCredentialService(true)).toBe(vi.mocked(useDeleteModelCredential).mock.results[0].value.mutateAsync) + expect(result.current.getActiveCredentialService(true)).toBe(vi.mocked(useActiveModelCredential).mock.results[0].value.mutateAsync) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth.spec.tsx new file mode 100644 index 0000000000..c2259f543c --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-auth.spec.tsx @@ -0,0 +1,247 @@ +import type { + Credential, + CustomModel, + ModelProvider, +} from '../../declarations' +import { act, renderHook } from '@testing-library/react' +import { ConfigurationMethodEnum, ModelModalModeEnum, ModelTypeEnum } from '../../declarations' +import { useAuth } from './use-auth' + +const mockNotify = vi.fn() +const mockHandleRefreshModel = vi.fn() +const mockOpenModelModal = vi.fn() +const mockDeleteModelService = vi.fn() +const mockDeleteProviderCredential = vi.fn() +const mockDeleteModelCredential = vi.fn() +const mockActiveProviderCredential = vi.fn() +const mockActiveModelCredential = vi.fn() +const mockAddProviderCredential = vi.fn() +const mockAddModelCredential = vi.fn() +const mockEditProviderCredential = vi.fn() +const mockEditModelCredential = vi.fn() + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ notify: mockNotify }), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelModalHandler: () => mockOpenModelModal, + useRefreshModel: () => ({ handleRefreshModel: mockHandleRefreshModel }), +})) + +vi.mock('@/service/use-models', () => ({ + useDeleteModel: () => ({ mutateAsync: mockDeleteModelService }), +})) + +vi.mock('./use-auth-service', () => ({ + useAuthService: () => ({ + getDeleteCredentialService: (isModel: boolean) => (isModel ? mockDeleteModelCredential : mockDeleteProviderCredential), + getActiveCredentialService: (isModel: boolean) => (isModel ? mockActiveModelCredential : mockActiveProviderCredential), + getEditCredentialService: (isModel: boolean) => (isModel ? mockEditModelCredential : mockEditProviderCredential), + getAddCredentialService: (isModel: boolean) => (isModel ? mockAddModelCredential : mockAddProviderCredential), + }), +})) + +const createDeferred = () => { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +describe('useAuth', () => { + const provider = { + provider: 'openai', + allow_custom_token: true, + } as ModelProvider + + const credential: Credential = { + credential_id: 'cred-1', + credential_name: 'Primary key', + } + + const model: CustomModel = { + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + } + + beforeEach(() => { + vi.clearAllMocks() + mockDeleteModelService.mockResolvedValue({ result: 'success' }) + mockDeleteProviderCredential.mockResolvedValue({ result: 'success' }) + mockDeleteModelCredential.mockResolvedValue({ result: 'success' }) + mockActiveProviderCredential.mockResolvedValue({ result: 'success' }) + mockActiveModelCredential.mockResolvedValue({ result: 'success' }) + mockAddProviderCredential.mockResolvedValue({ result: 'success' }) + mockAddModelCredential.mockResolvedValue({ result: 'success' }) + mockEditProviderCredential.mockResolvedValue({ result: 'success' }) + mockEditModelCredential.mockResolvedValue({ result: 'success' }) + }) + + it('should open and close delete confirmation state', () => { + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel)) + + act(() => { + result.current.openConfirmDelete(credential, model) + }) + + expect(result.current.deleteCredentialId).toBe('cred-1') + expect(result.current.deleteModel).toEqual(model) + expect(result.current.pendingOperationCredentialId.current).toBe('cred-1') + expect(result.current.pendingOperationModel.current).toEqual(model) + + act(() => { + result.current.closeConfirmDelete() + }) + + expect(result.current.deleteCredentialId).toBeNull() + expect(result.current.deleteModel).toBeNull() + }) + + it('should activate credential, notify success, and refresh models', async () => { + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel)) + + await act(async () => { + await result.current.handleActiveCredential(credential, model) + }) + + expect(mockActiveModelCredential).toHaveBeenCalledWith({ + credential_id: 'cred-1', + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + }) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + message: 'common.api.actionSuccess', + })) + expect(mockHandleRefreshModel).toHaveBeenCalledWith(provider, undefined, true) + expect(result.current.doingAction).toBe(false) + }) + + it('should close delete dialog without calling services when nothing is pending', async () => { + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel)) + + await act(async () => { + await result.current.handleConfirmDelete() + }) + + expect(mockDeleteProviderCredential).not.toHaveBeenCalled() + expect(mockDeleteModelService).not.toHaveBeenCalled() + expect(result.current.deleteCredentialId).toBeNull() + expect(result.current.deleteModel).toBeNull() + }) + + it('should delete credential and call onRemove callback', async () => { + const onRemove = vi.fn() + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel, undefined, { + isModelCredential: false, + onRemove, + })) + + act(() => { + result.current.openConfirmDelete(credential, model) + }) + + await act(async () => { + await result.current.handleConfirmDelete() + }) + + expect(mockDeleteProviderCredential).toHaveBeenCalledWith({ + credential_id: 'cred-1', + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + }) + expect(mockDeleteModelService).not.toHaveBeenCalled() + expect(onRemove).toHaveBeenCalledWith('cred-1') + expect(result.current.deleteCredentialId).toBeNull() + }) + + it('should delete model when pending operation has no credential id', async () => { + const onRemove = vi.fn() + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel, undefined, { + onRemove, + })) + + act(() => { + result.current.openConfirmDelete(undefined, model) + }) + + await act(async () => { + await result.current.handleConfirmDelete() + }) + + expect(mockDeleteModelService).toHaveBeenCalledWith({ + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + }) + expect(onRemove).toHaveBeenCalledWith('') + }) + + it('should add or edit credentials and refresh on successful save', async () => { + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel)) + + await act(async () => { + await result.current.handleSaveCredential({ api_key: 'new-key' }) + }) + + expect(mockAddProviderCredential).toHaveBeenCalledWith({ api_key: 'new-key' }) + expect(mockHandleRefreshModel).toHaveBeenCalledWith(provider, undefined, true) + + await act(async () => { + await result.current.handleSaveCredential({ credential_id: 'cred-1', api_key: 'updated-key' }) + }) + + expect(mockEditProviderCredential).toHaveBeenCalledWith({ credential_id: 'cred-1', api_key: 'updated-key' }) + expect(mockHandleRefreshModel).toHaveBeenCalledWith(provider, undefined, false) + }) + + it('should ignore duplicate save requests while an action is in progress', async () => { + const deferred = createDeferred<{ result: string }>() + mockAddProviderCredential.mockReturnValueOnce(deferred.promise) + + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel)) + + let first!: Promise + let second!: Promise + + await act(async () => { + first = result.current.handleSaveCredential({ api_key: 'first' }) + second = result.current.handleSaveCredential({ api_key: 'second' }) + deferred.resolve({ result: 'success' }) + await Promise.all([first, second]) + }) + + expect(mockAddProviderCredential).toHaveBeenCalledTimes(1) + expect(mockAddProviderCredential).toHaveBeenCalledWith({ api_key: 'first' }) + }) + + it('should forward modal open arguments', () => { + const onUpdate = vi.fn() + const fixedFields = { + __model_name: 'gpt-4', + __model_type: ModelTypeEnum.textGeneration, + } + const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel, fixedFields, { + isModelCredential: true, + onUpdate, + mode: ModelModalModeEnum.configModelCredential, + })) + + act(() => { + result.current.handleOpenModal(credential, model) + }) + + expect(mockOpenModelModal).toHaveBeenCalledWith( + provider, + ConfigurationMethodEnum.customizableModel, + fixedFields, + expect.objectContaining({ + isModelCredential: true, + credential, + model, + onUpdate, + }), + ) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-credential-data.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-credential-data.spec.tsx new file mode 100644 index 0000000000..0a61834dd0 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-credential-data.spec.tsx @@ -0,0 +1,60 @@ +import type { Credential, CustomModelCredential, ModelProvider } from '../../declarations' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook } from '@testing-library/react' +import { useCredentialData } from './use-credential-data' + +vi.mock('./use-auth-service', () => ({ + useGetCredential: vi.fn(), +})) + +const { useGetCredential } = await import('./use-auth-service') + +describe('useCredentialData', () => { + let queryClient: QueryClient + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + + beforeEach(() => { + vi.clearAllMocks() + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + }) + + it('determines correct config source and parameters', () => { + vi.mocked(useGetCredential).mockReturnValue({ isLoading: false, data: {} } as unknown as ReturnType) + const mockProvider = { provider: 'openai' } as unknown as ModelProvider + + // Predefined source + renderHook(() => useCredentialData(mockProvider, true), { wrapper }) + expect(useGetCredential).toHaveBeenCalledWith('openai', undefined, undefined, undefined, 'predefined-model') + + // Custom source + renderHook(() => useCredentialData(mockProvider, false), { wrapper }) + expect(useGetCredential).toHaveBeenCalledWith('openai', undefined, undefined, undefined, 'custom-model') + }) + + it('returns appropriate loading and data states', () => { + const mockData = { api_key: 'test' } + vi.mocked(useGetCredential).mockReturnValue({ isLoading: true, data: undefined } as unknown as ReturnType) + const mockProvider = { provider: 'openai' } as unknown as ModelProvider + + const { result: loadingRes } = renderHook(() => useCredentialData(mockProvider, true), { wrapper }) + expect(loadingRes.current.isLoading).toBe(true) + expect(loadingRes.current.credentialData).toEqual({}) + + vi.mocked(useGetCredential).mockReturnValue({ isLoading: false, data: mockData } as unknown as ReturnType) + const { result: dataRes } = renderHook(() => useCredentialData(mockProvider, true), { wrapper }) + expect(dataRes.current.isLoading).toBe(false) + expect(dataRes.current.credentialData).toBe(mockData) + }) + + it('passes credential and model identifier correctly', () => { + vi.mocked(useGetCredential).mockReturnValue({ isLoading: false, data: {} } as unknown as ReturnType) + const mockProvider = { provider: 'openai' } as unknown as ModelProvider + const mockCredential = { credential_id: 'cred-123' } as unknown as Credential + const mockModel = { model: 'gpt-4' } as unknown as CustomModelCredential + + renderHook(() => useCredentialData(mockProvider, true, true, mockCredential, mockModel), { wrapper }) + expect(useGetCredential).toHaveBeenCalledWith('openai', true, 'cred-123', mockModel, 'predefined-model') + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-credential-status.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-credential-status.spec.tsx new file mode 100644 index 0000000000..c84b452bb2 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-credential-status.spec.tsx @@ -0,0 +1,56 @@ +import type { ModelProvider } from '../../declarations' +import { renderHook } from '@testing-library/react' +import { useCredentialStatus } from './use-credential-status' + +describe('useCredentialStatus', () => { + it('computes authorized and authRemoved status correctly', () => { + // Authorized case + const authProvider = { + custom_configuration: { + current_credential_id: '123', + current_credential_name: 'Key', + available_credentials: [{ credential_id: '123', credential_name: 'Key' }], + }, + } as unknown as ModelProvider + const { result: authRes } = renderHook(() => useCredentialStatus(authProvider)) + expect(authRes.current.authorized).toBeTruthy() + expect(authRes.current.authRemoved).toBe(false) + + // AuthRemoved case (found but not selected) + const removedProvider = { + custom_configuration: { + current_credential_id: '', + current_credential_name: '', + available_credentials: [{ credential_id: '123' }], + }, + } as unknown as ModelProvider + const { result: removedRes } = renderHook(() => useCredentialStatus(removedProvider)) + expect(removedRes.current.authRemoved).toBe(true) + expect(removedRes.current.authorized).toBeFalsy() + }) + + it('handles empty or restricted credentials', () => { + // Empty case + const emptyProvider = { + custom_configuration: { available_credentials: [] }, + } as unknown as ModelProvider + const { result: emptyRes } = renderHook(() => useCredentialStatus(emptyProvider)) + expect(emptyRes.current.hasCredential).toBe(false) + + // Restricted case + const restrictedProvider = { + custom_configuration: { + current_credential_id: '123', + available_credentials: [{ credential_id: '123', not_allowed_to_use: true }], + }, + } as unknown as ModelProvider + const { result: restrictedRes } = renderHook(() => useCredentialStatus(restrictedProvider)) + expect(restrictedRes.current.notAllowedToUse).toBe(true) + }) + + it('handles undefined custom configuration gracefully', () => { + const { result } = renderHook(() => useCredentialStatus({ custom_configuration: {} } as ModelProvider)) + expect(result.current.hasCredential).toBe(false) + expect(result.current.available_credentials).toBeUndefined() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-custom-models.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-custom-models.spec.tsx new file mode 100644 index 0000000000..5f7e568c51 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-custom-models.spec.tsx @@ -0,0 +1,38 @@ +import type { ModelProvider } from '../../declarations' +import { renderHook } from '@testing-library/react' +import { useCanAddedModels, useCustomModels } from './use-custom-models' + +describe('useCustomModels and useCanAddedModels', () => { + it('extracts custom models from provider correctly', () => { + const mockProvider = { + custom_configuration: { + custom_models: [ + { model: 'gpt-4', model_type: 'text-generation' }, + { model: 'gpt-3.5', model_type: 'text-generation' }, + ], + }, + } as unknown as ModelProvider + + const { result } = renderHook(() => useCustomModels(mockProvider)) + expect(result.current).toHaveLength(2) + expect(result.current[0].model).toBe('gpt-4') + + const { result: emptyRes } = renderHook(() => useCustomModels({ custom_configuration: {} } as unknown as ModelProvider)) + expect(emptyRes.current).toEqual([]) + }) + + it('extracts can_added_models from provider correctly', () => { + const mockProvider = { + custom_configuration: { + can_added_models: [{ model: 'gpt-4-turbo', model_type: 'text-generation' }], + }, + } as unknown as ModelProvider + + const { result } = renderHook(() => useCanAddedModels(mockProvider)) + expect(result.current).toHaveLength(1) + expect(result.current[0].model).toBe('gpt-4-turbo') + + const { result: emptyRes } = renderHook(() => useCanAddedModels({ custom_configuration: {} } as unknown as ModelProvider)) + expect(emptyRes.current).toEqual([]) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-model-form-schemas.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-model-form-schemas.spec.tsx new file mode 100644 index 0000000000..a326b0c1d5 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/hooks/use-model-form-schemas.spec.tsx @@ -0,0 +1,78 @@ +import type { + Credential, + CustomModelCredential, + ModelProvider, +} from '../../declarations' +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { FormTypeEnum } from '@/app/components/base/form/types' +import { useModelFormSchemas } from './use-model-form-schemas' + +vi.mock('../../utils', () => ({ + genModelNameFormSchema: vi.fn(() => ({ + type: FormTypeEnum.textInput, + variable: '__model_name', + label: 'Model Name', + required: true, + })), + genModelTypeFormSchema: vi.fn(() => ({ + type: FormTypeEnum.select, + variable: '__model_type', + label: 'Model Type', + required: true, + })), +})) + +describe('useModelFormSchemas', () => { + const mockProvider = { + provider: 'openai', + provider_credential_schema: { + credential_form_schemas: [ + { type: FormTypeEnum.textInput, variable: 'api_key', label: 'API Key', required: true }, + ], + }, + model_credential_schema: { + credential_form_schemas: [ + { type: FormTypeEnum.textInput, variable: 'model_key', label: 'Model Key', required: true }, + ], + }, + supported_model_types: ['text-generation'], + } as unknown as ModelProvider + + it('selects correct form schemas based on providerFormSchemaPredefined', () => { + const { result: providerResult } = renderHook(() => useModelFormSchemas(mockProvider, true)) + expect(providerResult.current.formSchemas.some(s => s.variable === 'api_key')).toBe(true) + + const { result: modelResult } = renderHook(() => useModelFormSchemas(mockProvider, false)) + expect(modelResult.current.formSchemas.some(s => s.variable === 'model_key')).toBe(true) + + const { result: emptyResult } = renderHook(() => useModelFormSchemas({} as unknown as ModelProvider, true)) + expect(emptyResult.current.formSchemas).toHaveLength(1) // only __authorization_name__ + }) + + it('computes form values correctly for credentials and models', () => { + const mockCredential = { credential_name: 'Test' } as unknown as Credential + const mockModel = { model: 'gpt-4', model_type: 'text-generation' } as unknown as CustomModelCredential + const { result } = renderHook(() => useModelFormSchemas(mockProvider, true, { api_key: 'val' }, mockCredential, mockModel)) + expect((result.current.formValues as Record).api_key).toBe('val') + expect((result.current.formValues as Record).__authorization_name__).toBe('Test') + expect((result.current.formValues as Record).__model_name).toBe('gpt-4') + + // Branch: credential present but credentials (param) missing + const { result: emptyCredsRes } = renderHook(() => useModelFormSchemas(mockProvider, true, undefined, mockCredential)) + expect((emptyCredsRes.current.formValues as Record).__authorization_name__).toBe('Test') + }) + + it('handles model name and type schemas for custom models', () => { + const { result: predefined } = renderHook(() => useModelFormSchemas(mockProvider, true)) + expect(predefined.current.modelNameAndTypeFormSchemas).toHaveLength(0) + + const { result: custom } = renderHook(() => useModelFormSchemas(mockProvider, false)) + expect(custom.current.modelNameAndTypeFormSchemas).toHaveLength(2) + expect(custom.current.modelNameAndTypeFormSchemas[0].variable).toBe('__model_name') + + const mockModel = { model: 'custom', model_type: 'text' } as unknown as CustomModelCredential + const { result: customWithVal } = renderHook(() => useModelFormSchemas(mockProvider, false, undefined, undefined, mockModel)) + expect((customWithVal.current.modelNameAndTypeFormValues as Record).__model_name).toBe('custom') + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/manage-custom-model-credentials.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/manage-custom-model-credentials.spec.tsx new file mode 100644 index 0000000000..ee25dbe6cd --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/manage-custom-model-credentials.spec.tsx @@ -0,0 +1,62 @@ +import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { render, screen } from '@testing-library/react' +import ManageCustomModelCredentials from './manage-custom-model-credentials' + +// Mock hooks +const mockUseCustomModels = vi.fn() +vi.mock('./hooks', () => ({ + useCustomModels: () => mockUseCustomModels(), + useAuth: () => ({ + handleOpenModal: vi.fn(), + }), +})) + +// Mock Authorized +vi.mock('./authorized', () => ({ + default: ({ renderTrigger, items, popupTitle }: { renderTrigger: (o?: boolean) => React.ReactNode, items: { length: number }, popupTitle: string }) => ( +
+
{renderTrigger()}
+
{popupTitle}
+
{items.length}
+
+ ), +})) + +describe('ManageCustomModelCredentials', () => { + const mockProvider = { + provider: 'openai', + } as unknown as ModelProvider + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return null when no custom models exist', () => { + mockUseCustomModels.mockReturnValue([]) + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should render authorized component when custom models exist', () => { + const mockModels = [ + { + model: 'gpt-4', + available_model_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }], + current_credential_id: 'c1', + current_credential_name: 'Key 1', + }, + { + model: 'gpt-3.5', + // testing undefined credentials branch + }, + ] + mockUseCustomModels.mockReturnValue(mockModels) + + render() + + expect(screen.getByTestId('authorized-mock')).toBeInTheDocument() + expect(screen.getByText(/modelProvider.auth.manageCredentials/)).toBeInTheDocument() + expect(screen.getByTestId('items-count')).toHaveTextContent('2') + expect(screen.getByTestId('popup-title')).toHaveTextContent('modelProvider.auth.customModelCredentials') + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.spec.tsx new file mode 100644 index 0000000000..a727e2ea40 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.spec.tsx @@ -0,0 +1,130 @@ +import type { CustomModel, ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import SwitchCredentialInLoadBalancing from './switch-credential-in-load-balancing' + +// Mock components +vi.mock('./authorized', () => ({ + default: ({ renderTrigger, onItemClick, items }: { renderTrigger: () => React.ReactNode, onItemClick: (c: unknown) => void, items: { credentials: unknown[] }[] }) => ( +
+
onItemClick(items[0].credentials[0])}> + {renderTrigger()} +
+
+ ), +})) + +vi.mock('@/app/components/header/indicator', () => ({ + default: ({ color }: { color: string }) =>
, +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( +
+ {children} +
{popupContent}
+
+ ), +})) + +vi.mock('@remixicon/react', () => ({ + RiArrowDownSLine: () =>
, +})) + +describe('SwitchCredentialInLoadBalancing', () => { + const mockProvider = { + provider: 'openai', + allow_custom_token: true, + } as unknown as ModelProvider + + const mockModel = { + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + } as unknown as CustomModel + + const mockCredentials = [ + { credential_id: 'cred-1', credential_name: 'Key 1' }, + { credential_id: 'cred-2', credential_name: 'Key 2' }, + ] + + const mockSetCustomModelCredential = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render selected credential name correctly', () => { + render( + , + ) + + expect(screen.getByText('Key 1')).toBeInTheDocument() + expect(screen.getByTestId('indicator-green')).toBeInTheDocument() + }) + + it('should render auth removed status when selected credential is not in list', () => { + render( + , + ) + + expect(screen.getByText(/modelProvider.auth.authRemoved/)).toBeInTheDocument() + expect(screen.getByTestId('indicator-red')).toBeInTheDocument() + }) + + it('should render unavailable status when credentials list is empty', () => { + render( + , + ) + + expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument() + expect(screen.queryByTestId(/indicator-/)).not.toBeInTheDocument() + }) + + it('should call setCustomModelCredential when an item is selected in Authorized', () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger-container')) + expect(mockSetCustomModelCredential).toHaveBeenCalledWith(mockCredentials[0]) + }) + + it('should show tooltip when empty and custom credentials not allowed', () => { + const restrictedProvider = { ...mockProvider, allow_custom_token: false } + render( + , + ) + + expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument() + }) +})