diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/add-model-button.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/add-model-button.spec.tsx new file mode 100644 index 0000000000..c0c5daece1 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/add-model-button.spec.tsx @@ -0,0 +1,17 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import AddModelButton from './add-model-button' + +describe('AddModelButton', () => { + it('should render button with text', () => { + render() + expect(screen.getByText('common.modelProvider.addModel')).toBeInTheDocument() + }) + + it('should call onClick when clicked', () => { + const handleClick = vi.fn() + render() + const button = screen.getByText('common.modelProvider.addModel') + fireEvent.click(button) + expect(handleClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.spec.tsx new file mode 100644 index 0000000000..983f9e8f2c --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.spec.tsx @@ -0,0 +1,33 @@ +import { render } from '@testing-library/react' +import CooldownTimer from './cooldown-timer' + +describe('CooldownTimer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render timer when secondsRemaining is positive', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should not render when secondsRemaining is zero', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should not render when secondsRemaining is undefined', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should call onFinish after countdown completes', () => { + vi.useFakeTimers() + const onFinish = vi.fn() + render() + + vi.advanceTimersByTime(2000) + expect(onFinish).toHaveBeenCalled() + vi.useRealTimers() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx new file mode 100644 index 0000000000..554efc93d2 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx @@ -0,0 +1,145 @@ +import type { ModelProvider } from '../declarations' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { changeModelProviderPriority } from '@/service/common' +import { ConfigurationMethodEnum } from '../declarations' +import CredentialPanel from './credential-panel' + +const mockEventEmitter = { emit: vi.fn() } +const mockNotify = vi.fn() +const mockUpdateModelList = vi.fn() +const mockUpdateModelProviders = vi.fn() +const mockCredentialStatus = { + hasCredential: true, + authorized: true, + authRemoved: false, + current_credential_name: 'test-credential', + notAllowedToUse: false, +} + +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + IS_CLOUD_EDITION: true, + } +}) + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ + notify: mockNotify, + }), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: mockEventEmitter, + }), +})) + +vi.mock('@/service/common', () => ({ + changeModelProviderPriority: vi.fn(), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({ + ConfigProvider: () =>
, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth/hooks', () => ({ + useCredentialStatus: () => mockCredentialStatus, +})) + +vi.mock('../hooks', () => ({ + useUpdateModelList: () => mockUpdateModelList, + useUpdateModelProviders: () => mockUpdateModelProviders, +})) + +vi.mock('./priority-selector', () => ({ + default: ({ value, onSelect }: { value: string, onSelect: (key: string) => void }) => ( + + ), +})) + +vi.mock('./priority-use-tip', () => ({ + default: () =>
Priority Tip
, +})) + +vi.mock('@/app/components/header/indicator', () => ({ + default: ({ color }: { color: string }) =>
{color}
, +})) + +describe('CredentialPanel', () => { + const mockProvider: ModelProvider = { + provider: 'test-provider', + provider_credential_schema: true, + custom_configuration: { status: 'active' }, + system_configuration: { enabled: true }, + preferred_provider_type: 'system', + configurate_methods: [ConfigurationMethodEnum.predefinedModel], + supported_model_types: ['gpt-4'], + } as unknown as ModelProvider + + beforeEach(() => { + vi.clearAllMocks() + Object.assign(mockCredentialStatus, { + hasCredential: true, + authorized: true, + authRemoved: false, + current_credential_name: 'test-credential', + notAllowedToUse: false, + }) + }) + + it('should show credential name and configuration actions', () => { + render() + + expect(screen.getByText('test-credential')).toBeInTheDocument() + expect(screen.getByTestId('config-provider')).toBeInTheDocument() + expect(screen.getByTestId('priority-selector')).toBeInTheDocument() + }) + + it('should show unauthorized status label when credential is missing', () => { + mockCredentialStatus.hasCredential = false + render() + + expect(screen.getByText(/modelProvider\.auth\.unAuthorized/)).toBeInTheDocument() + }) + + it('should show removed credential label and priority tip for custom preference', () => { + mockCredentialStatus.authorized = false + mockCredentialStatus.authRemoved = true + render() + + expect(screen.getByText(/modelProvider\.auth\.authRemoved/)).toBeInTheDocument() + expect(screen.getByTestId('priority-use-tip')).toBeInTheDocument() + }) + + it('should change priority and refresh related data after success', async () => { + const mockChangePriority = changeModelProviderPriority as ReturnType + mockChangePriority.mockResolvedValue({ result: 'success' }) + render() + + fireEvent.click(screen.getByTestId('priority-selector')) + + await waitFor(() => { + expect(mockChangePriority).toHaveBeenCalled() + expect(mockNotify).toHaveBeenCalled() + expect(mockUpdateModelProviders).toHaveBeenCalled() + expect(mockUpdateModelList).toHaveBeenCalledWith('gpt-4') + expect(mockEventEmitter.emit).toHaveBeenCalled() + }) + }) + + it('should render standalone priority selector without provider schema', () => { + const providerNoSchema = { + ...mockProvider, + provider_credential_schema: null, + } as unknown as ModelProvider + render() + expect(screen.getByTestId('priority-selector')).toBeInTheDocument() + expect(screen.queryByTestId('config-provider')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.spec.tsx new file mode 100644 index 0000000000..a1c1eb277c --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.spec.tsx @@ -0,0 +1,137 @@ +import type { ModelItem, ModelProvider } from '../declarations' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fetchModelProviderModelList } from '@/service/common' +import { ConfigurationMethodEnum } from '../declarations' +import ProviderAddedCard from './index' + +let mockIsCurrentWorkspaceManager = true +type SubscriptionPayload = { type?: string, payload?: string } | unknown +let subscriptionHandler: ((value: SubscriptionPayload) => void) | undefined +const mockEventEmitter: { useSubscription: unknown, emit: unknown } = { + useSubscription: vi.fn((handler: (value: SubscriptionPayload) => void) => { + subscriptionHandler = handler + }), + emit: vi.fn(), +} + +vi.mock('@/service/common', () => ({ + fetchModelProviderModelList: vi.fn(), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager, + }), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: mockEventEmitter, + }), +})) + +vi.mock('./credential-panel', () => ({ + default: () =>
, +})) + +vi.mock('./model-list', () => ({ + default: ({ onCollapse, onChange }: { onCollapse: () => void, onChange: (provider: string) => void }) => ( +
+ + +
+ ), +})) + +vi.mock('../provider-icon', () => ({ + default: () =>
, +})) + +vi.mock('../model-badge', () => ({ + default: ({ children }: { children: string }) =>
{children}
, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({ + AddCustomModel: () =>
, + ManageCustomModelCredentials: () =>
, +})) + +describe('ProviderAddedCard', () => { + const mockProvider = { + provider: 'langgenius/openai/openai', + configurate_methods: ['predefinedModel'], + system_configuration: { enabled: true }, + supported_model_types: ['llm'], + } as unknown as ModelProvider + + beforeEach(() => { + vi.clearAllMocks() + mockIsCurrentWorkspaceManager = true + subscriptionHandler = undefined + }) + + it('should render provider added card component', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should open and refresh model list from user actions', async () => { + vi.mocked(fetchModelProviderModelList).mockResolvedValue({ data: [{ model: 'gpt-4' }] } as unknown as { data: ModelItem[] }) + render() + + const showModelsBtn = screen.getAllByText('common.modelProvider.showModels')[1] + fireEvent.click(showModelsBtn) + + await screen.findByTestId('model-list') + expect(fetchModelProviderModelList).toHaveBeenCalledWith(`/workspaces/current/model-providers/${mockProvider.provider}/models`) + + fireEvent.click(screen.getByRole('button', { name: 'refresh list' })) + await waitFor(() => { + expect(fetchModelProviderModelList).toHaveBeenCalledTimes(2) + }) + + fireEvent.click(screen.getByRole('button', { name: 'collapse list' })) + expect(screen.getAllByText(/common\.modelProvider\.showModelsNum:\{"num":1\}/).length).toBeGreaterThan(0) + }) + + it('should render configure tip when provider is not in quota list and not configured', () => { + const providerWithoutQuota = { + ...mockProvider, + provider: 'custom/provider', + } as unknown as ModelProvider + render() + expect(screen.getByText('common.modelProvider.configureTip')).toBeInTheDocument() + }) + + it('should refresh model list on matching event subscription', async () => { + vi.mocked(fetchModelProviderModelList).mockResolvedValue({ data: [{ model: 'gpt-4' }] } as unknown as { data: ModelItem[] }) + render() + + expect(subscriptionHandler).toBeTruthy() + await act(async () => { + subscriptionHandler?.({ + type: 'UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST', + payload: mockProvider.provider, + }) + }) + + await waitFor(() => { + expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1) + }) + }) + + it('should render custom model actions only for workspace managers', () => { + const customConfigProvider = { + ...mockProvider, + configurate_methods: [ConfigurationMethodEnum.customizableModel], + } as unknown as ModelProvider + const { rerender } = render() + + expect(screen.getByTestId('manage-custom-model')).toBeInTheDocument() + expect(screen.getByTestId('add-custom-model')).toBeInTheDocument() + + mockIsCurrentWorkspaceManager = false + rerender() + expect(screen.queryByTestId('manage-custom-model')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.spec.tsx new file mode 100644 index 0000000000..6ed82ed095 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.spec.tsx @@ -0,0 +1,130 @@ +import type { ModelItem, ModelProvider } from '../declarations' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { disableModel, enableModel } from '@/service/common' +import { ModelStatusEnum } from '../declarations' +import ModelListItem from './model-list-item' + +let mockModelLoadBalancingEnabled = false + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: true, + }), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + plan: { type: 'pro' }, + }), + useProviderContextSelector: () => mockModelLoadBalancingEnabled, +})) + +vi.mock('@/service/common', () => ({ + enableModel: vi.fn(), + disableModel: vi.fn(), +})) + +vi.mock('../hooks', () => ({ + useUpdateModelList: () => vi.fn(), +})) + +vi.mock('../model-icon', () => ({ + default: () =>
, +})) + +vi.mock('../model-name', () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('../model-auth', () => ({ + ConfigModel: ({ onClick }: { onClick: () => void }) => ( + + ), +})) + +describe('ModelListItem', () => { + const mockProvider = { + provider: 'test-provider', + } as unknown as ModelProvider + + const mockModel = { + model: 'gpt-4', + model_type: 'llm', + fetch_from: 'system', + status: 'active', + deprecated: false, + load_balancing_enabled: false, + has_invalid_load_balancing_configs: false, + } as unknown as ModelItem + + beforeEach(() => { + vi.clearAllMocks() + mockModelLoadBalancingEnabled = false + }) + + it('should render model item with icon and name', () => { + render( + , + ) + expect(screen.getByTestId('model-icon')).toBeInTheDocument() + expect(screen.getByTestId('model-name')).toBeInTheDocument() + }) + + it('should disable an active model when switch is clicked', async () => { + const onChange = vi.fn() + render( + , + ) + fireEvent.click(screen.getByRole('switch')) + + await waitFor(() => { + expect(disableModel).toHaveBeenCalled() + expect(onChange).toHaveBeenCalledWith('test-provider') + }, { timeout: 2000 }) + }) + + it('should enable a disabled model when switch is clicked', async () => { + const onChange = vi.fn() + const disabledModel = { ...mockModel, status: ModelStatusEnum.disabled } + render( + , + ) + fireEvent.click(screen.getByRole('switch')) + + await waitFor(() => { + expect(enableModel).toHaveBeenCalled() + expect(onChange).toHaveBeenCalledWith('test-provider') + }, { timeout: 2000 }) + }) + + it('should open load balancing config action when available', () => { + mockModelLoadBalancingEnabled = true + const onModifyLoadBalancing = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'modify load balancing' })) + expect(onModifyLoadBalancing).toHaveBeenCalledWith(mockModel) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.spec.tsx new file mode 100644 index 0000000000..2133c5e2db --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.spec.tsx @@ -0,0 +1,108 @@ +import type { ModelItem, ModelProvider } from '../declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import ModelList from './model-list' + +const mockSetShowModelLoadBalancingModal = vi.fn() +let mockIsCurrentWorkspaceManager = true + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager, + }), +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContextSelector: (selector: (state: { setShowModelLoadBalancingModal: typeof mockSetShowModelLoadBalancingModal }) => unknown) => + selector({ setShowModelLoadBalancingModal: mockSetShowModelLoadBalancingModal }), +})) + +vi.mock('./model-list-item', () => ({ + default: ({ model, onModifyLoadBalancing }: { model: ModelItem, onModifyLoadBalancing: (model: ModelItem) => void }) => ( + + ), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({ + ManageCustomModelCredentials: () =>
, + AddCustomModel: () =>
, +})) + +describe('ModelList', () => { + const mockProvider = { + provider: 'test-provider', + configurate_methods: ['customizableModel'], + } as unknown as ModelProvider + + const mockModels = [ + { model: 'gpt-4', model_type: 'llm', fetch_from: 'system' }, + { model: 'gpt-3.5', model_type: 'llm', fetch_from: 'system' }, + ] as unknown as ModelItem[] + + const mockOnCollapse = vi.fn() + const mockOnChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockIsCurrentWorkspaceManager = true + }) + + it('should render model count and model items', () => { + render( + , + ) + expect(screen.getAllByText(/modelProvider\.modelsNum/).length).toBeGreaterThan(0) + expect(screen.getByRole('button', { name: 'gpt-4' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'gpt-3.5' })).toBeInTheDocument() + }) + + it('should trigger collapse when collapsed label is clicked', () => { + render( + , + ) + + const countElements = screen.getAllByText(/modelProvider\.modelsNum/) + fireEvent.click(countElements[1]) + expect(mockOnCollapse).toHaveBeenCalled() + }) + + it('should open load balancing modal for selected model', () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'gpt-4' })) + expect(mockSetShowModelLoadBalancingModal).toHaveBeenCalled() + }) + + it('should hide custom model actions for non-manager', () => { + mockIsCurrentWorkspaceManager = false + render( + , + ) + + expect(screen.queryByTestId('manage-credentials')).not.toBeInTheDocument() + expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.spec.tsx new file mode 100644 index 0000000000..0cceccb1f0 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.spec.tsx @@ -0,0 +1,191 @@ +import type { + Credential, + CustomModelCredential, + ModelCredential, + ModelLoadBalancingConfig, + ModelProvider, +} from '../declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { ConfigurationMethodEnum } from '../declarations' +import ModelLoadBalancingConfigs from './model-load-balancing-configs' + +let mockModelLoadBalancingEnabled = true + +vi.mock('@/config', () => ({ + IS_CE_EDITION: false, +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContextSelector: () => mockModelLoadBalancingEnabled, +})) + +vi.mock('./cooldown-timer', () => ({ + default: ({ secondsRemaining, onFinish }: { secondsRemaining?: number, onFinish?: () => void }) => ( + + ), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({ + AddCredentialInLoadBalancing: ({ onSelectCredential, onUpdate, onRemove }: { + onSelectCredential: (credential: Credential) => void + onUpdate?: (payload?: unknown, formValues?: Record) => void + onRemove?: (credentialId: string) => void + }) => ( +
+ + + +
+ ), +})) + +vi.mock('@/app/components/billing/upgrade-btn', () => ({ + default: () =>
upgrade
, +})) + +describe('ModelLoadBalancingConfigs', () => { + const mockProvider = { + provider: 'test-provider', + } as unknown as ModelProvider + + const mockModelCredential = { + available_credentials: [ + { + credential_id: 'cred-1', + credential_name: 'Key 1', + not_allowed_to_use: false, + }, + { + credential_id: 'cred-2', + credential_name: 'Key 2', + not_allowed_to_use: false, + }, + ], + } as unknown as ModelCredential + + const createDraftConfig = (enabled = true): ModelLoadBalancingConfig => ({ + enabled, + configs: [ + { + id: 'cfg-1', + credential_id: 'cred-1', + enabled: true, + name: 'Key 1', + }, + ], + } as ModelLoadBalancingConfig) + + const StatefulHarness = ({ + initialConfig, + withSwitch = false, + onUpdate, + onRemove, + }: { + initialConfig: ModelLoadBalancingConfig + withSwitch?: boolean + onUpdate?: (payload?: unknown, formValues?: Record) => void + onRemove?: (credentialId: string) => void + }) => { + const [draftConfig, setDraftConfig] = useState(initialConfig) + return ( + + ) + } + + beforeEach(() => { + vi.clearAllMocks() + mockModelLoadBalancingEnabled = true + }) + + it('should render nothing when draft config is missing', () => { + const { container } = render( + , + ) + expect(container.firstChild).toBeNull() + }) + + it('should show current configs and low key warning when enabled', () => { + render() + + expect(screen.getAllByText(/modelProvider\.loadBalancing/).length).toBeGreaterThan(0) + expect(screen.getByText('Key 1')).toBeInTheDocument() + expect(screen.getByText(/modelProvider\.loadBalancingLeastKeyWarning/)).toBeInTheDocument() + }) + + it('should enable load balancing by clicking the panel when disabled', () => { + render() + + fireEvent.click(screen.getAllByText(/modelProvider\.loadBalancing/)[0]) + + expect(screen.getByText('Key 1')).toBeInTheDocument() + }) + + it('should add and remove credentials from the visible list', () => { + const onUpdate = vi.fn() + const onRemove = vi.fn() + const draftConfig = { + enabled: true, + configs: [ + { id: 'cfg-1', credential_id: 'cred-1', enabled: true, name: 'Key 1', in_cooldown: true, ttl: 30 }, + { id: 'cfg-2', credential_id: 'cred-2', enabled: true, name: '__inherit__' }, + ], + } as unknown as ModelLoadBalancingConfig + render() + + fireEvent.click(screen.getByRole('button', { name: '30s' })) + + fireEvent.click(screen.getByRole('button', { name: 'add credential' })) + expect(screen.getByText('Key 2')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'trigger update' })) + expect(onUpdate).toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'trigger remove' })) + expect(onRemove).toHaveBeenCalledWith('cred-2') + expect(screen.queryByText('Key 2')).not.toBeInTheDocument() + fireEvent.click(screen.getAllByRole('switch')[0]) + }) + + it('should show upgrade prompt when feature is unavailable', () => { + mockModelLoadBalancingEnabled = false + render() + + expect(screen.getByText(/modelProvider\.upgradeForLoadBalancing/)).toBeInTheDocument() + expect(screen.getByText('upgrade')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.spec.tsx new file mode 100644 index 0000000000..ea78234612 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.spec.tsx @@ -0,0 +1,268 @@ +import type { ModelItem, ModelProvider } from '../declarations' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { ConfigurationMethodEnum } from '../declarations' +import ModelLoadBalancingModal from './model-load-balancing-modal' + +type CredentialData = { + load_balancing: { + enabled: boolean + configs: Array<{ + id: string + credential_id: string + enabled: boolean + name: string + credentials: { api_key: string } + }> + } + current_credential_id: string + available_credentials: Array<{ credential_id: string, credential_name: string }> + current_credential_name: string +} + +const mockNotify = vi.fn() +const mockMutateAsync = vi.fn() +const mockRefetch = vi.fn() +const mockHandleRefreshModel = vi.fn() +const mockHandleConfirmDelete = vi.fn() +const mockOpenConfirmDelete = vi.fn() + +let mockDeleteModel: unknown = null +let mockCredentialData: CredentialData | undefined = { + load_balancing: { + enabled: true, + configs: [ + { id: 'cfg-1', credential_id: 'cred-1', enabled: true, name: 'Default', credentials: { api_key: 'same-key' } }, + { id: 'cfg-2', credential_id: 'cred-2', enabled: true, name: 'Backup', credentials: { api_key: 'backup-key' } }, + ], + }, + current_credential_id: 'cred-1', + available_credentials: [ + { credential_id: 'cred-1', credential_name: 'Default' }, + { credential_id: 'cred-2', credential_name: 'Backup' }, + ], + current_credential_name: 'Default', +} + +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ + notify: mockNotify, + }), +})) + +vi.mock('@/service/use-models', () => ({ + useGetModelCredential: () => ({ + isLoading: false, + data: mockCredentialData, + refetch: mockRefetch, + }), + useUpdateModelLoadBalancingConfig: () => ({ + mutateAsync: mockMutateAsync, + }), +})) + +vi.mock('../model-auth/hooks/use-auth', () => ({ + useAuth: () => ({ + doingAction: false, + deleteModel: mockDeleteModel, + openConfirmDelete: mockOpenConfirmDelete, + closeConfirmDelete: vi.fn(), + handleConfirmDelete: mockHandleConfirmDelete, + }), +})) + +vi.mock('../hooks', () => ({ + useRefreshModel: () => ({ handleRefreshModel: mockHandleRefreshModel }), +})) + +vi.mock('./model-load-balancing-configs', () => ({ + default: ({ onUpdate, onRemove }: { + onUpdate?: (payload?: unknown, formValues?: Record) => void + onRemove?: (credentialId: string) => void + }) => ( +
+ + + +
+ ), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({ + SwitchCredentialInLoadBalancing: ({ onUpdate }: { onUpdate: () => void }) => ( + + ), +})) + +vi.mock('../model-icon', () => ({ + default: () =>
model-icon
, +})) + +vi.mock('../model-name', () => ({ + default: () =>
model-name
, +})) + +describe('ModelLoadBalancingModal', () => { + const mockProvider = { + provider: 'test-provider', + provider_credential_schema: { + credential_form_schemas: [{ type: 'secret-input', variable: 'api_key' }], + }, + model_credential_schema: { + credential_form_schemas: [{ type: 'secret-input', variable: 'api_key' }], + }, + } as unknown as ModelProvider + + const mockModel = { + model: 'gpt-4', + model_type: 'llm', + fetch_from: 'predefined-model', + } as unknown as ModelItem + + beforeEach(() => { + vi.clearAllMocks() + mockDeleteModel = null + mockCredentialData = { + load_balancing: { + enabled: true, + configs: [ + { id: 'cfg-1', credential_id: 'cred-1', enabled: true, name: 'Default', credentials: { api_key: 'same-key' } }, + { id: 'cfg-2', credential_id: 'cred-2', enabled: true, name: 'Backup', credentials: { api_key: 'backup-key' } }, + ], + }, + current_credential_id: 'cred-1', + available_credentials: [ + { credential_id: 'cred-1', credential_name: 'Default' }, + { credential_id: 'cred-2', credential_name: 'Backup' }, + ], + current_credential_name: 'Default', + } + mockMutateAsync.mockResolvedValue({ result: 'success' }) + mockRefetch.mockResolvedValue({ data: mockCredentialData }) + }) + + it('should show loading area while draft config is not ready', () => { + mockCredentialData = undefined + + render( + , + ) + + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should render predefined model content', () => { + render( + , + ) + + expect(screen.getByText(/modelProvider\.auth\.configLoadBalancing/)).toBeInTheDocument() + expect(screen.getByText(/modelProvider\.auth\.providerManaged$/)).toBeInTheDocument() + expect(screen.getByText(/operation\.save/)).toBeInTheDocument() + }) + + it('should render custom model actions and close when update has no credentials', async () => { + const onClose = vi.fn() + mockRefetch.mockResolvedValue({ data: { available_credentials: [] } }) + render( + , + ) + + expect(screen.getByText(/modelProvider\.auth\.removeModel/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'switch credential' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'config add credential' })) + await waitFor(() => { + expect(onClose).toHaveBeenCalled() + }) + }) + + it('should save load balancing config and close modal', async () => { + const onSave = vi.fn() + const onClose = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'config add credential' })) + fireEvent.click(screen.getByRole('button', { name: 'config rename credential' })) + fireEvent.click(screen.getByText(/operation\.save/)) + + await waitFor(() => { + expect(mockRefetch).toHaveBeenCalled() + expect(mockMutateAsync).toHaveBeenCalled() + const payload = mockMutateAsync.mock.calls[0][0] as { load_balancing: { configs: Array<{ credentials: { api_key: string } }> } } + expect(payload.load_balancing.configs[0].credentials.api_key).toBe('[__HIDDEN__]') + expect(mockNotify).toHaveBeenCalled() + expect(mockHandleRefreshModel).toHaveBeenCalled() + expect(onSave).toHaveBeenCalledWith('test-provider') + expect(onClose).toHaveBeenCalled() + }) + }) + + it('should close modal when switching credential yields no available credentials', async () => { + const onClose = vi.fn() + mockRefetch.mockResolvedValue({ data: { available_credentials: [] } }) + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'switch credential' })) + await waitFor(() => { + expect(onClose).toHaveBeenCalled() + }) + }) + + it('should confirm model deletion and close modal', async () => { + const onClose = vi.fn() + mockDeleteModel = { model: 'gpt-4' } + + render( + , + ) + + fireEvent.click(screen.getByText(/modelProvider\.auth\.removeModel/)) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + + await waitFor(() => { + expect(mockOpenConfirmDelete).toHaveBeenCalled() + expect(mockHandleConfirmDelete).toHaveBeenCalled() + expect(onClose).toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-selector.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-selector.spec.tsx new file mode 100644 index 0000000000..3d4dc24a79 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-selector.spec.tsx @@ -0,0 +1,29 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import PrioritySelector from './priority-selector' + +describe('PrioritySelector', () => { + const mockOnSelect = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render selector button', () => { + render() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should call onSelect when option clicked', () => { + render() + fireEvent.click(screen.getByRole('button')) + const option = screen.getByText('common.modelProvider.apiKey') + fireEvent.click(option) + expect(mockOnSelect).toHaveBeenCalled() + }) + + it('should display priority use header in popover', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(screen.getByText('common.modelProvider.card.priorityUse')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-use-tip.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-use-tip.spec.tsx new file mode 100644 index 0000000000..86e51c4a53 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-use-tip.spec.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react' +import PriorityUseTip from './priority-use-tip' + +describe('PriorityUseTip', () => { + it('should render tooltip with icon content', () => { + const { container } = render() + expect(container.querySelector('[data-state]')).toBeInTheDocument() + }) + + it('should render the component without crashing', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.spec.tsx new file mode 100644 index 0000000000..1088114a59 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.spec.tsx @@ -0,0 +1,138 @@ +import type { ModelProvider } from '../declarations' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import QuotaPanel from './quota-panel' + +let mockWorkspace = { + trial_credits: 100, + trial_credits_used: 30, + next_credit_reset_date: '2024-12-31', +} +let mockTrialModels: string[] = ['langgenius/openai/openai'] +let mockPlugins = [{ + plugin_id: 'langgenius/openai', + latest_package_identifier: 'openai@1.0.0', +}] + +vi.mock('@/app/components/base/icons/src/public/llm', () => { + const Icon = ({ label }: { label: string }) => {label} + return { + OpenaiSmall: () => , + AnthropicShortLight: () => , + Gemini: () => , + Grok: () => , + Deepseek: () => , + Tongyi: () => , + } +}) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + currentWorkspace: mockWorkspace, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: { systemFeatures: { trial_models: string[] } }) => unknown) => selector({ + systemFeatures: { + trial_models: mockTrialModels, + }, + }), +})) + +vi.mock('../hooks', () => ({ + useMarketplaceAllPlugins: () => ({ + plugins: mockPlugins, + }), +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: () => '2024-12-31', + }), +})) + +vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
+ install modal + +
+ ), +})) + +describe('QuotaPanel', () => { + const mockProviders = [ + { + provider: 'langgenius/openai/openai', + preferred_provider_type: 'custom', + custom_configuration: { available_credentials: [{ id: '1' }] }, + }, + ] as unknown as ModelProvider[] + + beforeEach(() => { + vi.clearAllMocks() + mockWorkspace = { + trial_credits: 100, + trial_credits_used: 30, + next_credit_reset_date: '2024-12-31', + } + mockTrialModels = ['langgenius/openai/openai'] + mockPlugins = [{ plugin_id: 'langgenius/openai', latest_package_identifier: 'openai@1.0.0' }] + }) + + it('should render loading state', () => { + render( + , + ) + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('should show remaining credits and reset date', () => { + render( + , + ) + + expect(screen.getByText(/modelProvider\.quota/)).toBeInTheDocument() + expect(screen.getByText('70')).toBeInTheDocument() + expect(screen.getByText(/modelProvider\.resetDate/)).toBeInTheDocument() + }) + + it('should floor credits at zero when usage is higher than quota', () => { + mockWorkspace = { + trial_credits: 10, + trial_credits_used: 999, + next_credit_reset_date: '', + } + + render() + + expect(screen.getByText('0')).toBeInTheDocument() + expect(screen.queryByText(/modelProvider\.resetDate/)).not.toBeInTheDocument() + }) + + it('should open install modal when clicking an unsupported trial provider', () => { + render() + + fireEvent.click(screen.getByText('openai')) + + expect(screen.getByText('install modal')).toBeInTheDocument() + }) + + it('should close install modal when provider becomes installed', async () => { + const { rerender } = render() + + fireEvent.click(screen.getByText('openai')) + expect(screen.getByText('install modal')).toBeInTheDocument() + + rerender() + + await waitFor(() => { + expect(screen.queryByText('install modal')).not.toBeInTheDocument() + }) + }) +})