Files
dify/web/app/components/header/account-setting/model-provider-page/model-modal/index.spec.tsx
2026-03-09 09:18:11 +08:00

318 lines
12 KiB
TypeScript

import type { ComponentProps } from 'react'
import type { Credential, CredentialFormSchema, CustomModel, ModelProvider } from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
ModelModalModeEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
QuotaUnitEnum,
} from '../declarations'
import ModelModal from './index'
type CredentialData = {
credentials: Record<string, unknown>
available_credentials: Credential[]
}
type ModelFormSchemas = {
formSchemas: CredentialFormSchema[]
formValues: Record<string, unknown>
modelNameAndTypeFormSchemas: CredentialFormSchema[]
modelNameAndTypeFormValues: Record<string, unknown>
}
const mockState = vi.hoisted(() => ({
isLoading: false,
credentialData: { credentials: {}, available_credentials: [] } as CredentialData,
doingAction: false,
deleteCredentialId: null as string | null,
isCurrentWorkspaceManager: true,
formSchemas: [] as CredentialFormSchema[],
formValues: {} as Record<string, unknown>,
modelNameAndTypeFormSchemas: [] as CredentialFormSchema[],
modelNameAndTypeFormValues: {} as Record<string, unknown>,
}))
const mockHandlers = vi.hoisted(() => ({
handleSaveCredential: vi.fn(),
handleConfirmDelete: vi.fn(),
closeConfirmDelete: vi.fn(),
openConfirmDelete: vi.fn(),
handleActiveCredential: vi.fn(),
}))
vi.mock('../model-auth/hooks', () => ({
useCredentialData: () => ({
isLoading: mockState.isLoading,
credentialData: mockState.credentialData,
}),
useAuth: () => ({
handleSaveCredential: mockHandlers.handleSaveCredential,
handleConfirmDelete: mockHandlers.handleConfirmDelete,
deleteCredentialId: mockState.deleteCredentialId,
closeConfirmDelete: mockHandlers.closeConfirmDelete,
openConfirmDelete: mockHandlers.openConfirmDelete,
doingAction: mockState.doingAction,
handleActiveCredential: mockHandlers.handleActiveCredential,
}),
useModelFormSchemas: (): ModelFormSchemas => ({
formSchemas: mockState.formSchemas,
formValues: mockState.formValues,
modelNameAndTypeFormSchemas: mockState.modelNameAndTypeFormSchemas,
modelNameAndTypeFormValues: mockState.modelNameAndTypeFormValues,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({ isCurrentWorkspaceManager: mockState.isCurrentWorkspaceManager }),
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (value: { en_US: string }) => value.en_US,
}))
vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
const createI18n = (text: string) => ({ en_US: text, zh_Hans: text })
const createProvider = (overrides?: Partial<ModelProvider>): ModelProvider => ({
provider: 'openai',
label: createI18n('OpenAI'),
help: {
title: createI18n('Help'),
url: createI18n('https://example.com'),
},
icon_small: createI18n('icon'),
supported_model_types: [ModelTypeEnum.textGeneration],
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
provider_credential_schema: { credential_form_schemas: [] },
model_credential_schema: {
model: { label: createI18n('Model'), placeholder: createI18n('Model') },
credential_form_schemas: [],
},
preferred_provider_type: PreferredProviderTypeEnum.system,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
available_credentials: [],
custom_models: [],
can_added_models: [],
},
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.trial,
quota_configurations: [
{
quota_type: CurrentSystemQuotaTypeEnum.trial,
quota_unit: QuotaUnitEnum.times,
quota_limit: 0,
quota_used: 0,
last_used: 0,
is_valid: true,
},
],
},
allow_custom_token: true,
...overrides,
})
const renderModal = (overrides?: Partial<ComponentProps<typeof ModelModal>>) => {
const provider = createProvider()
const props = {
provider,
configurateMethod: ConfigurationMethodEnum.predefinedModel,
onCancel: vi.fn(),
onSave: vi.fn(),
onRemove: vi.fn(),
...overrides,
}
render(<ModelModal {...props} />)
return props
}
const mockFormRef1 = {
getFormValues: vi.fn(),
getForm: vi.fn(() => ({ setFieldValue: vi.fn() })),
}
const mockFormRef2 = {
getFormValues: vi.fn(),
getForm: vi.fn(() => ({ setFieldValue: vi.fn() })),
}
vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
default: React.forwardRef((props: { formSchemas: Record<string, unknown>[], onChange?: (f: string, v: string) => void }, ref: React.ForwardedRef<unknown>) => {
React.useImperativeHandle(ref, () => {
// Return the mock depending on schemas passed (hacky but works for refs)
if (props.formSchemas.length > 0 && props.formSchemas[0].name === '__model_name')
return mockFormRef1
return mockFormRef2
})
return (
<div data-testid="auth-form" onClick={() => props.onChange?.('test-field', 'val')}>
AuthForm Mock (
{props.formSchemas.length}
{' '}
fields)
</div>
)
}),
}))
vi.mock('../model-auth', () => ({
CredentialSelector: ({ onSelect }: { onSelect: (val: unknown) => void }) => (
<button onClick={() => onSelect({ addNewCredential: true })} data-testid="credential-selector">
Select Credential
</button>
),
useAuth: vi.fn(),
useCredentialData: vi.fn(),
useModelFormSchemas: vi.fn(),
}))
describe('ModelModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockState.isLoading = false
mockState.credentialData = { credentials: {}, available_credentials: [] }
mockState.doingAction = false
mockState.deleteCredentialId = null
mockState.isCurrentWorkspaceManager = true
mockState.formSchemas = []
mockState.formValues = {}
mockState.modelNameAndTypeFormSchemas = []
mockState.modelNameAndTypeFormValues = {}
// reset form refs
mockFormRef1.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __model_name: 'test', __model_type: ModelTypeEnum.textGeneration } })
mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __authorization_name__: 'test_auth', api_key: 'sk-test' } })
})
it('should render title and loading state for predefined credential modal', () => {
mockState.isLoading = true
renderModal()
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.title')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.desc')).toBeInTheDocument()
})
it('should render model credential title when mode is configModelCredential', () => {
renderModal({
mode: ModelModalModeEnum.configModelCredential,
model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration },
})
expect(screen.getByText('common.modelProvider.auth.addModelCredential')).toBeInTheDocument()
})
it('should render edit credential title when credential exists', () => {
renderModal({
mode: ModelModalModeEnum.configModelCredential,
credential: { credential_id: '1' } as unknown as Credential,
})
expect(screen.getByText('common.modelProvider.auth.editModelCredential')).toBeInTheDocument()
})
it('should change title to Add Model when mode is configCustomModel', () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema]
renderModal({ mode: ModelModalModeEnum.configCustomModel })
expect(screen.getByText('common.modelProvider.auth.addModel')).toBeInTheDocument()
})
it('should validate and fail save if form is invalid in configCustomModel mode', async () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema]
mockFormRef1.getFormValues.mockReturnValue({ isCheckValidated: false, values: {} })
renderModal({ mode: ModelModalModeEnum.configCustomModel })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockHandlers.handleSaveCredential).not.toHaveBeenCalled()
})
it('should validate and save new credential and model in configCustomModel mode', async () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema]
const props = renderModal({ mode: ModelModalModeEnum.configCustomModel })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'sk-test' },
name: 'test_auth',
model: 'test',
model_type: ModelTypeEnum.textGeneration,
})
expect(props.onSave).toHaveBeenCalled()
})
})
it('should save credential only in standard configProviderCredential mode', async () => {
const { onSave } = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'sk-test' },
name: 'test_auth',
})
expect(onSave).toHaveBeenCalled()
})
})
it('should save active credential and cancel when picking existing credential in addCustomModelToModelList mode', async () => {
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList, model: { model: 'm1', model_type: ModelTypeEnum.textGeneration } as unknown as CustomModel })
// By default selected is undefined so button clicks form
// Let's not click credential selector, so it evaluates without it. If selectedCredential is undefined, form validation is checked.
mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: false, values: {} })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockHandlers.handleSaveCredential).not.toHaveBeenCalled()
})
it('should save active credential when picking existing credential in addCustomModelToModelList mode', async () => {
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList, model: { model: 'm2', model_type: ModelTypeEnum.textGeneration } as unknown as CustomModel })
// Select existing credential (addNewCredential: true simulates new but we can simulate false if we just hack the mocked state in the component, but it's internal.
// The credential selector sets selectedCredential.
fireEvent.click(screen.getByTestId('credential-selector')) // Sets addNewCredential = true internally, so it proceeds to form save
mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __authorization_name__: 'auth', api: 'key' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api: 'key' },
name: 'auth',
model: 'm2',
model_type: ModelTypeEnum.textGeneration,
})
})
})
it('should open and confirm deletion of credential', () => {
mockState.credentialData = { credentials: { api_key: '123' }, available_credentials: [] }
mockState.formValues = { api_key: '123' } // To trigger isEditMode = true
const credential = { credential_id: 'c1' } as unknown as Credential
renderModal({ credential })
// Open Delete Confirm
fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' }))
expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith(credential, undefined)
// Simulate the dialog appearing and confirming
mockState.deleteCredentialId = 'c1'
renderModal({ credential }) // Re-render logic mock
fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.confirm' })[0])
expect(mockHandlers.handleConfirmDelete).toHaveBeenCalled()
})
it('should bind escape key to cancel', () => {
const props = renderModal()
fireEvent.keyDown(document, { key: 'Escape' })
expect(props.onCancel).toHaveBeenCalled()
})
})