mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
test: remaining header component and increase branch coverage (#33052)
Co-authored-by: sahil <sahil@infocusp.com>
This commit is contained in:
@ -225,5 +225,97 @@ describe('Compliance', () => {
|
|||||||
payload: ACCOUNT_SETTING_TAB.BILLING,
|
payload: ACCOUNT_SETTING_TAB.BILLING,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// isPending branches: spinner visible, disabled class, guard blocks second call
|
||||||
|
it('should show spinner and guard against duplicate download when isPending is true', async () => {
|
||||||
|
// Arrange
|
||||||
|
let resolveDownload: (value: { url: string }) => void
|
||||||
|
vi.mocked(getDocDownloadUrl).mockImplementation(() => new Promise((resolve) => {
|
||||||
|
resolveDownload = resolve
|
||||||
|
}))
|
||||||
|
vi.mocked(useProviderContext).mockReturnValue({
|
||||||
|
...baseProviderContextValue,
|
||||||
|
plan: {
|
||||||
|
...baseProviderContextValue.plan,
|
||||||
|
type: Plan.team,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act
|
||||||
|
openMenuAndRender()
|
||||||
|
const downloadButtons = screen.getAllByText('common.operation.download')
|
||||||
|
fireEvent.click(downloadButtons[0])
|
||||||
|
|
||||||
|
// Assert - btn-disabled class and spinner should appear while mutation is pending
|
||||||
|
await waitFor(() => {
|
||||||
|
const menuItem = screen.getByText('common.compliance.soc2Type1').closest('[role="menuitem"]')
|
||||||
|
expect(menuItem).not.toBeNull()
|
||||||
|
const disabledBtn = menuItem!.querySelector('.cursor-not-allowed')
|
||||||
|
expect(disabledBtn).not.toBeNull()
|
||||||
|
}, { timeout: 10000 })
|
||||||
|
|
||||||
|
// Cleanup: resolve the pending promise
|
||||||
|
resolveDownload!({ url: 'http://example.com/doc.pdf' })
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(downloadUrl).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not call downloadCompliance again while pending', async () => {
|
||||||
|
let resolveDownload: (value: { url: string }) => void
|
||||||
|
vi.mocked(getDocDownloadUrl).mockImplementation(() => new Promise((resolve) => {
|
||||||
|
resolveDownload = resolve
|
||||||
|
}))
|
||||||
|
vi.mocked(useProviderContext).mockReturnValue({
|
||||||
|
...baseProviderContextValue,
|
||||||
|
plan: {
|
||||||
|
...baseProviderContextValue.plan,
|
||||||
|
type: Plan.team,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
openMenuAndRender()
|
||||||
|
const downloadButtons = screen.getAllByText('common.operation.download')
|
||||||
|
|
||||||
|
// First click starts download
|
||||||
|
fireEvent.click(downloadButtons[0])
|
||||||
|
|
||||||
|
// Wait for mutation to start and React to re-render (isPending=true)
|
||||||
|
await waitFor(() => {
|
||||||
|
const menuItem = screen.getByText('common.compliance.soc2Type1').closest('[role="menuitem"]')
|
||||||
|
const el = menuItem!.querySelector('.cursor-not-allowed')
|
||||||
|
expect(el).not.toBeNull()
|
||||||
|
expect(getDocDownloadUrl).toHaveBeenCalledTimes(1)
|
||||||
|
}, { timeout: 10000 })
|
||||||
|
|
||||||
|
// Second click while pending - should be guarded by isPending check
|
||||||
|
fireEvent.click(downloadButtons[0])
|
||||||
|
|
||||||
|
resolveDownload!({ url: 'http://example.com/doc.pdf' })
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(downloadUrl).toHaveBeenCalledTimes(1)
|
||||||
|
}, { timeout: 10000 })
|
||||||
|
// getDocDownloadUrl should still have only been called once
|
||||||
|
expect(getDocDownloadUrl).toHaveBeenCalledTimes(1)
|
||||||
|
}, 20000)
|
||||||
|
|
||||||
|
// canShowUpgradeTooltip=false: enterprise plan has empty tooltip text → no TooltipContent
|
||||||
|
it('should show upgrade badge with empty tooltip for enterprise plan', () => {
|
||||||
|
// Arrange
|
||||||
|
vi.mocked(useProviderContext).mockReturnValue({
|
||||||
|
...baseProviderContextValue,
|
||||||
|
plan: {
|
||||||
|
...baseProviderContextValue.plan,
|
||||||
|
type: Plan.enterprise,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act
|
||||||
|
openMenuAndRender()
|
||||||
|
|
||||||
|
// Assert - enterprise is not in any download list, so upgrade badges should appear
|
||||||
|
// The key branch: upgradeTooltip[Plan.enterprise] = '' → canShowUpgradeTooltip=false
|
||||||
|
expect(screen.getAllByText('billing.upgradeBtn.encourageShort').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -247,6 +247,23 @@ describe('AccountDropdown', () => {
|
|||||||
// Assert
|
// Assert
|
||||||
expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument()
|
expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Compound AND middle-false: IS_CLOUD_EDITION=true but isCurrentWorkspaceOwner=false
|
||||||
|
it('should hide Compliance in Cloud Edition when user is not workspace owner', () => {
|
||||||
|
// Arrange
|
||||||
|
mockConfig.IS_CLOUD_EDITION = true
|
||||||
|
vi.mocked(useAppContext).mockReturnValue({
|
||||||
|
...baseAppContextValue,
|
||||||
|
isCurrentWorkspaceOwner: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act
|
||||||
|
renderWithRouter(<AppSelector />)
|
||||||
|
fireEvent.click(screen.getByRole('button'))
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(screen.queryByText('common.userProfile.compliance')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Actions', () => {
|
describe('Actions', () => {
|
||||||
|
|||||||
@ -36,8 +36,8 @@ vi.mock('@/config', async (importOriginal) => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
IS_CE_EDITION: false,
|
IS_CE_EDITION: false,
|
||||||
get ZENDESK_WIDGET_KEY() { return mockZendeskKey.value },
|
get ZENDESK_WIDGET_KEY() { return mockZendeskKey.value || '' },
|
||||||
get SUPPORT_EMAIL_ADDRESS() { return mockSupportEmailKey.value },
|
get SUPPORT_EMAIL_ADDRESS() { return mockSupportEmailKey.value || '' },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -173,25 +173,18 @@ describe('Support', () => {
|
|||||||
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
|
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show email support if specified in the config', () => {
|
// Optional chain null guard: ZENDESK_WIDGET_KEY is null
|
||||||
|
it('should show Email Support when ZENDESK_WIDGET_KEY is null', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
mockZendeskKey.value = ''
|
mockZendeskKey.value = null as unknown as string
|
||||||
mockSupportEmailKey.value = 'support@example.com'
|
|
||||||
vi.mocked(useProviderContext).mockReturnValue({
|
|
||||||
...baseProviderContextValue,
|
|
||||||
plan: {
|
|
||||||
...baseProviderContextValue.plan,
|
|
||||||
type: Plan.sandbox,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
renderSupport()
|
renderSupport()
|
||||||
fireEvent.click(screen.getByText('common.userProfile.support'))
|
fireEvent.click(screen.getByText('common.userProfile.support'))
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(screen.queryByText('common.userProfile.emailSupport')).toBeInTheDocument()
|
expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument()
|
||||||
expect(screen.getByText('common.userProfile.emailSupport')?.closest('a')?.getAttribute('href')?.startsWith(`mailto:${mockSupportEmailKey.value}`)).toBe(true)
|
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -136,4 +136,32 @@ describe('WorkplaceSelector', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
// find() returns undefined: no workspace with current: true
|
||||||
|
it('should not crash when no workspace has current: true', () => {
|
||||||
|
// Arrange
|
||||||
|
vi.mocked(useWorkspacesContext).mockReturnValue({
|
||||||
|
workspaces: [
|
||||||
|
{ id: '1', name: 'Workspace 1', current: false, plan: 'professional', status: 'normal', created_at: Date.now() },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act & Assert - should not throw
|
||||||
|
expect(() => renderComponent()).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
// name[0]?.toLocaleUpperCase() undefined: workspace with empty name
|
||||||
|
it('should not crash when workspace name is empty string', () => {
|
||||||
|
// Arrange
|
||||||
|
vi.mocked(useWorkspacesContext).mockReturnValue({
|
||||||
|
workspaces: [
|
||||||
|
{ id: '1', name: '', current: true, plan: 'sandbox', status: 'normal', created_at: Date.now() },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act & Assert - should not throw
|
||||||
|
expect(() => renderComponent()).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -388,37 +388,33 @@ describe('DataSourceNotion Component', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('Additional Action Edge Cases', () => {
|
describe('Additional Action Edge Cases', () => {
|
||||||
it('should cover all possible falsy/nullish branches for connection data in handleAuthAgain and useEffect', async () => {
|
it.each([
|
||||||
|
undefined,
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
{ data: undefined },
|
||||||
|
{ data: null },
|
||||||
|
{ data: '' },
|
||||||
|
{ data: 0 },
|
||||||
|
{ data: false },
|
||||||
|
{ data: 'http' },
|
||||||
|
{ data: 'internal' },
|
||||||
|
{ data: 'unknown' },
|
||||||
|
])('should cover connection data branch: %s', async (val) => {
|
||||||
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
|
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
|
||||||
|
/* eslint-disable-next-line ts/no-explicit-any */
|
||||||
|
vi.mocked(useNotionConnection).mockReturnValue({ data: val, isSuccess: true } as any)
|
||||||
|
|
||||||
render(<DataSourceNotion />)
|
render(<DataSourceNotion />)
|
||||||
|
|
||||||
const connectionCases = [
|
// Trigger handleAuthAgain with these values
|
||||||
undefined,
|
const workspaceItem = getWorkspaceItem('Workspace 1')
|
||||||
null,
|
const actionBtn = within(workspaceItem).getByRole('button')
|
||||||
{},
|
fireEvent.click(actionBtn)
|
||||||
{ data: undefined },
|
const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
|
||||||
{ data: null },
|
fireEvent.click(authAgainBtn)
|
||||||
{ data: '' },
|
|
||||||
{ data: 0 },
|
|
||||||
{ data: false },
|
|
||||||
{ data: 'http' },
|
|
||||||
{ data: 'internal' },
|
|
||||||
{ data: 'unknown' },
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const val of connectionCases) {
|
expect(useNotionConnection).toHaveBeenCalled()
|
||||||
/* eslint-disable-next-line ts/no-explicit-any */
|
|
||||||
vi.mocked(useNotionConnection).mockReturnValue({ data: val, isSuccess: true } as any)
|
|
||||||
|
|
||||||
// Trigger handleAuthAgain with these values
|
|
||||||
const workspaceItem = getWorkspaceItem('Workspace 1')
|
|
||||||
const actionBtn = within(workspaceItem).getByRole('button')
|
|
||||||
fireEvent.click(actionBtn)
|
|
||||||
const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
|
|
||||||
fireEvent.click(authAgainBtn)
|
|
||||||
}
|
|
||||||
|
|
||||||
await waitFor(() => expect(useNotionConnection).toHaveBeenCalled())
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -134,5 +134,46 @@ describe('ConfigJinaReaderModal Component', () => {
|
|||||||
resolveSave!({ result: 'success' })
|
resolveSave!({ result: 'success' })
|
||||||
await waitFor(() => expect(mockOnSaved).toHaveBeenCalledTimes(1))
|
await waitFor(() => expect(mockOnSaved).toHaveBeenCalledTimes(1))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should show encryption info and external link in the modal', async () => {
|
||||||
|
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||||
|
|
||||||
|
// Verify PKCS1_OAEP link exists
|
||||||
|
const pkcsLink = screen.getByText('PKCS1_OAEP')
|
||||||
|
expect(pkcsLink.closest('a')).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html')
|
||||||
|
|
||||||
|
// Verify the Jina Reader external link
|
||||||
|
const jinaLink = screen.getByRole('link', { name: /datasetCreation\.jinaReader\.getApiKeyLinkText/i })
|
||||||
|
expect(jinaLink).toHaveAttribute('target', '_blank')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return early when save is clicked while already saving (isSaving guard)', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
// Arrange - a save that never resolves so isSaving stays true
|
||||||
|
let resolveFirst: (value: { result: 'success' }) => void
|
||||||
|
const neverResolves = new Promise<{ result: 'success' }>((resolve) => {
|
||||||
|
resolveFirst = resolve
|
||||||
|
})
|
||||||
|
vi.mocked(createDataSourceApiKeyBinding).mockReturnValue(neverResolves)
|
||||||
|
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
|
||||||
|
|
||||||
|
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder')
|
||||||
|
await user.type(apiKeyInput, 'valid-key')
|
||||||
|
|
||||||
|
const saveBtn = screen.getByRole('button', { name: /common\.operation\.save/i })
|
||||||
|
// First click - starts saving, isSaving becomes true
|
||||||
|
await user.click(saveBtn)
|
||||||
|
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Second click using fireEvent bypasses disabled check - hits isSaving guard
|
||||||
|
const { fireEvent: fe } = await import('@testing-library/react')
|
||||||
|
fe.click(saveBtn)
|
||||||
|
// Still only called once because isSaving=true returns early
|
||||||
|
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
resolveFirst!({ result: 'success' })
|
||||||
|
await waitFor(() => expect(mockOnSaved).toHaveBeenCalled())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -195,4 +195,57 @@ describe('DataSourceWebsite Component', () => {
|
|||||||
expect(removeDataSourceApiKeyBinding).not.toHaveBeenCalled()
|
expect(removeDataSourceApiKeyBinding).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Firecrawl Save Flow', () => {
|
||||||
|
it('should re-fetch sources after saving Firecrawl configuration', async () => {
|
||||||
|
// Arrange
|
||||||
|
await renderAndWait(DataSourceProvider.fireCrawl)
|
||||||
|
fireEvent.click(screen.getByText('common.dataSource.configure'))
|
||||||
|
expect(screen.getByText('datasetCreation.firecrawl.configFirecrawl')).toBeInTheDocument()
|
||||||
|
vi.mocked(fetchDataSources).mockClear()
|
||||||
|
|
||||||
|
// Act - fill in required API key field and save
|
||||||
|
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder')
|
||||||
|
fireEvent.change(apiKeyInput, { target: { value: 'test-key' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchDataSources).toHaveBeenCalled()
|
||||||
|
expect(screen.queryByText('datasetCreation.firecrawl.configFirecrawl')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cancel Flow', () => {
|
||||||
|
it('should close watercrawl modal when cancel is clicked', async () => {
|
||||||
|
// Arrange
|
||||||
|
await renderAndWait(DataSourceProvider.waterCrawl)
|
||||||
|
fireEvent.click(screen.getByText('common.dataSource.configure'))
|
||||||
|
expect(screen.getByText('datasetCreation.watercrawl.configWatercrawl')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
|
||||||
|
|
||||||
|
// Assert - modal closed
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('datasetCreation.watercrawl.configWatercrawl')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should close jina reader modal when cancel is clicked', async () => {
|
||||||
|
// Arrange
|
||||||
|
await renderAndWait(DataSourceProvider.jinaReader)
|
||||||
|
fireEvent.click(screen.getByText('common.dataSource.configure'))
|
||||||
|
expect(screen.getByText('datasetCreation.jinaReader.configJinaReader')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
|
||||||
|
|
||||||
|
// Assert - modal closed
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('datasetCreation.jinaReader.configJinaReader')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
import Operate from './Operate'
|
import Operate from './Operate'
|
||||||
|
|
||||||
describe('Operate', () => {
|
describe('Operate', () => {
|
||||||
it('renders cancel and save when editing', () => {
|
it('should render cancel and save when editing is open', () => {
|
||||||
render(
|
render(
|
||||||
<Operate
|
<Operate
|
||||||
isOpen
|
isOpen
|
||||||
@ -18,7 +19,7 @@ describe('Operate', () => {
|
|||||||
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
|
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows add key prompt when closed', () => {
|
it('should show add-key prompt when closed', () => {
|
||||||
render(
|
render(
|
||||||
<Operate
|
<Operate
|
||||||
isOpen={false}
|
isOpen={false}
|
||||||
@ -33,7 +34,7 @@ describe('Operate', () => {
|
|||||||
expect(screen.getByText('common.provider.addKey')).toBeInTheDocument()
|
expect(screen.getByText('common.provider.addKey')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows invalid state indicator and edit prompt when status is fail', () => {
|
it('should show invalid state and edit prompt when status is fail', () => {
|
||||||
render(
|
render(
|
||||||
<Operate
|
<Operate
|
||||||
isOpen={false}
|
isOpen={false}
|
||||||
@ -49,7 +50,7 @@ describe('Operate', () => {
|
|||||||
expect(screen.getByText('common.provider.editKey')).toBeInTheDocument()
|
expect(screen.getByText('common.provider.editKey')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows edit prompt without error text when status is success', () => {
|
it('should show edit prompt without error text when status is success', () => {
|
||||||
render(
|
render(
|
||||||
<Operate
|
<Operate
|
||||||
isOpen={false}
|
isOpen={false}
|
||||||
@ -65,11 +66,30 @@ describe('Operate', () => {
|
|||||||
expect(screen.queryByText('common.provider.invalidApiKey')).toBeNull()
|
expect(screen.queryByText('common.provider.invalidApiKey')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows no actions for unsupported status', () => {
|
it('should not call onAdd when disabled', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onAdd = vi.fn()
|
||||||
render(
|
render(
|
||||||
<Operate
|
<Operate
|
||||||
isOpen={false}
|
isOpen={false}
|
||||||
status={'unknown' as never}
|
status="add"
|
||||||
|
disabled
|
||||||
|
onAdd={onAdd}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
await user.click(screen.getByText('common.provider.addKey'))
|
||||||
|
expect(onAdd).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show no actions when status is unsupported', () => {
|
||||||
|
render(
|
||||||
|
<Operate
|
||||||
|
isOpen={false}
|
||||||
|
// @ts-expect-error intentional invalid status for runtime fallback coverage
|
||||||
|
status="unknown"
|
||||||
onAdd={vi.fn()}
|
onAdd={vi.fn()}
|
||||||
onCancel={vi.fn()}
|
onCancel={vi.fn()}
|
||||||
onEdit={vi.fn()}
|
onEdit={vi.fn()}
|
||||||
|
|||||||
@ -267,6 +267,99 @@ describe('MembersPage', () => {
|
|||||||
expect(screen.getByText(/plansCommon\.unlimited/i)).toBeInTheDocument()
|
expect(screen.getByText(/plansCommon\.unlimited/i)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should show non-billing member format for team plan even when billing is enabled', () => {
|
||||||
|
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
|
||||||
|
enableBilling: true,
|
||||||
|
plan: {
|
||||||
|
type: Plan.team,
|
||||||
|
total: { teamMembers: 50 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'],
|
||||||
|
} as unknown as ReturnType<typeof useProviderContext>['plan'],
|
||||||
|
}))
|
||||||
|
|
||||||
|
render(<MembersPage />)
|
||||||
|
|
||||||
|
// Plan.team is an unlimited member plan → isNotUnlimitedMemberPlan=false → non-billing layout
|
||||||
|
expect(screen.getByText(/plansCommon\.memberAfter/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show invite button when user is manager but not owner', () => {
|
||||||
|
vi.mocked(useAppContext).mockReturnValue({
|
||||||
|
userProfile: { email: 'admin@example.com' },
|
||||||
|
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
|
||||||
|
isCurrentWorkspaceOwner: false,
|
||||||
|
isCurrentWorkspaceManager: true,
|
||||||
|
} as unknown as AppContextValue)
|
||||||
|
|
||||||
|
render(<MembersPage />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /invite/i })).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use created_at as fallback when last_active_at is empty', () => {
|
||||||
|
const memberNoLastActive: Member = {
|
||||||
|
...mockAccounts[1],
|
||||||
|
last_active_at: '',
|
||||||
|
created_at: '1700000000',
|
||||||
|
}
|
||||||
|
vi.mocked(useMembers).mockReturnValue({
|
||||||
|
data: { accounts: [memberNoLastActive] },
|
||||||
|
refetch: mockRefetch,
|
||||||
|
} as unknown as ReturnType<typeof useMembers>)
|
||||||
|
|
||||||
|
render(<MembersPage />)
|
||||||
|
|
||||||
|
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1700000000000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show plural s when only one account in billing layout', () => {
|
||||||
|
vi.mocked(useMembers).mockReturnValue({
|
||||||
|
data: { accounts: [mockAccounts[0]] },
|
||||||
|
refetch: mockRefetch,
|
||||||
|
} as unknown as ReturnType<typeof useMembers>)
|
||||||
|
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
|
||||||
|
enableBilling: true,
|
||||||
|
plan: {
|
||||||
|
type: Plan.sandbox,
|
||||||
|
total: { teamMembers: 5 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'],
|
||||||
|
} as unknown as ReturnType<typeof useProviderContext>['plan'],
|
||||||
|
}))
|
||||||
|
|
||||||
|
render(<MembersPage />)
|
||||||
|
|
||||||
|
expect(screen.getByText(/plansCommon\.member/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show plural s when only one account in non-billing layout', () => {
|
||||||
|
vi.mocked(useMembers).mockReturnValue({
|
||||||
|
data: { accounts: [mockAccounts[0]] },
|
||||||
|
refetch: mockRefetch,
|
||||||
|
} as unknown as ReturnType<typeof useMembers>)
|
||||||
|
|
||||||
|
render(<MembersPage />)
|
||||||
|
|
||||||
|
expect(screen.getByText(/plansCommon\.memberAfter/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show normal role as fallback for unknown role', () => {
|
||||||
|
vi.mocked(useAppContext).mockReturnValue({
|
||||||
|
userProfile: { email: 'admin@example.com' },
|
||||||
|
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
|
||||||
|
isCurrentWorkspaceOwner: false,
|
||||||
|
isCurrentWorkspaceManager: false,
|
||||||
|
} as unknown as AppContextValue)
|
||||||
|
vi.mocked(useMembers).mockReturnValue({
|
||||||
|
data: { accounts: [{ ...mockAccounts[1], role: 'unknown_role' as Member['role'] }] },
|
||||||
|
refetch: mockRefetch,
|
||||||
|
} as unknown as ReturnType<typeof useMembers>)
|
||||||
|
|
||||||
|
render(<MembersPage />)
|
||||||
|
|
||||||
|
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('should show upgrade button when member limit is full', () => {
|
it('should show upgrade button when member limit is full', () => {
|
||||||
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
|
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
|
||||||
enableBilling: true,
|
enableBilling: true,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { InvitationResponse } from '@/models/common'
|
import type { InvitationResponse } from '@/models/common'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { vi } from 'vitest'
|
import { vi } from 'vitest'
|
||||||
import { ToastContext } from '@/app/components/base/toast/context'
|
import { ToastContext } from '@/app/components/base/toast/context'
|
||||||
@ -171,6 +171,66 @@ describe('InviteModal', () => {
|
|||||||
expect(screen.queryByText('user@example.com')).not.toBeInTheDocument()
|
expect(screen.queryByText('user@example.com')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should show unlimited label when workspace member limit is zero', async () => {
|
||||||
|
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||||
|
licenseLimit: { workspace_members: { size: 5, limit: 0 } },
|
||||||
|
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||||
|
} as unknown as Parameters<typeof selector>[0]))
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
expect(await screen.findByText(/license\.unlimited/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize usedSize to zero when workspace_members.size is null', async () => {
|
||||||
|
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||||
|
licenseLimit: { workspace_members: { size: null, limit: 10 } },
|
||||||
|
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||||
|
} as unknown as Parameters<typeof selector>[0]))
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
// usedSize starts at 0 (via ?? 0 fallback), no emails added → counter shows 0
|
||||||
|
expect(await screen.findByText('0')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not call onSend when invite result is not success', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
vi.mocked(inviteMember).mockResolvedValue({
|
||||||
|
result: 'error',
|
||||||
|
invitation_results: [],
|
||||||
|
} as unknown as InvitationResponse)
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
await user.type(screen.getByTestId('mock-email-input'), 'user@example.com')
|
||||||
|
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(inviteMember).toHaveBeenCalled()
|
||||||
|
expect(mockOnSend).not.toHaveBeenCalled()
|
||||||
|
expect(mockOnCancel).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show destructive text color when used size exceeds limit', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||||
|
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
|
||||||
|
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||||
|
} as unknown as Parameters<typeof selector>[0]))
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
const input = screen.getByTestId('mock-email-input')
|
||||||
|
await user.type(input, 'user@example.com')
|
||||||
|
|
||||||
|
// usedSize = 10 + 1 = 11 > limit 10 → destructive color
|
||||||
|
const counter = screen.getByText('11')
|
||||||
|
expect(counter.closest('div')).toHaveClass('text-text-destructive')
|
||||||
|
})
|
||||||
|
|
||||||
it('should not submit if already submitting', async () => {
|
it('should not submit if already submitting', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
let resolveInvite: (value: InvitationResponse) => void
|
let resolveInvite: (value: InvitationResponse) => void
|
||||||
@ -202,4 +262,72 @@ describe('InviteModal', () => {
|
|||||||
expect(mockOnCancel).toHaveBeenCalled()
|
expect(mockOnCancel).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should show destructive color and disable send button when limit is exactly met with one email', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
// size=10, limit=10 - adding 1 email makes usedSize=11 > limit=10
|
||||||
|
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||||
|
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
|
||||||
|
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||||
|
} as unknown as Parameters<typeof selector>[0]))
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
const input = screen.getByTestId('mock-email-input')
|
||||||
|
await user.type(input, 'user@example.com')
|
||||||
|
|
||||||
|
// isLimitExceeded=true → button is disabled, cannot submit
|
||||||
|
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
|
||||||
|
expect(sendBtn).toBeDisabled()
|
||||||
|
expect(inviteMember).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hit isSubmitting guard inside handleSend when button is force-clicked during submission', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
let resolveInvite: (value: InvitationResponse) => void
|
||||||
|
const invitePromise = new Promise<InvitationResponse>((resolve) => {
|
||||||
|
resolveInvite = resolve
|
||||||
|
})
|
||||||
|
vi.mocked(inviteMember).mockReturnValue(invitePromise)
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
const input = screen.getByTestId('mock-email-input')
|
||||||
|
await user.type(input, 'user@example.com')
|
||||||
|
|
||||||
|
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
|
||||||
|
|
||||||
|
// First click starts submission
|
||||||
|
await user.click(sendBtn)
|
||||||
|
expect(inviteMember).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Force-click bypasses disabled attribute → hits isSubmitting guard in handleSend
|
||||||
|
fireEvent.click(sendBtn)
|
||||||
|
expect(inviteMember).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
resolveInvite!({ result: 'success', invitation_results: [] })
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnCancel).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show error text color when isLimited is false even with many emails', async () => {
|
||||||
|
// size=0, limit=0 → isLimited=false, usedSize=emails.length
|
||||||
|
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||||
|
licenseLimit: { workspace_members: { size: 0, limit: 0 } },
|
||||||
|
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||||
|
} as unknown as Parameters<typeof selector>[0]))
|
||||||
|
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
const input = screen.getByTestId('mock-email-input')
|
||||||
|
await user.type(input, 'user@example.com')
|
||||||
|
|
||||||
|
// isLimited=false → no destructive color
|
||||||
|
const counter = screen.getByText('1')
|
||||||
|
expect(counter.closest('div')).not.toHaveClass('text-text-destructive')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,8 +2,12 @@ import type { InvitationResult } from '@/models/common'
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import InvitedModal from './index'
|
import InvitedModal from './index'
|
||||||
|
|
||||||
|
const mockConfigState = vi.hoisted(() => ({ isCeEdition: true }))
|
||||||
|
|
||||||
vi.mock('@/config', () => ({
|
vi.mock('@/config', () => ({
|
||||||
IS_CE_EDITION: true,
|
get IS_CE_EDITION() {
|
||||||
|
return mockConfigState.isCeEdition
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('InvitedModal', () => {
|
describe('InvitedModal', () => {
|
||||||
@ -13,6 +17,11 @@ describe('InvitedModal', () => {
|
|||||||
{ email: 'failed@example.com', status: 'failed', message: 'Error msg' },
|
{ email: 'failed@example.com', status: 'failed', message: 'Error msg' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockConfigState.isCeEdition = true
|
||||||
|
})
|
||||||
|
|
||||||
it('should show success and failed invitation sections', async () => {
|
it('should show success and failed invitation sections', async () => {
|
||||||
render(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />)
|
render(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />)
|
||||||
|
|
||||||
@ -21,4 +30,59 @@ describe('InvitedModal', () => {
|
|||||||
expect(screen.getByText('http://invite.com/1')).toBeInTheDocument()
|
expect(screen.getByText('http://invite.com/1')).toBeInTheDocument()
|
||||||
expect(screen.getByText('failed@example.com')).toBeInTheDocument()
|
expect(screen.getByText('failed@example.com')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should hide invitation link section when there are no successes', () => {
|
||||||
|
const failedOnly: InvitationResult[] = [
|
||||||
|
{ email: 'fail@example.com', status: 'failed', message: 'Quota exceeded' },
|
||||||
|
]
|
||||||
|
|
||||||
|
render(<InvitedModal invitationResults={failedOnly} onCancel={mockOnCancel} />)
|
||||||
|
|
||||||
|
expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/members\.failedInvitationEmails/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide failed section when there are only successes', () => {
|
||||||
|
const successOnly: InvitationResult[] = [
|
||||||
|
{ email: 'ok@example.com', status: 'success', url: 'http://invite.com/2' },
|
||||||
|
]
|
||||||
|
|
||||||
|
render(<InvitedModal invitationResults={successOnly} onCancel={mockOnCancel} />)
|
||||||
|
|
||||||
|
expect(screen.getByText(/members\.invitationLink/i)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/members\.failedInvitationEmails/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide both sections when results are empty', () => {
|
||||||
|
render(<InvitedModal invitationResults={[]} onCancel={mockOnCancel} />)
|
||||||
|
|
||||||
|
expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/members\.failedInvitationEmails/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('InvitedModal (non-CE edition)', () => {
|
||||||
|
const mockOnCancel = vi.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockConfigState.isCeEdition = false
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockConfigState.isCeEdition = true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render invitationSentTip without CE edition content when IS_CE_EDITION is false', async () => {
|
||||||
|
const results: InvitationResult[] = [
|
||||||
|
{ email: 'success@example.com', status: 'success', url: 'http://invite.com/1' },
|
||||||
|
]
|
||||||
|
|
||||||
|
render(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />)
|
||||||
|
|
||||||
|
// The !IS_CE_EDITION branch - should show the tip text
|
||||||
|
expect(await screen.findByText(/members\.invitationSentTip/i)).toBeInTheDocument()
|
||||||
|
// CE-only content should not be shown
|
||||||
|
expect(screen.queryByText(/members\.invitationLink/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -49,13 +49,13 @@ describe('Operation', () => {
|
|||||||
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: false })
|
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the current role label', () => {
|
it('should render the current role label when member has editor role', () => {
|
||||||
renderOperation()
|
renderOperation()
|
||||||
|
|
||||||
expect(screen.getByText('common.members.editor')).toBeInTheDocument()
|
expect(screen.getByText('common.members.editor')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows dataset operator option when the feature flag is enabled', async () => {
|
it('should show dataset operator option when feature flag is enabled', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
|
|
||||||
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
|
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
|
||||||
@ -66,7 +66,7 @@ describe('Operation', () => {
|
|||||||
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
|
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows owner-allowed role options for admin operators', async () => {
|
it('should show owner-allowed role options when operator role is admin', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
|
|
||||||
renderOperation({}, 'admin')
|
renderOperation({}, 'admin')
|
||||||
@ -77,7 +77,7 @@ describe('Operation', () => {
|
|||||||
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
|
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not show role options for unsupported operators', async () => {
|
it('should not show role options when operator role is unsupported', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
|
|
||||||
renderOperation({}, 'normal')
|
renderOperation({}, 'normal')
|
||||||
@ -88,7 +88,7 @@ describe('Operation', () => {
|
|||||||
expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument()
|
expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls updateMemberRole and onOperate when selecting another role', async () => {
|
it('should call updateMemberRole and onOperate when selecting another role', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const onOperate = vi.fn()
|
const onOperate = vi.fn()
|
||||||
renderOperation({}, 'owner', onOperate)
|
renderOperation({}, 'owner', onOperate)
|
||||||
@ -102,7 +102,24 @@ describe('Operation', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls deleteMemberOrCancelInvitation when removing the member', async () => {
|
it('should show dataset operator option when operator is admin and feature flag is enabled', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
|
||||||
|
renderOperation({}, 'admin')
|
||||||
|
|
||||||
|
await user.click(screen.getByText('common.members.editor'))
|
||||||
|
|
||||||
|
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fall back to normal role label when member role is unknown', () => {
|
||||||
|
renderOperation({ role: 'unknown_role' as Member['role'] })
|
||||||
|
|
||||||
|
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call deleteMemberOrCancelInvitation when removing the member', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const onOperate = vi.fn()
|
const onOperate = vi.fn()
|
||||||
renderOperation({}, 'owner', onOperate)
|
renderOperation({}, 'owner', onOperate)
|
||||||
|
|||||||
@ -13,11 +13,6 @@ vi.mock('@/context/app-context')
|
|||||||
vi.mock('@/service/common')
|
vi.mock('@/service/common')
|
||||||
vi.mock('@/service/use-common')
|
vi.mock('@/service/use-common')
|
||||||
|
|
||||||
// Mock Modal directly to avoid transition/portal issues in tests
|
|
||||||
vi.mock('@/app/components/base/modal', () => ({
|
|
||||||
default: ({ children, isShow }: { children: React.ReactNode, isShow: boolean }) => isShow ? <div data-testid="mock-modal">{children}</div> : null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('./member-selector', () => ({
|
vi.mock('./member-selector', () => ({
|
||||||
default: ({ onSelect }: { onSelect: (id: string) => void }) => (
|
default: ({ onSelect }: { onSelect: (id: string) => void }) => (
|
||||||
<button onClick={() => onSelect('new-owner-id')}>Select member</button>
|
<button onClick={() => onSelect('new-owner-id')}>Select member</button>
|
||||||
@ -40,11 +35,13 @@ describe('TransferOwnershipModal', () => {
|
|||||||
data: { accounts: [] },
|
data: { accounts: [] },
|
||||||
} as unknown as ReturnType<typeof useMembers>)
|
} as unknown as ReturnType<typeof useMembers>)
|
||||||
|
|
||||||
// Fix Location stubbing for reload
|
// Stub globalThis.location.reload (component calls globalThis.location.reload())
|
||||||
const mockReload = vi.fn()
|
const mockReload = vi.fn()
|
||||||
vi.stubGlobal('location', {
|
vi.stubGlobal('location', {
|
||||||
...window.location,
|
|
||||||
reload: mockReload,
|
reload: mockReload,
|
||||||
|
href: '',
|
||||||
|
assign: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
} as unknown as Location)
|
} as unknown as Location)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -105,8 +102,8 @@ describe('TransferOwnershipModal', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(ownershipTransfer).toHaveBeenCalledWith('new-owner-id', { token: 'final-token' })
|
expect(ownershipTransfer).toHaveBeenCalledWith('new-owner-id', { token: 'final-token' })
|
||||||
expect(window.location.reload).toHaveBeenCalled()
|
expect(window.location.reload).toHaveBeenCalled()
|
||||||
})
|
}, { timeout: 10000 })
|
||||||
})
|
}, 15000)
|
||||||
|
|
||||||
it('should handle timer countdown and resend', async () => {
|
it('should handle timer countdown and resend', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
@ -202,6 +199,70 @@ describe('TransferOwnershipModal', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should handle sendOwnerEmail returning null data', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
vi.mocked(sendOwnerEmail).mockResolvedValue({
|
||||||
|
data: null,
|
||||||
|
result: 'success',
|
||||||
|
} as unknown as Awaited<ReturnType<typeof sendOwnerEmail>>)
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
await user.click(screen.getByTestId('transfer-modal-send-code'))
|
||||||
|
|
||||||
|
// Should advance to verify step even with null data
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/members\.transferModal\.verifyEmail/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show fallback error prefix when sendOwnerEmail throws null', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
vi.mocked(sendOwnerEmail).mockRejectedValue(null)
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
await user.click(screen.getByTestId('transfer-modal-send-code'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'error',
|
||||||
|
message: expect.stringContaining('Error sending verification code:'),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show fallback error prefix when verifyOwnerEmail throws null', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockEmailVerification()
|
||||||
|
vi.mocked(verifyOwnerEmail).mockRejectedValue(null)
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
await goToTransferStep(user)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'error',
|
||||||
|
message: expect.stringContaining('Error verifying email:'),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show fallback error prefix when ownershipTransfer throws null', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockEmailVerification()
|
||||||
|
vi.mocked(ownershipTransfer).mockRejectedValue(null)
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
await goToTransferStep(user)
|
||||||
|
await selectNewOwnerAndSubmit(user)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'error',
|
||||||
|
message: expect.stringContaining('Error ownership transfer:'),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('should close when close button is clicked', async () => {
|
it('should close when close button is clicked', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderModal()
|
renderModal()
|
||||||
|
|||||||
@ -71,9 +71,80 @@ describe('MemberSelector', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should filter list by email when name does not match', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<MemberSelector onSelect={mockOnSelect} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('member-selector-trigger'))
|
||||||
|
await user.type(screen.getByTestId('member-selector-search'), 'john@')
|
||||||
|
|
||||||
|
const items = screen.getAllByTestId('member-selector-item')
|
||||||
|
expect(items).toHaveLength(1)
|
||||||
|
expect(screen.getByText('John Doe')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show placeholder when value does not match any account', () => {
|
||||||
|
render(<MemberSelector value="nonexistent-id" onSelect={mockOnSelect} />)
|
||||||
|
|
||||||
|
expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('should handle missing data gracefully', () => {
|
it('should handle missing data gracefully', () => {
|
||||||
vi.mocked(useMembers).mockReturnValue({ data: undefined } as unknown as ReturnType<typeof useMembers>)
|
vi.mocked(useMembers).mockReturnValue({ data: undefined } as unknown as ReturnType<typeof useMembers>)
|
||||||
render(<MemberSelector onSelect={mockOnSelect} />)
|
render(<MemberSelector onSelect={mockOnSelect} />)
|
||||||
expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument()
|
expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should filter by email when account name is empty', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
vi.mocked(useMembers).mockReturnValue({
|
||||||
|
data: { accounts: [...mockAccounts, { id: '4', name: '', email: 'noname@example.com', avatar_url: '' }] },
|
||||||
|
} as unknown as ReturnType<typeof useMembers>)
|
||||||
|
render(<MemberSelector onSelect={mockOnSelect} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('member-selector-trigger'))
|
||||||
|
await user.type(screen.getByTestId('member-selector-search'), 'noname@')
|
||||||
|
|
||||||
|
const items = screen.getAllByTestId('member-selector-item')
|
||||||
|
expect(items).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply hover background class when dropdown is open', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<MemberSelector onSelect={mockOnSelect} />)
|
||||||
|
|
||||||
|
const trigger = screen.getByTestId('member-selector-trigger')
|
||||||
|
await user.click(trigger)
|
||||||
|
|
||||||
|
expect(trigger).toHaveClass('bg-state-base-hover-alt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not match account when neither name nor email contains search value', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<MemberSelector onSelect={mockOnSelect} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('member-selector-trigger'))
|
||||||
|
await user.type(screen.getByTestId('member-selector-search'), 'xyz-no-match-xyz')
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('member-selector-item')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fall back to empty string for account with undefined email when searching', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
vi.mocked(useMembers).mockReturnValue({
|
||||||
|
data: {
|
||||||
|
accounts: [
|
||||||
|
{ id: '1', name: 'John', email: undefined as unknown as string, avatar_url: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as unknown as ReturnType<typeof useMembers>)
|
||||||
|
render(<MemberSelector onSelect={mockOnSelect} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId('member-selector-trigger'))
|
||||||
|
await user.type(screen.getByTestId('member-selector-search'), 'john')
|
||||||
|
|
||||||
|
const items = screen.getAllByTestId('member-selector-item')
|
||||||
|
expect(items).toHaveLength(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -433,6 +433,55 @@ describe('hooks', () => {
|
|||||||
|
|
||||||
expect(result.current.credentials).toBeUndefined()
|
expect(result.current.credentials).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not call invalidateQueries when neither predefined nor custom is enabled', () => {
|
||||||
|
const invalidateQueries = vi.fn()
|
||||||
|
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||||
|
; (useQuery as Mock).mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isPending: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Both predefinedEnabled and customEnabled are false (no credentialId)
|
||||||
|
const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
|
||||||
|
'openai',
|
||||||
|
ConfigurationMethodEnum.predefinedModel,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.mutate()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(invalidateQueries).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should build URL without credentialId when not provided in predefined queryFn', async () => {
|
||||||
|
// Trigger the queryFn when credentialId is undefined but predefinedEnabled is true
|
||||||
|
; (useQuery as Mock).mockReturnValue({
|
||||||
|
data: { credentials: { api_key: 'k' } },
|
||||||
|
isPending: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result: _result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
|
||||||
|
'openai',
|
||||||
|
ConfigurationMethodEnum.predefinedModel,
|
||||||
|
true,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
))
|
||||||
|
|
||||||
|
// Find and invoke the predefined queryFn
|
||||||
|
const queryCall = (useQuery as Mock).mock.calls.find(
|
||||||
|
call => call[0].queryKey?.[1] === 'credentials',
|
||||||
|
)
|
||||||
|
if (queryCall) {
|
||||||
|
await queryCall[0].queryFn()
|
||||||
|
expect(fetchModelProviderCredentials).toHaveBeenCalled()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('useModelList', () => {
|
describe('useModelList', () => {
|
||||||
@ -1111,6 +1160,26 @@ describe('hooks', () => {
|
|||||||
expect(result.current.plugins![0].plugin_id).toBe('plugin1')
|
expect(result.current.plugins![0].plugin_id).toBe('plugin1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should deduplicate plugins that exist in both collections and regular plugins', () => {
|
||||||
|
const duplicatePlugin = { plugin_id: 'shared-plugin', type: 'plugin' }
|
||||||
|
|
||||||
|
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
|
||||||
|
plugins: [duplicatePlugin],
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
; (useMarketplacePlugins as Mock).mockReturnValue({
|
||||||
|
plugins: [{ ...duplicatePlugin }, { plugin_id: 'unique-plugin', type: 'plugin' }],
|
||||||
|
queryPlugins: vi.fn(),
|
||||||
|
queryPluginsWithDebounced: vi.fn(),
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
|
||||||
|
|
||||||
|
expect(result.current.plugins).toHaveLength(2)
|
||||||
|
expect(result.current.plugins!.filter(p => p.plugin_id === 'shared-plugin')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('should handle loading states', () => {
|
it('should handle loading states', () => {
|
||||||
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
|
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
|
||||||
plugins: [],
|
plugins: [],
|
||||||
@ -1127,6 +1196,45 @@ describe('hooks', () => {
|
|||||||
|
|
||||||
expect(result.current.isLoading).toBe(true)
|
expect(result.current.isLoading).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not crash when plugins is undefined', () => {
|
||||||
|
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
|
||||||
|
plugins: [],
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
; (useMarketplacePlugins as Mock).mockReturnValue({
|
||||||
|
plugins: undefined,
|
||||||
|
queryPlugins: vi.fn(),
|
||||||
|
queryPluginsWithDebounced: vi.fn(),
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
|
||||||
|
|
||||||
|
expect(result.current.plugins).toBeDefined()
|
||||||
|
expect(result.current.isLoading).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return search plugins (not allPlugins) when searchText is truthy', () => {
|
||||||
|
const searchPlugins = [{ plugin_id: 'search-result', type: 'plugin' }]
|
||||||
|
const collectionPlugins = [{ plugin_id: 'collection-only', type: 'plugin' }]
|
||||||
|
|
||||||
|
; (useMarketplacePluginsByCollectionId as Mock).mockReturnValue({
|
||||||
|
plugins: collectionPlugins,
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
; (useMarketplacePlugins as Mock).mockReturnValue({
|
||||||
|
plugins: searchPlugins,
|
||||||
|
queryPlugins: vi.fn(),
|
||||||
|
queryPluginsWithDebounced: vi.fn(),
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMarketplaceAllPlugins([], 'openai'))
|
||||||
|
|
||||||
|
expect(result.current.plugins).toEqual(searchPlugins)
|
||||||
|
expect(result.current.plugins?.some(p => p.plugin_id === 'collection-only')).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('useRefreshModel', () => {
|
describe('useRefreshModel', () => {
|
||||||
@ -1234,6 +1342,35 @@ describe('hooks', () => {
|
|||||||
expect(emit).not.toHaveBeenCalled()
|
expect(emit).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should emit event and invalidate all supported model types when __model_type is undefined', () => {
|
||||||
|
const invalidateQueries = vi.fn()
|
||||||
|
const emit = vi.fn()
|
||||||
|
|
||||||
|
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||||
|
; (useEventEmitterContextContext as Mock).mockReturnValue({
|
||||||
|
eventEmitter: { emit },
|
||||||
|
})
|
||||||
|
|
||||||
|
const provider = createMockProvider()
|
||||||
|
const customFields = { __model_name: 'my-model', __model_type: undefined } as unknown as CustomConfigurationModelFixedFields
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRefreshModel())
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleRefreshModel(provider, customFields, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(emit).toHaveBeenCalledWith({
|
||||||
|
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
|
||||||
|
payload: 'openai',
|
||||||
|
})
|
||||||
|
// When __model_type is undefined, all supported model types are invalidated
|
||||||
|
const modelListCalls = invalidateQueries.mock.calls.filter(
|
||||||
|
call => call[0]?.queryKey?.[0] === 'model-list',
|
||||||
|
)
|
||||||
|
expect(modelListCalls).toHaveLength(provider.supported_model_types.length)
|
||||||
|
})
|
||||||
|
|
||||||
it('should handle provider with single model type', () => {
|
it('should handle provider with single model type', () => {
|
||||||
const invalidateQueries = vi.fn()
|
const invalidateQueries = vi.fn()
|
||||||
|
|
||||||
|
|||||||
@ -60,7 +60,15 @@ vi.mock('@/context/provider-context', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const mockDefaultModelState = {
|
type MockDefaultModelData = {
|
||||||
|
model: string
|
||||||
|
provider?: { provider: string }
|
||||||
|
} | null
|
||||||
|
|
||||||
|
const mockDefaultModelState: {
|
||||||
|
data: MockDefaultModelData
|
||||||
|
isLoading: boolean
|
||||||
|
} = {
|
||||||
data: null,
|
data: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
}
|
}
|
||||||
@ -196,4 +204,129 @@ describe('ModelProviderPage', () => {
|
|||||||
])
|
])
|
||||||
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
|
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should show not configured alert when all default models are absent', () => {
|
||||||
|
mockDefaultModelState.data = null
|
||||||
|
mockDefaultModelState.isLoading = false
|
||||||
|
|
||||||
|
render(<ModelProviderPage searchText="" />)
|
||||||
|
|
||||||
|
expect(screen.getByText('common.modelProvider.notConfigured')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show not configured alert when default model is loading', () => {
|
||||||
|
mockDefaultModelState.data = null
|
||||||
|
mockDefaultModelState.isLoading = true
|
||||||
|
|
||||||
|
render(<ModelProviderPage searchText="" />)
|
||||||
|
|
||||||
|
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter providers by label text', () => {
|
||||||
|
render(<ModelProviderPage searchText="OpenAI" />)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(600)
|
||||||
|
})
|
||||||
|
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('anthropic')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should classify system-enabled providers with matching quota as configured', () => {
|
||||||
|
mockProviders.splice(0, mockProviders.length, {
|
||||||
|
provider: 'sys-provider',
|
||||||
|
label: { en_US: 'System Provider' },
|
||||||
|
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||||
|
system_configuration: {
|
||||||
|
enabled: true,
|
||||||
|
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||||
|
quota_configurations: [mockQuotaConfig],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelProviderPage searchText="" />)
|
||||||
|
|
||||||
|
expect(screen.getByText('sys-provider')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should classify system-enabled provider with no matching quota as not configured', () => {
|
||||||
|
mockProviders.splice(0, mockProviders.length, {
|
||||||
|
provider: 'sys-no-quota',
|
||||||
|
label: { en_US: 'System No Quota' },
|
||||||
|
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||||
|
system_configuration: {
|
||||||
|
enabled: true,
|
||||||
|
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||||
|
quota_configurations: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelProviderPage searchText="" />)
|
||||||
|
|
||||||
|
expect(screen.getByText('sys-no-quota')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should preserve order of two non-fixed providers (sort returns 0)', () => {
|
||||||
|
mockProviders.splice(0, mockProviders.length, {
|
||||||
|
provider: 'alpha-provider',
|
||||||
|
label: { en_US: 'Alpha Provider' },
|
||||||
|
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||||
|
system_configuration: {
|
||||||
|
enabled: false,
|
||||||
|
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||||
|
quota_configurations: [mockQuotaConfig],
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
provider: 'beta-provider',
|
||||||
|
label: { en_US: 'Beta Provider' },
|
||||||
|
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||||
|
system_configuration: {
|
||||||
|
enabled: false,
|
||||||
|
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||||
|
quota_configurations: [mockQuotaConfig],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelProviderPage searchText="" />)
|
||||||
|
|
||||||
|
const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent)
|
||||||
|
expect(renderedProviders).toEqual(['alpha-provider', 'beta-provider'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show not configured alert when shared default model mock has data', () => {
|
||||||
|
mockDefaultModelState.data = { model: 'embed-model' }
|
||||||
|
|
||||||
|
render(<ModelProviderPage searchText="" />)
|
||||||
|
|
||||||
|
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show not configured alert when rerankDefaultModel has data', () => {
|
||||||
|
mockDefaultModelState.data = { model: 'rerank-model', provider: { provider: 'cohere' } }
|
||||||
|
mockDefaultModelState.isLoading = false
|
||||||
|
|
||||||
|
render(<ModelProviderPage searchText="" />)
|
||||||
|
|
||||||
|
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show not configured alert when ttsDefaultModel has data', () => {
|
||||||
|
mockDefaultModelState.data = { model: 'tts-model', provider: { provider: 'openai' } }
|
||||||
|
mockDefaultModelState.isLoading = false
|
||||||
|
|
||||||
|
render(<ModelProviderPage searchText="" />)
|
||||||
|
|
||||||
|
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show not configured alert when speech2textDefaultModel has data', () => {
|
||||||
|
mockDefaultModelState.data = { model: 'whisper', provider: { provider: 'openai' } }
|
||||||
|
mockDefaultModelState.isLoading = false
|
||||||
|
|
||||||
|
render(<ModelProviderPage searchText="" />)
|
||||||
|
|
||||||
|
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -96,4 +96,97 @@ describe('AddCredentialInLoadBalancing', () => {
|
|||||||
|
|
||||||
expect(onSelectCredential).toHaveBeenCalledWith(modelCredential.available_credentials[0])
|
expect(onSelectCredential).toHaveBeenCalledWith(modelCredential.available_credentials[0])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// renderTrigger with open=true: bg-state-base-hover style applied
|
||||||
|
it('should apply hover background when trigger is rendered with open=true', async () => {
|
||||||
|
vi.doMock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
|
||||||
|
Authorized: ({
|
||||||
|
renderTrigger,
|
||||||
|
}: {
|
||||||
|
renderTrigger: (open?: boolean) => React.ReactNode
|
||||||
|
}) => (
|
||||||
|
<div data-testid="open-trigger">{renderTrigger(true)}</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Must invalidate module cache so the component picks up the new mock
|
||||||
|
vi.resetModules()
|
||||||
|
try {
|
||||||
|
const { default: AddCredentialLB } = await import('./add-credential-in-load-balancing')
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<AddCredentialLB
|
||||||
|
provider={provider}
|
||||||
|
model={model}
|
||||||
|
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
modelCredential={modelCredential}
|
||||||
|
onSelectCredential={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The trigger div rendered by renderTrigger(true) should have bg-state-base-hover
|
||||||
|
// (the static class applied when open=true via cn())
|
||||||
|
const triggerDiv = container.querySelector('[data-testid="open-trigger"] > div')
|
||||||
|
expect(triggerDiv).toBeInTheDocument()
|
||||||
|
expect(triggerDiv!.className).toContain('bg-state-base-hover')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
vi.doUnmock('@/app/components/header/account-setting/model-provider-page/model-auth')
|
||||||
|
vi.resetModules()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// customizableModel configuration method: component renders the add credential label
|
||||||
|
it('should render correctly with customizableModel configuration method', () => {
|
||||||
|
render(
|
||||||
|
<AddCredentialInLoadBalancing
|
||||||
|
provider={provider}
|
||||||
|
model={model}
|
||||||
|
configurationMethod={ConfigurationMethodEnum.customizableModel}
|
||||||
|
modelCredential={modelCredential}
|
||||||
|
onSelectCredential={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText(/modelProvider.auth.addCredential/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle undefined available_credentials gracefully using nullish coalescing', () => {
|
||||||
|
const credentialWithNoAvailable = {
|
||||||
|
available_credentials: undefined,
|
||||||
|
credentials: {},
|
||||||
|
load_balancing: { enabled: false, configs: [] },
|
||||||
|
} as unknown as typeof modelCredential
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AddCredentialInLoadBalancing
|
||||||
|
provider={provider}
|
||||||
|
model={model}
|
||||||
|
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
modelCredential={credentialWithNoAvailable}
|
||||||
|
onSelectCredential={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Component should render without error - the ?? [] fallback is used
|
||||||
|
expect(screen.getByText(/modelProvider.auth.addCredential/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not throw when update action fires without onUpdate prop', () => {
|
||||||
|
// Arrange - no onUpdate prop
|
||||||
|
render(
|
||||||
|
<AddCredentialInLoadBalancing
|
||||||
|
provider={provider}
|
||||||
|
model={model}
|
||||||
|
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
modelCredential={modelCredential}
|
||||||
|
onSelectCredential={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act - trigger the update without onUpdate being set (should not throw)
|
||||||
|
expect(() => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Run update' }))
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -85,4 +85,69 @@ describe('CredentialItem', () => {
|
|||||||
|
|
||||||
expect(onDelete).not.toHaveBeenCalled()
|
expect(onDelete).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// All disable flags true → no action buttons rendered
|
||||||
|
it('should hide all action buttons when disableRename, disableEdit, and disableDelete are all true', () => {
|
||||||
|
// Act
|
||||||
|
render(
|
||||||
|
<CredentialItem
|
||||||
|
credential={credential}
|
||||||
|
onEdit={vi.fn()}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
disableRename
|
||||||
|
disableEdit
|
||||||
|
disableDelete
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(screen.queryByTestId('edit-icon')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('delete-icon')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// disabled=true guards: clicks on the item row and on delete should both be no-ops
|
||||||
|
it('should not call onItemClick when disabled=true and item is clicked', () => {
|
||||||
|
const onItemClick = vi.fn()
|
||||||
|
|
||||||
|
render(<CredentialItem credential={credential} disabled onItemClick={onItemClick} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Test API Key'))
|
||||||
|
|
||||||
|
expect(onItemClick).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not call onDelete when disabled=true and delete button is clicked', () => {
|
||||||
|
const onDelete = vi.fn()
|
||||||
|
|
||||||
|
render(<CredentialItem credential={credential} disabled onDelete={onDelete} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
|
||||||
|
|
||||||
|
expect(onDelete).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// showSelectedIcon=true: check icon area is always rendered; check icon only appears when IDs match
|
||||||
|
it('should render check icon area when showSelectedIcon=true and selectedCredentialId matches', () => {
|
||||||
|
render(
|
||||||
|
<CredentialItem
|
||||||
|
credential={credential}
|
||||||
|
showSelectedIcon
|
||||||
|
selectedCredentialId="cred-1"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('check-icon')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render check icon when showSelectedIcon=true but selectedCredentialId does not match', () => {
|
||||||
|
render(
|
||||||
|
<CredentialItem
|
||||||
|
credential={credential}
|
||||||
|
showSelectedIcon
|
||||||
|
selectedCredentialId="other-cred"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('check-icon')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -24,36 +24,6 @@ vi.mock('../hooks', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
let mockPortalOpen = false
|
|
||||||
|
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
|
||||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => {
|
|
||||||
mockPortalOpen = open
|
|
||||||
return <div data-testid="portal" data-open={open}>{children}</div>
|
|
||||||
},
|
|
||||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
|
||||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
|
||||||
),
|
|
||||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
|
|
||||||
if (!mockPortalOpen)
|
|
||||||
return null
|
|
||||||
return <div data-testid="portal-content">{children}</div>
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/app/components/base/confirm', () => ({
|
|
||||||
default: ({ isShow, onCancel, onConfirm }: { isShow: boolean, onCancel: () => void, onConfirm: () => void }) => {
|
|
||||||
if (!isShow)
|
|
||||||
return null
|
|
||||||
return (
|
|
||||||
<div data-testid="confirm-dialog">
|
|
||||||
<button onClick={onCancel}>Cancel</button>
|
|
||||||
<button onClick={onConfirm}>Confirm</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('./authorized-item', () => ({
|
vi.mock('./authorized-item', () => ({
|
||||||
default: ({ credentials, model, onEdit, onDelete, onItemClick }: {
|
default: ({ credentials, model, onEdit, onDelete, onItemClick }: {
|
||||||
credentials: Credential[]
|
credentials: Credential[]
|
||||||
@ -105,382 +75,127 @@ describe('Authorized', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockPortalOpen = false
|
|
||||||
mockDeleteCredentialId = null
|
mockDeleteCredentialId = null
|
||||||
mockDoingAction = false
|
mockDoingAction = false
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Rendering', () => {
|
it('should render trigger and open popup when trigger is clicked', () => {
|
||||||
it('should render trigger button', () => {
|
render(
|
||||||
render(
|
<Authorized
|
||||||
<Authorized
|
provider={mockProvider}
|
||||||
provider={mockProvider}
|
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
items={mockItems}
|
||||||
items={mockItems}
|
renderTrigger={mockRenderTrigger}
|
||||||
renderTrigger={mockRenderTrigger}
|
/>,
|
||||||
/>,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.getByText(/Trigger/)).toBeInTheDocument()
|
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
|
||||||
})
|
expect(screen.getByTestId('authorized-item')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: /addApiKey/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('should render portal content when open', () => {
|
it('should call handleOpenModal when triggerOnlyOpenModal is true', () => {
|
||||||
render(
|
render(
|
||||||
<Authorized
|
<Authorized
|
||||||
provider={mockProvider}
|
provider={mockProvider}
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
items={mockItems}
|
items={mockItems}
|
||||||
renderTrigger={mockRenderTrigger}
|
renderTrigger={mockRenderTrigger}
|
||||||
isOpen
|
triggerOnlyOpenModal
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
|
||||||
expect(screen.getByTestId('authorized-item')).toBeInTheDocument()
|
expect(mockHandleOpenModal).toHaveBeenCalled()
|
||||||
})
|
expect(screen.queryByTestId('authorized-item')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('should not render portal content when closed', () => {
|
it('should call onItemClick when credential is selected', () => {
|
||||||
render(
|
const onItemClick = vi.fn()
|
||||||
<Authorized
|
render(
|
||||||
provider={mockProvider}
|
<Authorized
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
provider={mockProvider}
|
||||||
items={mockItems}
|
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
renderTrigger={mockRenderTrigger}
|
items={mockItems}
|
||||||
/>,
|
renderTrigger={mockRenderTrigger}
|
||||||
)
|
onItemClick={onItemClick}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
|
||||||
})
|
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0])
|
||||||
|
|
||||||
it('should render Add API Key button when not model credential', () => {
|
expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
|
||||||
render(
|
})
|
||||||
<Authorized
|
|
||||||
provider={mockProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
|
||||||
items={mockItems}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
isOpen
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.getByText(/addApiKey/)).toBeInTheDocument()
|
it('should call handleActiveCredential when onItemClick is not provided', () => {
|
||||||
})
|
render(
|
||||||
|
<Authorized
|
||||||
|
provider={mockProvider}
|
||||||
|
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
items={mockItems}
|
||||||
|
renderTrigger={mockRenderTrigger}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
it('should render Add Model Credential button when is model credential', () => {
|
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
|
||||||
render(
|
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0])
|
||||||
<Authorized
|
|
||||||
provider={mockProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
|
||||||
items={mockItems}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
authParams={{ isModelCredential: true }}
|
|
||||||
isOpen
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.getByText(/addModelCredential/)).toBeInTheDocument()
|
expect(mockHandleActiveCredential).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not render add action when hideAddAction is true', () => {
|
it('should call handleOpenModal with fixed model fields when adding model credential', () => {
|
||||||
render(
|
render(
|
||||||
<Authorized
|
<Authorized
|
||||||
provider={mockProvider}
|
provider={mockProvider}
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
configurationMethod={ConfigurationMethodEnum.customizableModel}
|
||||||
items={mockItems}
|
items={mockItems}
|
||||||
renderTrigger={mockRenderTrigger}
|
renderTrigger={mockRenderTrigger}
|
||||||
hideAddAction
|
authParams={{ isModelCredential: true }}
|
||||||
isOpen
|
currentCustomConfigurationModelFixedFields={{
|
||||||
/>,
|
__model_name: 'gpt-4',
|
||||||
)
|
__model_type: ModelTypeEnum.textGeneration,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
expect(screen.queryByText(/addApiKey/)).not.toBeInTheDocument()
|
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
|
||||||
})
|
fireEvent.click(screen.getByText(/addModelCredential/))
|
||||||
|
|
||||||
it('should render popup title when provided', () => {
|
expect(mockHandleOpenModal).toHaveBeenCalledWith(undefined, {
|
||||||
render(
|
model: 'gpt-4',
|
||||||
<Authorized
|
model_type: ModelTypeEnum.textGeneration,
|
||||||
provider={mockProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
|
||||||
items={mockItems}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
popupTitle="Select Credential"
|
|
||||||
isOpen
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.getByText('Select Credential')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('User Interactions', () => {
|
it('should not render add action when hideAddAction is true', () => {
|
||||||
it('should call onOpenChange when trigger is clicked in controlled mode', () => {
|
render(
|
||||||
const onOpenChange = vi.fn()
|
<Authorized
|
||||||
|
provider={mockProvider}
|
||||||
|
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
items={mockItems}
|
||||||
|
renderTrigger={mockRenderTrigger}
|
||||||
|
hideAddAction
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
render(
|
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
|
||||||
<Authorized
|
expect(screen.queryByRole('button', { name: /addApiKey/i })).not.toBeInTheDocument()
|
||||||
provider={mockProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
|
||||||
items={mockItems}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
isOpen={false}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
|
||||||
|
|
||||||
expect(onOpenChange).toHaveBeenCalledWith(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should toggle portal on trigger click', () => {
|
|
||||||
const { rerender } = render(
|
|
||||||
<Authorized
|
|
||||||
provider={mockProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
|
||||||
items={mockItems}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
|
||||||
|
|
||||||
rerender(
|
|
||||||
<Authorized
|
|
||||||
provider={mockProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
|
||||||
items={mockItems}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
isOpen
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should open modal when triggerOnlyOpenModal is true', () => {
|
|
||||||
render(
|
|
||||||
<Authorized
|
|
||||||
provider={mockProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
|
||||||
items={mockItems}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
triggerOnlyOpenModal
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
|
||||||
|
|
||||||
expect(mockHandleOpenModal).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call handleOpenModal when Add API Key is clicked', () => {
|
|
||||||
render(
|
|
||||||
<Authorized
|
|
||||||
provider={mockProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
|
||||||
items={mockItems}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
isOpen
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText(/addApiKey/))
|
|
||||||
|
|
||||||
expect(mockHandleOpenModal).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call handleOpenModal with credential and model when edit is clicked', () => {
|
|
||||||
render(
|
|
||||||
<Authorized
|
|
||||||
provider={mockProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
|
||||||
items={mockItems}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
isOpen
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
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(
|
|
||||||
<Authorized
|
|
||||||
provider={mockProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.customizableModel}
|
|
||||||
items={mockItems}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
authParams={{ isModelCredential: true }}
|
|
||||||
currentCustomConfigurationModelFixedFields={{
|
|
||||||
__model_name: 'gpt-4',
|
|
||||||
__model_type: ModelTypeEnum.textGeneration,
|
|
||||||
}}
|
|
||||||
isOpen
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
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(
|
|
||||||
<Authorized
|
|
||||||
provider={mockProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
|
||||||
items={mockItems}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
onItemClick={onItemClick}
|
|
||||||
isOpen
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getAllByText('Select')[0])
|
|
||||||
|
|
||||||
expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call handleActiveCredential when onItemClick is not provided', () => {
|
|
||||||
render(
|
|
||||||
<Authorized
|
|
||||||
provider={mockProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
|
||||||
items={mockItems}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
isOpen
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
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(
|
|
||||||
<Authorized
|
|
||||||
provider={mockProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
|
||||||
items={mockItems}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
onItemClick={onItemClick}
|
|
||||||
disableItemClick
|
|
||||||
isOpen
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getAllByText('Select')[0])
|
|
||||||
|
|
||||||
expect(onItemClick).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Delete Confirmation', () => {
|
it('should show confirm dialog and call confirm handler when delete is confirmed', () => {
|
||||||
it('should show confirm dialog when deleteCredentialId is set', () => {
|
mockDeleteCredentialId = 'cred-1'
|
||||||
mockDeleteCredentialId = 'cred-1'
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<Authorized
|
<Authorized
|
||||||
provider={mockProvider}
|
provider={mockProvider}
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
items={mockItems}
|
items={mockItems}
|
||||||
renderTrigger={mockRenderTrigger}
|
renderTrigger={mockRenderTrigger}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
fireEvent.click(screen.getByRole('button', { name: /common.operation.confirm/i }))
|
||||||
})
|
expect(mockHandleConfirmDelete).toHaveBeenCalled()
|
||||||
|
|
||||||
it('should not show confirm dialog when deleteCredentialId is null', () => {
|
|
||||||
render(
|
|
||||||
<Authorized
|
|
||||||
provider={mockProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
|
||||||
items={mockItems}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call closeConfirmDelete when cancel is clicked', () => {
|
|
||||||
mockDeleteCredentialId = 'cred-1'
|
|
||||||
|
|
||||||
render(
|
|
||||||
<Authorized
|
|
||||||
provider={mockProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
|
||||||
items={mockItems}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Cancel'))
|
|
||||||
|
|
||||||
expect(mockCloseConfirmDelete).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call handleConfirmDelete when confirm is clicked', () => {
|
|
||||||
mockDeleteCredentialId = 'cred-1'
|
|
||||||
|
|
||||||
render(
|
|
||||||
<Authorized
|
|
||||||
provider={mockProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
|
||||||
items={mockItems}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Confirm'))
|
|
||||||
|
|
||||||
expect(mockHandleConfirmDelete).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Edge Cases', () => {
|
|
||||||
it('should handle empty items array', () => {
|
|
||||||
render(
|
|
||||||
<Authorized
|
|
||||||
provider={mockProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
|
||||||
items={[]}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
isOpen
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
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(
|
|
||||||
<Authorized
|
|
||||||
provider={restrictedProvider}
|
|
||||||
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
|
||||||
items={mockItems}
|
|
||||||
renderTrigger={mockRenderTrigger}
|
|
||||||
isOpen
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(screen.queryByText(/addApiKey/)).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
import ConfigProvider from './config-provider'
|
import ConfigProvider from './config-provider'
|
||||||
|
|
||||||
const mockUseCredentialStatus = vi.fn()
|
const mockUseCredentialStatus = vi.fn()
|
||||||
@ -54,7 +55,8 @@ describe('ConfigProvider', () => {
|
|||||||
expect(screen.getByText(/operation.config/i)).toBeInTheDocument()
|
expect(screen.getByText(/operation.config/i)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should still render setup label when custom credentials are not allowed', () => {
|
it('should show setup label and unavailable tooltip when custom credentials are not allowed and no credential exists', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
mockUseCredentialStatus.mockReturnValue({
|
mockUseCredentialStatus.mockReturnValue({
|
||||||
hasCredential: false,
|
hasCredential: false,
|
||||||
authorized: false,
|
authorized: false,
|
||||||
@ -65,6 +67,50 @@ describe('ConfigProvider', () => {
|
|||||||
|
|
||||||
render(<ConfigProvider provider={{ ...baseProvider, allow_custom_token: false }} />)
|
render(<ConfigProvider provider={{ ...baseProvider, allow_custom_token: false }} />)
|
||||||
|
|
||||||
|
expect(screen.getByText(/operation.setup/i)).toBeInTheDocument()
|
||||||
|
await user.hover(screen.getByText(/operation.setup/i))
|
||||||
|
expect(await screen.findByText(/auth\.credentialUnavailable/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show config label when hasCredential but not authorized', () => {
|
||||||
|
mockUseCredentialStatus.mockReturnValue({
|
||||||
|
hasCredential: true,
|
||||||
|
authorized: false,
|
||||||
|
current_credential_id: 'cred-1',
|
||||||
|
current_credential_name: 'Key 1',
|
||||||
|
available_credentials: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ConfigProvider provider={baseProvider} />)
|
||||||
|
|
||||||
|
expect(screen.getByText(/operation.config/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show config label when custom credentials are not allowed but credential exists', () => {
|
||||||
|
mockUseCredentialStatus.mockReturnValue({
|
||||||
|
hasCredential: true,
|
||||||
|
authorized: true,
|
||||||
|
current_credential_id: 'cred-1',
|
||||||
|
current_credential_name: 'Key 1',
|
||||||
|
available_credentials: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ConfigProvider provider={{ ...baseProvider, allow_custom_token: false }} />)
|
||||||
|
|
||||||
|
expect(screen.getByText(/operation.config/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle nullish credential values with fallbacks', () => {
|
||||||
|
mockUseCredentialStatus.mockReturnValue({
|
||||||
|
hasCredential: false,
|
||||||
|
authorized: false,
|
||||||
|
current_credential_id: null,
|
||||||
|
current_credential_name: null,
|
||||||
|
available_credentials: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ConfigProvider provider={baseProvider} />)
|
||||||
|
|
||||||
expect(screen.getByText(/operation.setup/i)).toBeInTheDocument()
|
expect(screen.getByText(/operation.setup/i)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
import CredentialSelector from './credential-selector'
|
import CredentialSelector from './credential-selector'
|
||||||
|
|
||||||
// Mock components
|
|
||||||
vi.mock('./authorized/credential-item', () => ({
|
vi.mock('./authorized/credential-item', () => ({
|
||||||
default: ({ credential, onItemClick }: { credential: { credential_name: string }, onItemClick: (c: unknown) => void }) => (
|
default: ({ credential, onItemClick }: { credential: { credential_name: string }, onItemClick?: (c: unknown) => void }) => (
|
||||||
<div data-testid="credential-item" onClick={() => onItemClick(credential)}>
|
<button type="button" onClick={() => onItemClick?.(credential)}>
|
||||||
{credential.credential_name}
|
{credential.credential_name}
|
||||||
</div>
|
</button>
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@ -19,22 +19,6 @@ vi.mock('@remixicon/react', () => ({
|
|||||||
RiArrowDownSLine: () => <div data-testid="arrow-icon" />,
|
RiArrowDownSLine: () => <div data-testid="arrow-icon" />,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock portal components
|
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
|
||||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
|
|
||||||
<div data-testid="portal" data-open={open}>{children}</div>
|
|
||||||
),
|
|
||||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
|
||||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
|
||||||
),
|
|
||||||
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 <div data-testid="portal-content">{children}</div>
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('CredentialSelector', () => {
|
describe('CredentialSelector', () => {
|
||||||
const mockCredentials = [
|
const mockCredentials = [
|
||||||
{ credential_id: 'cred-1', credential_name: 'Key 1' },
|
{ credential_id: 'cred-1', credential_name: 'Key 1' },
|
||||||
@ -46,7 +30,7 @@ describe('CredentialSelector', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render selected credential name', () => {
|
it('should render selected credential name when selectedCredential is provided', () => {
|
||||||
render(
|
render(
|
||||||
<CredentialSelector
|
<CredentialSelector
|
||||||
selectedCredential={mockCredentials[0]}
|
selectedCredential={mockCredentials[0]}
|
||||||
@ -55,12 +39,11 @@ describe('CredentialSelector', () => {
|
|||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Use getAllByText and take the first one (the one in the trigger)
|
expect(screen.getByText('Key 1')).toBeInTheDocument()
|
||||||
expect(screen.getAllByText('Key 1')[0]).toBeInTheDocument()
|
|
||||||
expect(screen.getByTestId('indicator')).toBeInTheDocument()
|
expect(screen.getByTestId('indicator')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render placeholder when no credential selected', () => {
|
it('should render placeholder when selectedCredential is missing', () => {
|
||||||
render(
|
render(
|
||||||
<CredentialSelector
|
<CredentialSelector
|
||||||
credentials={mockCredentials}
|
credentials={mockCredentials}
|
||||||
@ -71,7 +54,8 @@ describe('CredentialSelector', () => {
|
|||||||
expect(screen.getByText(/modelProvider.auth.selectModelCredential/)).toBeInTheDocument()
|
expect(screen.getByText(/modelProvider.auth.selectModelCredential/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should open portal on click', () => {
|
it('should call onSelect when a credential item is clicked', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
render(
|
render(
|
||||||
<CredentialSelector
|
<CredentialSelector
|
||||||
credentials={mockCredentials}
|
credentials={mockCredentials}
|
||||||
@ -79,26 +63,14 @@ describe('CredentialSelector', () => {
|
|||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/))
|
||||||
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true')
|
await user.click(screen.getByRole('button', { name: 'Key 2' }))
|
||||||
expect(screen.getAllByTestId('credential-item')).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call onSelect when a credential is clicked', () => {
|
|
||||||
render(
|
|
||||||
<CredentialSelector
|
|
||||||
credentials={mockCredentials}
|
|
||||||
onSelect={mockOnSelect}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
|
||||||
fireEvent.click(screen.getByText('Key 2'))
|
|
||||||
|
|
||||||
expect(mockOnSelect).toHaveBeenCalledWith(mockCredentials[1])
|
expect(mockOnSelect).toHaveBeenCalledWith(mockCredentials[1])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call onSelect with add new credential data when clicking add button', () => {
|
it('should call onSelect with add-new payload when add action is clicked', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
render(
|
render(
|
||||||
<CredentialSelector
|
<CredentialSelector
|
||||||
credentials={mockCredentials}
|
credentials={mockCredentials}
|
||||||
@ -106,8 +78,8 @@ describe('CredentialSelector', () => {
|
|||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/))
|
||||||
fireEvent.click(screen.getByText(/modelProvider.auth.addNewModelCredential/))
|
await user.click(screen.getByText(/modelProvider.auth.addNewModelCredential/))
|
||||||
|
|
||||||
expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({
|
expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
credential_id: '__add_new_credential',
|
credential_id: '__add_new_credential',
|
||||||
@ -115,7 +87,8 @@ describe('CredentialSelector', () => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not open portal when disabled', () => {
|
it('should not open options when disabled is true', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
render(
|
render(
|
||||||
<CredentialSelector
|
<CredentialSelector
|
||||||
disabled
|
disabled
|
||||||
@ -124,7 +97,7 @@ describe('CredentialSelector', () => {
|
|||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
await user.click(screen.getByText(/modelProvider.auth.selectModelCredential/))
|
||||||
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
|
expect(screen.queryByRole('button', { name: 'Key 1' })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
import type {
|
import type {
|
||||||
Credential,
|
Credential,
|
||||||
CustomModel,
|
CustomModel,
|
||||||
ModelProvider,
|
ModelProvider,
|
||||||
} from '../../declarations'
|
} from '../../declarations'
|
||||||
import { act, renderHook } from '@testing-library/react'
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { ToastContext } from '@/app/components/base/toast/context'
|
||||||
import { ConfigurationMethodEnum, ModelModalModeEnum, ModelTypeEnum } from '../../declarations'
|
import { ConfigurationMethodEnum, ModelModalModeEnum, ModelTypeEnum } from '../../declarations'
|
||||||
import { useAuth } from './use-auth'
|
import { useAuth } from './use-auth'
|
||||||
|
|
||||||
@ -20,9 +22,13 @@ const mockAddModelCredential = vi.fn()
|
|||||||
const mockEditProviderCredential = vi.fn()
|
const mockEditProviderCredential = vi.fn()
|
||||||
const mockEditModelCredential = vi.fn()
|
const mockEditModelCredential = vi.fn()
|
||||||
|
|
||||||
vi.mock('@/app/components/base/toast/context', () => ({
|
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
|
||||||
useToastContext: () => ({ notify: mockNotify }),
|
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
|
||||||
}))
|
return {
|
||||||
|
...actual,
|
||||||
|
useToastContext: () => ({ notify: mockNotify }),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||||
useModelModalHandler: () => mockOpenModelModal,
|
useModelModalHandler: () => mockOpenModelModal,
|
||||||
@ -66,6 +72,12 @@ describe('useAuth', () => {
|
|||||||
model_type: ModelTypeEnum.textGeneration,
|
model_type: ModelTypeEnum.textGeneration,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createWrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
|
||||||
|
{children}
|
||||||
|
</ToastContext.Provider>
|
||||||
|
)
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockDeleteModelService.mockResolvedValue({ result: 'success' })
|
mockDeleteModelService.mockResolvedValue({ result: 'success' })
|
||||||
@ -80,7 +92,7 @@ describe('useAuth', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should open and close delete confirmation state', () => {
|
it('should open and close delete confirmation state', () => {
|
||||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
|
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.openConfirmDelete(credential, model)
|
result.current.openConfirmDelete(credential, model)
|
||||||
@ -100,7 +112,7 @@ describe('useAuth', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should activate credential, notify success, and refresh models', async () => {
|
it('should activate credential, notify success, and refresh models', async () => {
|
||||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel))
|
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel), { wrapper: createWrapper })
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.handleActiveCredential(credential, model)
|
await result.current.handleActiveCredential(credential, model)
|
||||||
@ -120,7 +132,7 @@ describe('useAuth', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should close delete dialog without calling services when nothing is pending', async () => {
|
it('should close delete dialog without calling services when nothing is pending', async () => {
|
||||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
|
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.handleConfirmDelete()
|
await result.current.handleConfirmDelete()
|
||||||
@ -137,7 +149,7 @@ describe('useAuth', () => {
|
|||||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel, undefined, {
|
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel, undefined, {
|
||||||
isModelCredential: false,
|
isModelCredential: false,
|
||||||
onRemove,
|
onRemove,
|
||||||
}))
|
}), { wrapper: createWrapper })
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.openConfirmDelete(credential, model)
|
result.current.openConfirmDelete(credential, model)
|
||||||
@ -161,7 +173,7 @@ describe('useAuth', () => {
|
|||||||
const onRemove = vi.fn()
|
const onRemove = vi.fn()
|
||||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel, undefined, {
|
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel, undefined, {
|
||||||
onRemove,
|
onRemove,
|
||||||
}))
|
}), { wrapper: createWrapper })
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.openConfirmDelete(undefined, model)
|
result.current.openConfirmDelete(undefined, model)
|
||||||
@ -179,7 +191,7 @@ describe('useAuth', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should add or edit credentials and refresh on successful save', async () => {
|
it('should add or edit credentials and refresh on successful save', async () => {
|
||||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
|
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.handleSaveCredential({ api_key: 'new-key' })
|
await result.current.handleSaveCredential({ api_key: 'new-key' })
|
||||||
@ -200,7 +212,7 @@ describe('useAuth', () => {
|
|||||||
const deferred = createDeferred<{ result: string }>()
|
const deferred = createDeferred<{ result: string }>()
|
||||||
mockAddProviderCredential.mockReturnValueOnce(deferred.promise)
|
mockAddProviderCredential.mockReturnValueOnce(deferred.promise)
|
||||||
|
|
||||||
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
|
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||||
|
|
||||||
let first!: Promise<void>
|
let first!: Promise<void>
|
||||||
let second!: Promise<void>
|
let second!: Promise<void>
|
||||||
@ -226,7 +238,7 @@ describe('useAuth', () => {
|
|||||||
isModelCredential: true,
|
isModelCredential: true,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
mode: ModelModalModeEnum.configModelCredential,
|
mode: ModelModalModeEnum.configModelCredential,
|
||||||
}))
|
}), { wrapper: createWrapper })
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.handleOpenModal(credential, model)
|
result.current.handleOpenModal(credential, model)
|
||||||
@ -244,4 +256,90 @@ describe('useAuth', () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not notify or refresh when handleSaveCredential returns non-success result', async () => {
|
||||||
|
mockAddProviderCredential.mockResolvedValue({ result: 'error' })
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleSaveCredential({ api_key: 'some-key' })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockAddProviderCredential).toHaveBeenCalledWith({ api_key: 'some-key' })
|
||||||
|
expect(mockNotify).not.toHaveBeenCalled()
|
||||||
|
expect(mockHandleRefreshModel).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass undefined model and model_type when handleActiveCredential is called without a model parameter', async () => {
|
||||||
|
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleActiveCredential(credential)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockActiveProviderCredential).toHaveBeenCalledWith({
|
||||||
|
credential_id: 'cred-1',
|
||||||
|
model: undefined,
|
||||||
|
model_type: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// openConfirmDelete with credential only (no model): deleteCredentialId set, deleteModel stays null
|
||||||
|
it('should only set deleteCredentialId when openConfirmDelete is called without a model', () => {
|
||||||
|
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.openConfirmDelete(credential, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.deleteCredentialId).toBe('cred-1')
|
||||||
|
expect(result.current.deleteModel).toBeNull()
|
||||||
|
expect(result.current.pendingOperationCredentialId.current).toBe('cred-1')
|
||||||
|
expect(result.current.pendingOperationModel.current).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
// doingActionRef guard: second handleConfirmDelete call while first is in progress is a no-op
|
||||||
|
it('should ignore a second handleConfirmDelete call while the first is still in progress', async () => {
|
||||||
|
const deferred = createDeferred<{ result: string }>()
|
||||||
|
mockDeleteProviderCredential.mockReturnValueOnce(deferred.promise)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.openConfirmDelete(credential, model)
|
||||||
|
})
|
||||||
|
|
||||||
|
let first!: Promise<void>
|
||||||
|
let second!: Promise<void>
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
first = result.current.handleConfirmDelete()
|
||||||
|
second = result.current.handleConfirmDelete()
|
||||||
|
deferred.resolve({ result: 'success' })
|
||||||
|
await Promise.all([first, second])
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockDeleteProviderCredential).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// doingActionRef guard: second handleActiveCredential call while first is in progress is a no-op
|
||||||
|
it('should ignore a second handleActiveCredential call while the first is still in progress', async () => {
|
||||||
|
const deferred = createDeferred<{ result: string }>()
|
||||||
|
mockActiveProviderCredential.mockReturnValueOnce(deferred.promise)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel), { wrapper: createWrapper })
|
||||||
|
|
||||||
|
let first!: Promise<void>
|
||||||
|
let second!: Promise<void>
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
first = result.current.handleActiveCredential(credential)
|
||||||
|
second = result.current.handleActiveCredential(credential)
|
||||||
|
deferred.resolve({ result: 'success' })
|
||||||
|
await Promise.all([first, second])
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockActiveProviderCredential).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -13,11 +13,13 @@ vi.mock('./hooks', () => ({
|
|||||||
|
|
||||||
// Mock Authorized
|
// Mock Authorized
|
||||||
vi.mock('./authorized', () => ({
|
vi.mock('./authorized', () => ({
|
||||||
default: ({ renderTrigger, items, popupTitle }: { renderTrigger: (o?: boolean) => React.ReactNode, items: { length: number }, popupTitle: string }) => (
|
default: ({ renderTrigger, items, popupTitle }: { renderTrigger: (o?: boolean) => React.ReactNode, items: Array<{ selectedCredential?: unknown }>, popupTitle: string }) => (
|
||||||
<div data-testid="authorized-mock">
|
<div data-testid="authorized-mock">
|
||||||
<div data-testid="trigger-container">{renderTrigger()}</div>
|
<div data-testid="trigger-closed">{renderTrigger()}</div>
|
||||||
|
<div data-testid="trigger-open">{renderTrigger(true)}</div>
|
||||||
<div data-testid="popup-title">{popupTitle}</div>
|
<div data-testid="popup-title">{popupTitle}</div>
|
||||||
<div data-testid="items-count">{items.length}</div>
|
<div data-testid="items-count">{items.length}</div>
|
||||||
|
<div data-testid="items-selected">{items.map((it, i) => <span key={i} data-testid={`selected-${i}`}>{it.selectedCredential ? 'has-cred' : 'no-cred'}</span>)}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
@ -55,8 +57,41 @@ describe('ManageCustomModelCredentials', () => {
|
|||||||
render(<ManageCustomModelCredentials provider={mockProvider} />)
|
render(<ManageCustomModelCredentials provider={mockProvider} />)
|
||||||
|
|
||||||
expect(screen.getByTestId('authorized-mock')).toBeInTheDocument()
|
expect(screen.getByTestId('authorized-mock')).toBeInTheDocument()
|
||||||
expect(screen.getByText(/modelProvider.auth.manageCredentials/)).toBeInTheDocument()
|
expect(screen.getAllByText(/modelProvider.auth.manageCredentials/).length).toBeGreaterThan(0)
|
||||||
expect(screen.getByTestId('items-count')).toHaveTextContent('2')
|
expect(screen.getByTestId('items-count')).toHaveTextContent('2')
|
||||||
expect(screen.getByTestId('popup-title')).toHaveTextContent('modelProvider.auth.customModelCredentials')
|
expect(screen.getByTestId('popup-title')).toHaveTextContent('modelProvider.auth.customModelCredentials')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render trigger in both open and closed states', () => {
|
||||||
|
const mockModels = [
|
||||||
|
{
|
||||||
|
model: 'gpt-4',
|
||||||
|
available_model_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
|
||||||
|
current_credential_id: 'c1',
|
||||||
|
current_credential_name: 'Key 1',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mockUseCustomModels.mockReturnValue(mockModels)
|
||||||
|
|
||||||
|
render(<ManageCustomModelCredentials provider={mockProvider} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('trigger-closed')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('trigger-open')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass undefined selectedCredential when model has no current_credential_id', () => {
|
||||||
|
const mockModels = [
|
||||||
|
{
|
||||||
|
model: 'gpt-3.5',
|
||||||
|
available_model_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
|
||||||
|
current_credential_id: '',
|
||||||
|
current_credential_name: '',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mockUseCustomModels.mockReturnValue(mockModels)
|
||||||
|
|
||||||
|
render(<ManageCustomModelCredentials provider={mockProvider} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('selected-0')).toHaveTextContent('no-cred')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -18,15 +18,6 @@ vi.mock('@/app/components/header/indicator', () => ({
|
|||||||
default: ({ color }: { color: string }) => <div data-testid={`indicator-${color}`} />,
|
default: ({ color }: { color: string }) => <div data-testid={`indicator-${color}`} />,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/tooltip', () => ({
|
|
||||||
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
|
|
||||||
<div data-testid="tooltip-mock">
|
|
||||||
{children}
|
|
||||||
<div>{popupContent}</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@remixicon/react', () => ({
|
vi.mock('@remixicon/react', () => ({
|
||||||
RiArrowDownSLine: () => <div data-testid="arrow-icon" />,
|
RiArrowDownSLine: () => <div data-testid="arrow-icon" />,
|
||||||
}))
|
}))
|
||||||
@ -125,6 +116,131 @@ describe('SwitchCredentialInLoadBalancing', () => {
|
|||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(screen.getByText(/auth.credentialUnavailableInButton/))
|
||||||
expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument()
|
expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Empty credentials with allowed custom: no tooltip but still shows unavailable text
|
||||||
|
it('should show unavailable status without tooltip when custom credentials are allowed', () => {
|
||||||
|
// Act
|
||||||
|
render(
|
||||||
|
<SwitchCredentialInLoadBalancing
|
||||||
|
provider={mockProvider}
|
||||||
|
model={mockModel}
|
||||||
|
credentials={[]}
|
||||||
|
customModelCredential={undefined}
|
||||||
|
setCustomModelCredential={mockSetCustomModelCredential}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('plugin.auth.credentialUnavailable')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// not_allowed_to_use=true: indicator is red and destructive button text is shown
|
||||||
|
it('should show red indicator and unavailable button text when credential has not_allowed_to_use=true', () => {
|
||||||
|
const unavailableCredential = { credential_id: 'cred-1', credential_name: 'Key 1', not_allowed_to_use: true }
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SwitchCredentialInLoadBalancing
|
||||||
|
provider={mockProvider}
|
||||||
|
model={mockModel}
|
||||||
|
credentials={[unavailableCredential]}
|
||||||
|
customModelCredential={unavailableCredential}
|
||||||
|
setCustomModelCredential={mockSetCustomModelCredential}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('indicator-red')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// from_enterprise=true on the selected credential: Enterprise badge appears in the trigger
|
||||||
|
it('should show Enterprise badge when selected credential has from_enterprise=true', () => {
|
||||||
|
const enterpriseCredential = { credential_id: 'cred-1', credential_name: 'Enterprise Key', from_enterprise: true }
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SwitchCredentialInLoadBalancing
|
||||||
|
provider={mockProvider}
|
||||||
|
model={mockModel}
|
||||||
|
credentials={[enterpriseCredential]}
|
||||||
|
customModelCredential={enterpriseCredential}
|
||||||
|
setCustomModelCredential={mockSetCustomModelCredential}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Enterprise')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// non-empty credentials with allow_custom_token=false: no tooltip (tooltip only for empty+notAllowCustom)
|
||||||
|
it('should not show unavailable tooltip when credentials are non-empty and allow_custom_token=false', () => {
|
||||||
|
const restrictedProvider = { ...mockProvider, allow_custom_token: false }
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SwitchCredentialInLoadBalancing
|
||||||
|
provider={restrictedProvider}
|
||||||
|
model={mockModel}
|
||||||
|
credentials={mockCredentials}
|
||||||
|
customModelCredential={mockCredentials[0]}
|
||||||
|
setCustomModelCredential={mockSetCustomModelCredential}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.mouseEnter(screen.getByText('Key 1'))
|
||||||
|
expect(screen.queryByText('plugin.auth.credentialUnavailable')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Key 1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass undefined currentCustomConfigurationModelFixedFields when model is undefined', () => {
|
||||||
|
render(
|
||||||
|
<SwitchCredentialInLoadBalancing
|
||||||
|
provider={mockProvider}
|
||||||
|
// @ts-expect-error testing runtime handling when model is omitted
|
||||||
|
model={undefined}
|
||||||
|
credentials={mockCredentials}
|
||||||
|
customModelCredential={mockCredentials[0]}
|
||||||
|
setCustomModelCredential={mockSetCustomModelCredential}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Component still renders (Authorized receives undefined currentCustomConfigurationModelFixedFields)
|
||||||
|
expect(screen.getByTestId('authorized-mock')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Key 1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should treat undefined credentials as empty list', () => {
|
||||||
|
render(
|
||||||
|
<SwitchCredentialInLoadBalancing
|
||||||
|
provider={mockProvider}
|
||||||
|
model={mockModel}
|
||||||
|
credentials={undefined}
|
||||||
|
customModelCredential={undefined}
|
||||||
|
setCustomModelCredential={mockSetCustomModelCredential}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// credentials is undefined → empty=true → unavailable text shown
|
||||||
|
expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId(/indicator-/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render nothing for credential_name when it is empty string', () => {
|
||||||
|
const credWithEmptyName = { credential_id: 'cred-1', credential_name: '' }
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SwitchCredentialInLoadBalancing
|
||||||
|
provider={mockProvider}
|
||||||
|
model={mockModel}
|
||||||
|
credentials={[credWithEmptyName]}
|
||||||
|
customModelCredential={credWithEmptyName}
|
||||||
|
setCustomModelCredential={mockSetCustomModelCredential}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// indicator-green shown (not authRemoved, not unavailable, not empty)
|
||||||
|
expect(screen.getByTestId('indicator-green')).toBeInTheDocument()
|
||||||
|
// credential_name is empty so nothing printed for name
|
||||||
|
expect(screen.queryByText('Key 1')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -24,10 +24,6 @@ vi.mock('../hooks', () => ({
|
|||||||
useLanguage: () => mockLanguage,
|
useLanguage: () => mockLanguage,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/icons/src/public/llm', () => ({
|
|
||||||
OpenaiYellow: () => <svg data-testid="openai-yellow-icon" />,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const createI18nText = (value: string): I18nText => ({
|
const createI18nText = (value: string): I18nText => ({
|
||||||
en_US: value,
|
en_US: value,
|
||||||
zh_Hans: value,
|
zh_Hans: value,
|
||||||
@ -92,10 +88,10 @@ describe('ModelIcon', () => {
|
|||||||
icon_small: createI18nText('openai.png'),
|
icon_small: createI18nText('openai.png'),
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<ModelIcon provider={provider} modelName="o1" />)
|
const { container } = render(<ModelIcon provider={provider} modelName="o1" />)
|
||||||
|
|
||||||
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
|
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
|
||||||
expect(screen.getByTestId('openai-yellow-icon')).toBeInTheDocument()
|
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Edge case
|
// Edge case
|
||||||
@ -105,4 +101,25 @@ describe('ModelIcon', () => {
|
|||||||
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
|
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
|
||||||
expect(container.firstChild).not.toBeNull()
|
expect(container.firstChild).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render OpenAI Yellow icon for langgenius/openai/openai provider with model starting with o', () => {
|
||||||
|
const provider = createModel({
|
||||||
|
provider: 'langgenius/openai/openai',
|
||||||
|
icon_small: createI18nText('openai.png'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { container } = render(<ModelIcon provider={provider} modelName="o3" />)
|
||||||
|
|
||||||
|
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
|
||||||
|
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply opacity-50 when isDeprecated is true', () => {
|
||||||
|
const provider = createModel()
|
||||||
|
|
||||||
|
const { container } = render(<ModelIcon provider={provider} isDeprecated={true} />)
|
||||||
|
|
||||||
|
const wrapper = container.querySelector('.opacity-50')
|
||||||
|
expect(wrapper).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -161,7 +161,7 @@ function Form<
|
|||||||
const disabled = readonly || (isEditMode && (variable === '__model_type' || variable === '__model_name'))
|
const disabled = readonly || (isEditMode && (variable === '__model_type' || variable === '__model_name'))
|
||||||
return (
|
return (
|
||||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
|
||||||
{label[language] || label.en_US}
|
{label[language] || label.en_US}
|
||||||
{required && (
|
{required && (
|
||||||
<span className="ml-1 text-red-500">*</span>
|
<span className="ml-1 text-red-500">*</span>
|
||||||
@ -204,13 +204,14 @@ function Form<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
|
||||||
{label[language] || label.en_US}
|
{label[language] || label.en_US}
|
||||||
{required && (
|
{required && (
|
||||||
<span className="ml-1 text-red-500">*</span>
|
<span className="ml-1 text-red-500">*</span>
|
||||||
)}
|
)}
|
||||||
{tooltipContent}
|
{tooltipContent}
|
||||||
</div>
|
</div>
|
||||||
|
{/* eslint-disable-next-line tailwindcss/no-unknown-classes */}
|
||||||
<div className={cn('grid gap-3', `grid-cols-${options?.length}`)}>
|
<div className={cn('grid gap-3', `grid-cols-${options?.length}`)}>
|
||||||
{options.filter((option) => {
|
{options.filter((option) => {
|
||||||
if (option.show_on.length)
|
if (option.show_on.length)
|
||||||
@ -229,7 +230,7 @@ function Form<
|
|||||||
>
|
>
|
||||||
<RadioE isChecked={value[variable] === option.value} />
|
<RadioE isChecked={value[variable] === option.value} />
|
||||||
|
|
||||||
<div className="system-sm-regular text-text-secondary">{option.label[language] || option.label.en_US}</div>
|
<div className="text-text-secondary system-sm-regular">{option.label[language] || option.label.en_US}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -254,7 +255,7 @@ function Form<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
|
||||||
{label[language] || label.en_US}
|
{label[language] || label.en_US}
|
||||||
|
|
||||||
{required && (
|
{required && (
|
||||||
@ -295,9 +296,9 @@ function Form<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||||
<div className="system-sm-semibold flex items-center justify-between py-2 text-text-secondary">
|
<div className="flex items-center justify-between py-2 text-text-secondary system-sm-semibold">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>{label[language] || label.en_US}</span>
|
<span className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>{label[language] || label.en_US}</span>
|
||||||
{required && (
|
{required && (
|
||||||
<span className="ml-1 text-red-500">*</span>
|
<span className="ml-1 text-red-500">*</span>
|
||||||
)}
|
)}
|
||||||
@ -326,7 +327,7 @@ function Form<
|
|||||||
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
|
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
|
||||||
return (
|
return (
|
||||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
|
||||||
{label[language] || label.en_US}
|
{label[language] || label.en_US}
|
||||||
{required && (
|
{required && (
|
||||||
<span className="ml-1 text-red-500">*</span>
|
<span className="ml-1 text-red-500">*</span>
|
||||||
@ -358,7 +359,7 @@ function Form<
|
|||||||
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
|
} = formSchema as (CredentialFormSchemaTextInput | CredentialFormSchemaSecretInput)
|
||||||
return (
|
return (
|
||||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
|
||||||
{label[language] || label.en_US}
|
{label[language] || label.en_US}
|
||||||
{required && (
|
{required && (
|
||||||
<span className="ml-1 text-red-500">*</span>
|
<span className="ml-1 text-red-500">*</span>
|
||||||
@ -422,7 +423,7 @@ function Form<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
|
||||||
{label[language] || label.en_US}
|
{label[language] || label.en_US}
|
||||||
{required && (
|
{required && (
|
||||||
<span className="ml-1 text-red-500">*</span>
|
<span className="ml-1 text-red-500">*</span>
|
||||||
@ -451,7 +452,7 @@ function Form<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
<div key={variable} className={cn(itemClassName, 'py-3')}>
|
||||||
<div className={cn(fieldLabelClassName, 'system-sm-semibold flex items-center py-2 text-text-secondary')}>
|
<div className={cn(fieldLabelClassName, 'flex items-center py-2 text-text-secondary system-sm-semibold')}>
|
||||||
{label[language] || label.en_US}
|
{label[language] || label.en_US}
|
||||||
{required && (
|
{required && (
|
||||||
<span className="ml-1 text-red-500">*</span>
|
<span className="ml-1 text-red-500">*</span>
|
||||||
|
|||||||
@ -93,4 +93,88 @@ describe('Input', () => {
|
|||||||
expect(onChange).not.toHaveBeenCalledWith('2')
|
expect(onChange).not.toHaveBeenCalledWith('2')
|
||||||
expect(onChange).not.toHaveBeenCalledWith('6')
|
expect(onChange).not.toHaveBeenCalledWith('6')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not clamp when min and max are not provided', () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Input
|
||||||
|
placeholder="Free"
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Free')
|
||||||
|
fireEvent.change(input, { target: { value: '999' } })
|
||||||
|
fireEvent.blur(input)
|
||||||
|
|
||||||
|
// onChange only called from change event, not from blur clamping
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onChange).toHaveBeenCalledWith('999')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show check circle icon when validated is true', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Input
|
||||||
|
placeholder="Key"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
validated
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('Key')).toBeInTheDocument()
|
||||||
|
expect(container.querySelector('.absolute.right-2\\.5.top-2\\.5')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show check circle icon when validated is false', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Input
|
||||||
|
placeholder="Key"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
validated={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('Key')).toBeInTheDocument()
|
||||||
|
expect(container.querySelector('.absolute.right-2\\.5.top-2\\.5')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply disabled attribute when disabled prop is true', () => {
|
||||||
|
render(
|
||||||
|
<Input
|
||||||
|
placeholder="Disabled"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
disabled
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('Disabled')).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onFocus when input receives focus', () => {
|
||||||
|
const onFocus = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Input
|
||||||
|
placeholder="Focus"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
onFocus={onFocus}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.focus(screen.getByPlaceholderText('Focus'))
|
||||||
|
expect(onFocus).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render with custom className', () => {
|
||||||
|
render(
|
||||||
|
<Input
|
||||||
|
placeholder="Styled"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
className="custom-class"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('Styled')).toHaveClass('custom-class')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import type { Credential, CredentialFormSchema, ModelProvider } from '../declarations'
|
import type { ComponentProps } from 'react'
|
||||||
|
import type { Credential, CredentialFormSchema, CustomModel, ModelProvider } from '../declarations'
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import * as React from 'react'
|
||||||
import {
|
import {
|
||||||
ConfigurationMethodEnum,
|
ConfigurationMethodEnum,
|
||||||
CurrentSystemQuotaTypeEnum,
|
CurrentSystemQuotaTypeEnum,
|
||||||
@ -43,15 +45,6 @@ const mockHandlers = vi.hoisted(() => ({
|
|||||||
handleActiveCredential: vi.fn(),
|
handleActiveCredential: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
type FormResponse = {
|
|
||||||
isCheckValidated: boolean
|
|
||||||
values: Record<string, unknown>
|
|
||||||
}
|
|
||||||
const mockFormState = vi.hoisted(() => ({
|
|
||||||
responses: [] as FormResponse[],
|
|
||||||
setFieldValue: vi.fn(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../model-auth/hooks', () => ({
|
vi.mock('../model-auth/hooks', () => ({
|
||||||
useCredentialData: () => ({
|
useCredentialData: () => ({
|
||||||
isLoading: mockState.isLoading,
|
isLoading: mockState.isLoading,
|
||||||
@ -86,36 +79,6 @@ vi.mock('../hooks', () => ({
|
|||||||
useLanguage: () => 'en_US',
|
useLanguage: () => 'en_US',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/form/form-scenarios/auth', async () => {
|
|
||||||
const React = await import('react')
|
|
||||||
const AuthForm = React.forwardRef(({
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
onChange?: (field: string, value: string) => void
|
|
||||||
}, ref: React.ForwardedRef<{ getFormValues: () => FormResponse, getForm: () => { setFieldValue: (field: string, value: string) => void } }>) => {
|
|
||||||
React.useImperativeHandle(ref, () => ({
|
|
||||||
getFormValues: () => mockFormState.responses.shift() || { isCheckValidated: false, values: {} },
|
|
||||||
getForm: () => ({ setFieldValue: mockFormState.setFieldValue }),
|
|
||||||
}))
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button type="button" onClick={() => onChange?.('__model_name', 'updated-model')}>Model Name Change</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
return { default: AuthForm }
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock('../model-auth', () => ({
|
|
||||||
CredentialSelector: ({ onSelect }: { onSelect: (credential: Credential & { addNewCredential?: boolean }) => void }) => (
|
|
||||||
<div>
|
|
||||||
<button type="button" onClick={() => onSelect({ credential_id: 'existing' })}>Choose Existing</button>
|
|
||||||
<button type="button" onClick={() => onSelect({ credential_id: 'new', addNewCredential: true })}>Add New</button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const createI18n = (text: string) => ({ en_US: text, zh_Hans: text })
|
const createI18n = (text: string) => ({ en_US: text, zh_Hans: text })
|
||||||
|
|
||||||
const createProvider = (overrides?: Partial<ModelProvider>): ModelProvider => ({
|
const createProvider = (overrides?: Partial<ModelProvider>): ModelProvider => ({
|
||||||
@ -158,7 +121,7 @@ const createProvider = (overrides?: Partial<ModelProvider>): ModelProvider => ({
|
|||||||
...overrides,
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderModal = (overrides?: Partial<React.ComponentProps<typeof ModelModal>>) => {
|
const renderModal = (overrides?: Partial<ComponentProps<typeof ModelModal>>) => {
|
||||||
const provider = createProvider()
|
const provider = createProvider()
|
||||||
const props = {
|
const props = {
|
||||||
provider,
|
provider,
|
||||||
@ -168,13 +131,50 @@ const renderModal = (overrides?: Partial<React.ComponentProps<typeof ModelModal>
|
|||||||
onRemove: vi.fn(),
|
onRemove: vi.fn(),
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
const view = render(<ModelModal {...props} />)
|
render(<ModelModal {...props} />)
|
||||||
return {
|
return props
|
||||||
...props,
|
|
||||||
unmount: view.unmount,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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', () => {
|
describe('ModelModal', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
@ -187,167 +187,131 @@ describe('ModelModal', () => {
|
|||||||
mockState.formValues = {}
|
mockState.formValues = {}
|
||||||
mockState.modelNameAndTypeFormSchemas = []
|
mockState.modelNameAndTypeFormSchemas = []
|
||||||
mockState.modelNameAndTypeFormValues = {}
|
mockState.modelNameAndTypeFormValues = {}
|
||||||
mockFormState.responses = []
|
|
||||||
|
// 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 show title, description, and loading state for predefined models', () => {
|
it('should render title and loading state for predefined credential modal', () => {
|
||||||
mockState.isLoading = true
|
mockState.isLoading = true
|
||||||
|
renderModal()
|
||||||
const predefined = renderModal()
|
|
||||||
|
|
||||||
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.title')).toBeInTheDocument()
|
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.title')).toBeInTheDocument()
|
||||||
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.desc')).toBeInTheDocument()
|
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.desc')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
})
|
||||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
|
|
||||||
|
|
||||||
predefined.unmount()
|
it('should render model credential title when mode is configModelCredential', () => {
|
||||||
const customizable = renderModal({ configurateMethod: ConfigurationMethodEnum.customizableModel })
|
renderModal({
|
||||||
expect(screen.queryByText('common.modelProvider.auth.apiKeyModal.desc')).not.toBeInTheDocument()
|
mode: ModelModalModeEnum.configModelCredential,
|
||||||
customizable.unmount()
|
model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration },
|
||||||
|
})
|
||||||
mockState.credentialData = { credentials: {}, available_credentials: [] }
|
|
||||||
renderModal({ mode: ModelModalModeEnum.configModelCredential, model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } })
|
|
||||||
expect(screen.getByText('common.modelProvider.auth.addModelCredential')).toBeInTheDocument()
|
expect(screen.getByText('common.modelProvider.auth.addModelCredential')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should reveal the credential label when adding a new credential', () => {
|
it('should render edit credential title when credential exists', () => {
|
||||||
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList })
|
renderModal({
|
||||||
|
|
||||||
expect(screen.queryByText('common.modelProvider.auth.modelCredential')).not.toBeInTheDocument()
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Add New'))
|
|
||||||
|
|
||||||
expect(screen.getByText('common.modelProvider.auth.modelCredential')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call onCancel when the cancel button is clicked', () => {
|
|
||||||
const { onCancel } = renderModal()
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
|
||||||
|
|
||||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call onCancel when the escape key is pressed', () => {
|
|
||||||
const { onCancel } = renderModal()
|
|
||||||
|
|
||||||
fireEvent.keyDown(document, { key: 'Escape' })
|
|
||||||
|
|
||||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should confirm deletion when a delete dialog is shown', () => {
|
|
||||||
mockState.credentialData = { credentials: { api_key: 'secret' }, available_credentials: [] }
|
|
||||||
mockState.deleteCredentialId = 'delete-id'
|
|
||||||
|
|
||||||
const credential: Credential = { credential_id: 'cred-1' }
|
|
||||||
const { onCancel } = renderModal({ credential })
|
|
||||||
|
|
||||||
expect(screen.getByText('common.modelProvider.confirmDelete')).toBeInTheDocument()
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
|
||||||
|
|
||||||
expect(mockHandlers.handleConfirmDelete).toHaveBeenCalledTimes(1)
|
|
||||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle save flows for different modal modes', async () => {
|
|
||||||
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text-input' } as unknown as CredentialFormSchema]
|
|
||||||
mockState.formSchemas = [{ variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema]
|
|
||||||
mockFormState.responses = [
|
|
||||||
{ isCheckValidated: true, values: { __model_name: 'custom-model', __model_type: ModelTypeEnum.textGeneration } },
|
|
||||||
{ isCheckValidated: true, values: { __authorization_name__: 'Auth Name', api_key: 'secret' } },
|
|
||||||
]
|
|
||||||
const configCustomModel = renderModal({ mode: ModelModalModeEnum.configCustomModel })
|
|
||||||
fireEvent.click(screen.getAllByText('Model Name Change')[0])
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
|
||||||
|
|
||||||
expect(mockFormState.setFieldValue).toHaveBeenCalledWith('__model_name', 'updated-model')
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
|
|
||||||
credential_id: undefined,
|
|
||||||
credentials: { api_key: 'secret' },
|
|
||||||
name: 'Auth Name',
|
|
||||||
model: 'custom-model',
|
|
||||||
model_type: ModelTypeEnum.textGeneration,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
expect(configCustomModel.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Auth Name', api_key: 'secret' })
|
|
||||||
configCustomModel.unmount()
|
|
||||||
|
|
||||||
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Model Auth', api_key: 'abc' } }]
|
|
||||||
const model = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration }
|
|
||||||
const configModelCredential = renderModal({
|
|
||||||
mode: ModelModalModeEnum.configModelCredential,
|
mode: ModelModalModeEnum.configModelCredential,
|
||||||
model,
|
credential: { credential_id: '1' } as unknown as Credential,
|
||||||
credential: { credential_id: 'cred-123' },
|
|
||||||
})
|
})
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
expect(screen.getByText('common.modelProvider.auth.editModelCredential')).toBeInTheDocument()
|
||||||
await waitFor(() => {
|
})
|
||||||
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
|
|
||||||
credential_id: 'cred-123',
|
it('should change title to Add Model when mode is configCustomModel', () => {
|
||||||
credentials: { api_key: 'abc' },
|
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema]
|
||||||
name: 'Model Auth',
|
renderModal({ mode: ModelModalModeEnum.configCustomModel })
|
||||||
model: 'gpt-4',
|
expect(screen.getByText('common.modelProvider.auth.addModel')).toBeInTheDocument()
|
||||||
model_type: ModelTypeEnum.textGeneration,
|
})
|
||||||
})
|
|
||||||
})
|
it('should validate and fail save if form is invalid in configCustomModel mode', async () => {
|
||||||
expect(configModelCredential.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Model Auth', api_key: 'abc' })
|
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema]
|
||||||
configModelCredential.unmount()
|
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' }))
|
||||||
|
|
||||||
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Provider Auth', api_key: 'provider-key' } }]
|
|
||||||
const configProviderCredential = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
|
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
|
||||||
credential_id: undefined,
|
credential_id: undefined,
|
||||||
credentials: { api_key: 'provider-key' },
|
credentials: { api_key: 'sk-test' },
|
||||||
name: 'Provider Auth',
|
name: 'test_auth',
|
||||||
|
model: 'test',
|
||||||
|
model_type: ModelTypeEnum.textGeneration,
|
||||||
})
|
})
|
||||||
|
expect(props.onSave).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
configProviderCredential.unmount()
|
})
|
||||||
|
|
||||||
const addToModelList = renderModal({
|
it('should save credential only in standard configProviderCredential mode', async () => {
|
||||||
mode: ModelModalModeEnum.addCustomModelToModelList,
|
const { onSave } = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
|
||||||
model,
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||||
})
|
|
||||||
fireEvent.click(screen.getByText('Choose Existing'))
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
|
||||||
expect(mockHandlers.handleActiveCredential).toHaveBeenCalledWith({ credential_id: 'existing' }, model)
|
|
||||||
expect(addToModelList.onCancel).toHaveBeenCalled()
|
|
||||||
addToModelList.unmount()
|
|
||||||
|
|
||||||
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'New Auth', api_key: 'new-key' } }]
|
|
||||||
const addToModelListWithNew = renderModal({
|
|
||||||
mode: ModelModalModeEnum.addCustomModelToModelList,
|
|
||||||
model,
|
|
||||||
})
|
|
||||||
fireEvent.click(screen.getByText('Add New'))
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
|
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
|
||||||
credential_id: undefined,
|
credential_id: undefined,
|
||||||
credentials: { api_key: 'new-key' },
|
credentials: { api_key: 'sk-test' },
|
||||||
name: 'New Auth',
|
name: 'test_auth',
|
||||||
model: 'gpt-4',
|
})
|
||||||
|
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,
|
model_type: ModelTypeEnum.textGeneration,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
addToModelListWithNew.unmount()
|
})
|
||||||
|
|
||||||
mockFormState.responses = [{ isCheckValidated: false, values: {} }]
|
it('should open and confirm deletion of credential', () => {
|
||||||
const invalidSave = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
|
mockState.credentialData = { credentials: { api_key: '123' }, available_credentials: [] }
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
mockState.formValues = { api_key: '123' } // To trigger isEditMode = true
|
||||||
await waitFor(() => {
|
const credential = { credential_id: 'c1' } as unknown as Credential
|
||||||
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledTimes(4)
|
renderModal({ credential })
|
||||||
})
|
|
||||||
invalidSave.unmount()
|
|
||||||
|
|
||||||
mockState.credentialData = { credentials: { api_key: 'value' }, available_credentials: [] }
|
// Open Delete Confirm
|
||||||
mockState.formValues = { api_key: 'value' }
|
|
||||||
const removable = renderModal({ credential: { credential_id: 'remove-1' } })
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' }))
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' }))
|
||||||
expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith({ credential_id: 'remove-1' }, undefined)
|
expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith(credential, undefined)
|
||||||
removable.unmount()
|
|
||||||
|
// 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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { vi } from 'vitest'
|
|
||||||
import ModelParameterModal from './index'
|
import ModelParameterModal from './index'
|
||||||
|
|
||||||
let isAPIKeySet = true
|
let isAPIKeySet = true
|
||||||
let parameterRules = [
|
let parameterRules: Array<Record<string, unknown>> | undefined = [
|
||||||
{
|
{
|
||||||
name: 'temperature',
|
name: 'temperature',
|
||||||
label: { en_US: 'Temperature' },
|
label: { en_US: 'Temperature' },
|
||||||
@ -62,42 +61,17 @@ vi.mock('../hooks', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock PortalToFollowElem components to control visibility and simplify testing
|
|
||||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
|
|
||||||
return {
|
|
||||||
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div data-testid="portal-wrapper">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
|
||||||
<div data-testid="portal-trigger" onClick={onClick}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className: string }) => (
|
|
||||||
<div data-testid="portal-content" className={className}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock('./parameter-item', () => ({
|
vi.mock('./parameter-item', () => ({
|
||||||
default: ({ parameterRule, value, onChange, onSwitch }: { parameterRule: { name: string, label: { en_US: string } }, value: string | number, onChange: (v: number) => void, onSwitch: (checked: boolean, val: unknown) => void }) => (
|
default: ({ parameterRule, onChange, onSwitch }: {
|
||||||
|
parameterRule: { name: string, label: { en_US: string } }
|
||||||
|
onChange: (v: number) => void
|
||||||
|
onSwitch: (checked: boolean, val: unknown) => void
|
||||||
|
}) => (
|
||||||
<div data-testid={`param-${parameterRule.name}`}>
|
<div data-testid={`param-${parameterRule.name}`}>
|
||||||
{parameterRule.label.en_US}
|
{parameterRule.label.en_US}
|
||||||
<input
|
<button onClick={() => onChange(0.9)}>Change</button>
|
||||||
aria-label={parameterRule.name}
|
<button onClick={() => onSwitch(false, undefined)}>Remove</button>
|
||||||
value={value || ''}
|
<button onClick={() => onSwitch(true, 'assigned')}>Add</button>
|
||||||
onChange={e => onChange(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
<button onClick={() => onSwitch?.(false, undefined)}>Remove</button>
|
|
||||||
<button onClick={() => onSwitch?.(true, 'assigned')}>Add</button>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
@ -105,7 +79,6 @@ vi.mock('./parameter-item', () => ({
|
|||||||
vi.mock('../model-selector', () => ({
|
vi.mock('../model-selector', () => ({
|
||||||
default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => (
|
default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => (
|
||||||
<div data-testid="model-selector">
|
<div data-testid="model-selector">
|
||||||
Model Selector
|
|
||||||
<button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}>Select GPT-4.1</button>
|
<button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}>Select GPT-4.1</button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -121,16 +94,11 @@ vi.mock('./trigger', () => ({
|
|||||||
default: () => <button>Open Settings</button>,
|
default: () => <button>Open Settings</button>,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/classnames', () => ({
|
|
||||||
cn: (...args: (string | undefined | null | false)[]) => args.filter(Boolean).join(' '),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock config
|
|
||||||
vi.mock('@/config', async (importOriginal) => {
|
vi.mock('@/config', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('@/config')>()
|
const actual = await importOriginal<typeof import('@/config')>()
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
PROVIDER_WITH_PRESET_TONE: ['openai'], // ensure presets mock renders
|
PROVIDER_WITH_PRESET_TONE: ['openai'],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -188,21 +156,19 @@ describe('ModelParameterModal', () => {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render trigger and content', () => {
|
it('should render trigger and open modal content when trigger is clicked', () => {
|
||||||
render(<ModelParameterModal {...defaultProps} />)
|
render(<ModelParameterModal {...defaultProps} />)
|
||||||
|
|
||||||
expect(screen.getByText('Open Settings')).toBeInTheDocument()
|
fireEvent.click(screen.getByText('Open Settings'))
|
||||||
expect(screen.getByText('Temperature')).toBeInTheDocument()
|
|
||||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
expect(screen.getByTestId('param-temperature')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should update params when changed and handle switch add/remove', () => {
|
it('should call onCompletionParamsChange when parameter changes and switch actions happen', () => {
|
||||||
render(<ModelParameterModal {...defaultProps} />)
|
render(<ModelParameterModal {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Open Settings'))
|
||||||
|
|
||||||
const input = screen.getByLabelText('temperature')
|
fireEvent.click(screen.getByText('Change'))
|
||||||
fireEvent.change(input, { target: { value: '0.9' } })
|
|
||||||
|
|
||||||
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
|
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
|
||||||
...defaultProps.completionParams,
|
...defaultProps.completionParams,
|
||||||
temperature: 0.9,
|
temperature: 0.9,
|
||||||
@ -218,51 +184,18 @@ describe('ModelParameterModal', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle preset selection', () => {
|
it('should call onCompletionParamsChange when preset is selected', () => {
|
||||||
render(<ModelParameterModal {...defaultProps} />)
|
render(<ModelParameterModal {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Open Settings'))
|
||||||
fireEvent.click(screen.getByText('Preset 1'))
|
fireEvent.click(screen.getByText('Preset 1'))
|
||||||
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalled()
|
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle debug mode toggle', () => {
|
it('should call setModel when model selector picks another model', () => {
|
||||||
const { rerender } = render(<ModelParameterModal {...defaultProps} />)
|
render(<ModelParameterModal {...defaultProps} />)
|
||||||
const toggle = screen.getByText(/debugAsMultipleModel/i)
|
fireEvent.click(screen.getByText('Open Settings'))
|
||||||
fireEvent.click(toggle)
|
|
||||||
expect(defaultProps.onDebugWithMultipleModelChange).toHaveBeenCalled()
|
|
||||||
|
|
||||||
rerender(<ModelParameterModal {...defaultProps} debugWithMultipleModel />)
|
|
||||||
expect(screen.getByText(/debugAsSingleModel/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
it('should handle custom renderTrigger', () => {
|
|
||||||
const renderTrigger = vi.fn().mockReturnValue(<div>Custom Trigger</div>)
|
|
||||||
render(<ModelParameterModal {...defaultProps} renderTrigger={renderTrigger} readonly />)
|
|
||||||
|
|
||||||
expect(screen.getByText('Custom Trigger')).toBeInTheDocument()
|
|
||||||
expect(renderTrigger).toHaveBeenCalled()
|
|
||||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
|
||||||
expect(renderTrigger).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle model selection and advanced mode parameters', () => {
|
|
||||||
parameterRules = [
|
|
||||||
{
|
|
||||||
name: 'temperature',
|
|
||||||
label: { en_US: 'Temperature' },
|
|
||||||
type: 'float',
|
|
||||||
default: 0.7,
|
|
||||||
min: 0,
|
|
||||||
max: 1,
|
|
||||||
help: { en_US: 'Control randomness' },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const { rerender } = render(<ModelParameterModal {...defaultProps} />)
|
|
||||||
expect(screen.getByTestId('param-temperature')).toBeInTheDocument()
|
|
||||||
|
|
||||||
rerender(<ModelParameterModal {...defaultProps} isAdvancedMode />)
|
|
||||||
expect(screen.getByTestId('param-stop')).toBeInTheDocument()
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Select GPT-4.1'))
|
fireEvent.click(screen.getByText('Select GPT-4.1'))
|
||||||
|
|
||||||
expect(defaultProps.setModel).toHaveBeenCalledWith({
|
expect(defaultProps.setModel).toHaveBeenCalledWith({
|
||||||
modelId: 'gpt-4.1',
|
modelId: 'gpt-4.1',
|
||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
@ -270,4 +203,32 @@ describe('ModelParameterModal', () => {
|
|||||||
features: ['vision', 'tool-call'],
|
features: ['vision', 'tool-call'],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should toggle debug mode when debug footer is clicked', () => {
|
||||||
|
render(<ModelParameterModal {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Open Settings'))
|
||||||
|
fireEvent.click(screen.getByText(/debugAsMultipleModel/i))
|
||||||
|
expect(defaultProps.onDebugWithMultipleModelChange).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render loading state when parameter rules are loading', () => {
|
||||||
|
isRulesLoading = true
|
||||||
|
render(<ModelParameterModal {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Open Settings'))
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not open content when readonly is true', () => {
|
||||||
|
render(<ModelParameterModal {...defaultProps} readonly />)
|
||||||
|
fireEvent.click(screen.getByText('Open Settings'))
|
||||||
|
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render no parameter items when rules are undefined', () => {
|
||||||
|
parameterRules = undefined
|
||||||
|
render(<ModelParameterModal {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Open Settings'))
|
||||||
|
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,238 +1,182 @@
|
|||||||
import type { ModelParameterRule } from '../declarations'
|
import type { ModelParameterRule } from '../declarations'
|
||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { vi } from 'vitest'
|
|
||||||
import ParameterItem from './parameter-item'
|
import ParameterItem from './parameter-item'
|
||||||
|
|
||||||
vi.mock('../hooks', () => ({
|
vi.mock('../hooks', () => ({
|
||||||
useLanguage: () => 'en_US',
|
useLanguage: () => 'en_US',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/radio', () => {
|
|
||||||
const Radio = ({ children, value }: { children: React.ReactNode, value: boolean }) => <button data-testid={`radio-${value}`}>{children}</button>
|
|
||||||
Radio.Group = ({ children, onChange }: { children: React.ReactNode, onChange: (value: boolean) => void }) => (
|
|
||||||
<div>
|
|
||||||
{children}
|
|
||||||
<button onClick={() => onChange(true)}>Select True</button>
|
|
||||||
<button onClick={() => onChange(false)}>Select False</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
return { default: Radio }
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock('@/app/components/base/select', () => ({
|
|
||||||
SimpleSelect: ({ onSelect, items }: { onSelect: (item: { value: string }) => void, items: { value: string, name: string }[] }) => (
|
|
||||||
<select onChange={e => onSelect({ value: e.target.value })}>
|
|
||||||
{items.map(item => (
|
|
||||||
<option key={item.value} value={item.value}>{item.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/app/components/base/slider', () => ({
|
vi.mock('@/app/components/base/slider', () => ({
|
||||||
default: ({ value, onChange }: { value: number, onChange: (val: number) => void }) => (
|
default: ({ onChange }: { onChange: (v: number) => void }) => (
|
||||||
<input type="range" value={value} onChange={e => onChange(Number(e.target.value))} />
|
<button onClick={() => onChange(2)} data-testid="slider-btn">Slide 2</button>
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/app/components/base/switch', () => ({
|
|
||||||
default: ({ onChange, value }: { onChange: (val: boolean) => void, value: boolean }) => (
|
|
||||||
<button onClick={() => onChange(!value)}>Switch</button>
|
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/tag-input', () => ({
|
vi.mock('@/app/components/base/tag-input', () => ({
|
||||||
default: ({ onChange }: { onChange: (val: string[]) => void }) => (
|
default: ({ onChange }: { onChange: (v: string[]) => void }) => (
|
||||||
<input onChange={e => onChange(e.target.value.split(','))} />
|
<button onClick={() => onChange(['tag1', 'tag2'])} data-testid="tag-input">Tag</button>
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/tooltip', () => ({
|
|
||||||
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('ParameterItem', () => {
|
describe('ParameterItem', () => {
|
||||||
const createRule = (overrides: Partial<ModelParameterRule> = {}): ModelParameterRule => ({
|
const createRule = (overrides: Partial<ModelParameterRule> = {}): ModelParameterRule => ({
|
||||||
name: 'temp',
|
name: 'temp',
|
||||||
label: { en_US: 'Temperature', zh_Hans: 'Temperature' },
|
label: { en_US: 'Temperature', zh_Hans: 'Temperature' },
|
||||||
type: 'float',
|
type: 'float',
|
||||||
min: 0,
|
|
||||||
max: 1,
|
|
||||||
help: { en_US: 'Help text', zh_Hans: 'Help text' },
|
help: { en_US: 'Help text', zh_Hans: 'Help text' },
|
||||||
required: false,
|
required: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
})
|
})
|
||||||
|
|
||||||
const createProps = (overrides: {
|
|
||||||
parameterRule?: ModelParameterRule
|
|
||||||
value?: number | string | boolean | string[]
|
|
||||||
} = {}) => {
|
|
||||||
const onChange = vi.fn()
|
|
||||||
const onSwitch = vi.fn()
|
|
||||||
return {
|
|
||||||
parameterRule: createRule(),
|
|
||||||
value: 0.7,
|
|
||||||
onChange,
|
|
||||||
onSwitch,
|
|
||||||
...overrides,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render float input with slider', () => {
|
// Float tests
|
||||||
const props = createProps()
|
it('should render float controls and clamp numeric input to max', () => {
|
||||||
const { rerender } = render(<ParameterItem {...props} />)
|
const onChange = vi.fn()
|
||||||
|
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 1 })} value={0.7} onChange={onChange} />)
|
||||||
expect(screen.getByText('Temperature')).toBeInTheDocument()
|
|
||||||
const input = screen.getByRole('spinbutton')
|
const input = screen.getByRole('spinbutton')
|
||||||
fireEvent.change(input, { target: { value: '0.8' } })
|
|
||||||
expect(props.onChange).toHaveBeenCalledWith(0.8)
|
|
||||||
|
|
||||||
fireEvent.change(input, { target: { value: '1.4' } })
|
fireEvent.change(input, { target: { value: '1.4' } })
|
||||||
expect(props.onChange).toHaveBeenCalledWith(1)
|
expect(onChange).toHaveBeenCalledWith(1)
|
||||||
|
expect(screen.getByTestId('slider-btn')).toBeInTheDocument()
|
||||||
fireEvent.change(input, { target: { value: '-0.2' } })
|
|
||||||
expect(props.onChange).toHaveBeenCalledWith(0)
|
|
||||||
|
|
||||||
const slider = screen.getByRole('slider')
|
|
||||||
fireEvent.change(slider, { target: { value: '2' } })
|
|
||||||
expect(props.onChange).toHaveBeenCalledWith(1)
|
|
||||||
|
|
||||||
fireEvent.change(slider, { target: { value: '-1' } })
|
|
||||||
expect(props.onChange).toHaveBeenCalledWith(0)
|
|
||||||
|
|
||||||
fireEvent.change(slider, { target: { value: '0.4' } })
|
|
||||||
expect(props.onChange).toHaveBeenCalledWith(0.4)
|
|
||||||
|
|
||||||
fireEvent.blur(input)
|
|
||||||
expect(input).toHaveValue(0.7)
|
|
||||||
|
|
||||||
const minBoundedProps = createProps({
|
|
||||||
parameterRule: createRule({ type: 'float', min: 1, max: 2 }),
|
|
||||||
value: 1.5,
|
|
||||||
})
|
|
||||||
rerender(<ParameterItem {...minBoundedProps} />)
|
|
||||||
fireEvent.change(screen.getByRole('slider'), { target: { value: '0' } })
|
|
||||||
expect(minBoundedProps.onChange).toHaveBeenCalledWith(1)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render boolean radio', () => {
|
it('should clamp float numeric input to min', () => {
|
||||||
const props = createProps({ parameterRule: createRule({ type: 'boolean', default: false }), value: true })
|
const onChange = vi.fn()
|
||||||
render(<ParameterItem {...props} />)
|
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0.1, max: 1 })} value={0.7} onChange={onChange} />)
|
||||||
|
const input = screen.getByRole('spinbutton')
|
||||||
|
fireEvent.change(input, { target: { value: '0.05' } })
|
||||||
|
expect(onChange).toHaveBeenCalledWith(0.1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Int tests
|
||||||
|
it('should render int controls and clamp numeric input', () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 10 })} value={5} onChange={onChange} />)
|
||||||
|
const input = screen.getByRole('spinbutton')
|
||||||
|
fireEvent.change(input, { target: { value: '15' } })
|
||||||
|
expect(onChange).toHaveBeenCalledWith(10)
|
||||||
|
fireEvent.change(input, { target: { value: '-5' } })
|
||||||
|
expect(onChange).toHaveBeenCalledWith(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should adjust step based on max for int type', () => {
|
||||||
|
const { rerender } = render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 50 })} value={5} />)
|
||||||
|
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '1')
|
||||||
|
|
||||||
|
rerender(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 500 })} value={50} />)
|
||||||
|
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '10')
|
||||||
|
|
||||||
|
rerender(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 2000 })} value={50} />)
|
||||||
|
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '100')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render int input without slider if min or max is missing', () => {
|
||||||
|
render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0 })} value={5} />)
|
||||||
|
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
|
||||||
|
// No max -> precision step
|
||||||
|
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Slider events (uses generic value mock for slider)
|
||||||
|
it('should handle slide change and clamp values', () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 10 })} value={0.7} onChange={onChange} />)
|
||||||
|
|
||||||
|
// Test that the actual slider triggers the onChange logic correctly
|
||||||
|
// The implementation of Slider uses onChange(val) directly via the mock
|
||||||
|
fireEvent.click(screen.getByTestId('slider-btn'))
|
||||||
|
expect(onChange).toHaveBeenCalledWith(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Text & String tests
|
||||||
|
it('should render exact string input and propagate text changes', () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
render(<ParameterItem parameterRule={createRule({ type: 'string', name: 'prompt' })} value="initial" onChange={onChange} />)
|
||||||
|
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'updated' } })
|
||||||
|
expect(onChange).toHaveBeenCalledWith('updated')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render textarea for text type', () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const { container } = render(<ParameterItem parameterRule={createRule({ type: 'text' })} value="long text" onChange={onChange} />)
|
||||||
|
const textarea = container.querySelector('textarea')!
|
||||||
|
expect(textarea).toBeInTheDocument()
|
||||||
|
fireEvent.change(textarea, { target: { value: 'new long text' } })
|
||||||
|
expect(onChange).toHaveBeenCalledWith('new long text')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render select for string with options', () => {
|
||||||
|
render(<ParameterItem parameterRule={createRule({ type: 'string', options: ['a', 'b'] })} value="a" />)
|
||||||
|
// SimpleSelect renders an element with text 'a'
|
||||||
|
expect(screen.getByText('a')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tag Tests
|
||||||
|
it('should render tag input for tag type', () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
render(<ParameterItem parameterRule={createRule({ type: 'tag', tagPlaceholder: { en_US: 'placeholder', zh_Hans: 'placeholder' } })} value={['a']} onChange={onChange} />)
|
||||||
|
expect(screen.getByText('placeholder')).toBeInTheDocument()
|
||||||
|
// Trigger mock tag input
|
||||||
|
fireEvent.click(screen.getByTestId('tag-input'))
|
||||||
|
expect(onChange).toHaveBeenCalledWith(['tag1', 'tag2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Boolean tests
|
||||||
|
it('should render boolean radios and update value on click', () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
render(<ParameterItem parameterRule={createRule({ type: 'boolean', default: false })} value={true} onChange={onChange} />)
|
||||||
|
fireEvent.click(screen.getByText('False'))
|
||||||
|
expect(onChange).toHaveBeenCalledWith(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Switch tests
|
||||||
|
it('should call onSwitch with current value when optional switch is toggled off', () => {
|
||||||
|
const onSwitch = vi.fn()
|
||||||
|
render(<ParameterItem parameterRule={createRule()} value={0.7} onSwitch={onSwitch} />)
|
||||||
|
fireEvent.click(screen.getByRole('switch'))
|
||||||
|
expect(onSwitch).toHaveBeenCalledWith(false, 0.7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render switch if required or name is stop', () => {
|
||||||
|
const { rerender } = render(<ParameterItem parameterRule={createRule({ required: true as unknown as false })} value={1} />)
|
||||||
|
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||||
|
rerender(<ParameterItem parameterRule={createRule({ name: 'stop', required: false })} value={1} />)
|
||||||
|
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Default Value Fallbacks (rendering without value)
|
||||||
|
it('should use default values if value is undefined', () => {
|
||||||
|
const { rerender } = render(<ParameterItem parameterRule={createRule({ type: 'float', default: 0.5 })} />)
|
||||||
|
expect(screen.getByRole('spinbutton')).toHaveValue(0.5)
|
||||||
|
|
||||||
|
rerender(<ParameterItem parameterRule={createRule({ type: 'string', default: 'hello' })} />)
|
||||||
|
expect(screen.getByRole('textbox')).toHaveValue('hello')
|
||||||
|
|
||||||
|
rerender(<ParameterItem parameterRule={createRule({ type: 'boolean', default: true })} />)
|
||||||
expect(screen.getByText('True')).toBeInTheDocument()
|
expect(screen.getByText('True')).toBeInTheDocument()
|
||||||
fireEvent.click(screen.getByText('Select False'))
|
expect(screen.getByText('False')).toBeInTheDocument()
|
||||||
expect(props.onChange).toHaveBeenCalledWith(false)
|
|
||||||
|
// Without default
|
||||||
|
rerender(<ParameterItem parameterRule={createRule({ type: 'float' })} />) // min is 0 by default in createRule
|
||||||
|
expect(screen.getByRole('spinbutton')).toHaveValue(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render string input and select options', () => {
|
// Input Blur
|
||||||
const props = createProps({ parameterRule: createRule({ type: 'string' }), value: 'test' })
|
it('should reset input to actual bound value on blur', () => {
|
||||||
const { rerender } = render(<ParameterItem {...props} />)
|
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 1 })} />)
|
||||||
const input = screen.getByRole('textbox')
|
const input = screen.getByRole('spinbutton')
|
||||||
fireEvent.change(input, { target: { value: 'new' } })
|
// change local state (which triggers clamp internally to let's say 1.4 -> 1 but leaves input text, though handleInputChange updates local state)
|
||||||
expect(props.onChange).toHaveBeenCalledWith('new')
|
// Actually our test fires a change so localValue = 1, then blur sets it
|
||||||
|
fireEvent.change(input, { target: { value: '5' } })
|
||||||
const selectProps = createProps({
|
fireEvent.blur(input)
|
||||||
parameterRule: createRule({ type: 'string', options: ['opt1', 'opt2'] }),
|
expect(input).toHaveValue(1)
|
||||||
value: 'opt1',
|
|
||||||
})
|
|
||||||
rerender(<ParameterItem {...selectProps} />)
|
|
||||||
const select = screen.getByRole('combobox')
|
|
||||||
fireEvent.change(select, { target: { value: 'opt2' } })
|
|
||||||
expect(selectProps.onChange).toHaveBeenCalledWith('opt2')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle switch toggle', () => {
|
// Unsupported
|
||||||
const props = createProps()
|
it('should render no input for unsupported parameter type', () => {
|
||||||
let view = render(<ParameterItem {...props} />)
|
render(<ParameterItem parameterRule={createRule({ type: 'unsupported' as unknown as string })} value={0.7} />)
|
||||||
fireEvent.click(screen.getByText('Switch'))
|
|
||||||
expect(props.onSwitch).toHaveBeenCalledWith(false, 0.7)
|
|
||||||
|
|
||||||
const intDefaultProps = createProps({
|
|
||||||
parameterRule: createRule({ type: 'int', min: 0, default: undefined }),
|
|
||||||
value: undefined,
|
|
||||||
})
|
|
||||||
view.unmount()
|
|
||||||
view = render(<ParameterItem {...intDefaultProps} />)
|
|
||||||
fireEvent.click(screen.getByText('Switch'))
|
|
||||||
expect(intDefaultProps.onSwitch).toHaveBeenCalledWith(true, 0)
|
|
||||||
|
|
||||||
const stringDefaultProps = createProps({
|
|
||||||
parameterRule: createRule({ type: 'string', default: 'preset-value' }),
|
|
||||||
value: undefined,
|
|
||||||
})
|
|
||||||
view.unmount()
|
|
||||||
view = render(<ParameterItem {...stringDefaultProps} />)
|
|
||||||
fireEvent.click(screen.getByText('Switch'))
|
|
||||||
expect(stringDefaultProps.onSwitch).toHaveBeenCalledWith(true, 'preset-value')
|
|
||||||
|
|
||||||
const booleanDefaultProps = createProps({
|
|
||||||
parameterRule: createRule({ type: 'boolean', default: true }),
|
|
||||||
value: undefined,
|
|
||||||
})
|
|
||||||
view.unmount()
|
|
||||||
view = render(<ParameterItem {...booleanDefaultProps} />)
|
|
||||||
fireEvent.click(screen.getByText('Switch'))
|
|
||||||
expect(booleanDefaultProps.onSwitch).toHaveBeenCalledWith(true, true)
|
|
||||||
|
|
||||||
const tagDefaultProps = createProps({
|
|
||||||
parameterRule: createRule({ type: 'tag', default: ['one'] }),
|
|
||||||
value: undefined,
|
|
||||||
})
|
|
||||||
view.unmount()
|
|
||||||
const tagView = render(<ParameterItem {...tagDefaultProps} />)
|
|
||||||
fireEvent.click(screen.getByText('Switch'))
|
|
||||||
expect(tagDefaultProps.onSwitch).toHaveBeenCalledWith(true, ['one'])
|
|
||||||
|
|
||||||
const zeroValueProps = createProps({
|
|
||||||
parameterRule: createRule({ type: 'float', default: 0.5 }),
|
|
||||||
value: 0,
|
|
||||||
})
|
|
||||||
tagView.unmount()
|
|
||||||
render(<ParameterItem {...zeroValueProps} />)
|
|
||||||
fireEvent.click(screen.getByText('Switch'))
|
|
||||||
expect(zeroValueProps.onSwitch).toHaveBeenCalledWith(false, 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support text and tag parameter interactions', () => {
|
|
||||||
const textProps = createProps({
|
|
||||||
parameterRule: createRule({ type: 'text', name: 'prompt' }),
|
|
||||||
value: 'initial prompt',
|
|
||||||
})
|
|
||||||
const { rerender } = render(<ParameterItem {...textProps} />)
|
|
||||||
const textarea = screen.getByRole('textbox')
|
|
||||||
fireEvent.change(textarea, { target: { value: 'rewritten prompt' } })
|
|
||||||
expect(textProps.onChange).toHaveBeenCalledWith('rewritten prompt')
|
|
||||||
|
|
||||||
const tagProps = createProps({
|
|
||||||
parameterRule: createRule({
|
|
||||||
type: 'tag',
|
|
||||||
name: 'tags',
|
|
||||||
tagPlaceholder: { en_US: 'Tag hint', zh_Hans: 'Tag hint' },
|
|
||||||
}),
|
|
||||||
value: ['alpha'],
|
|
||||||
})
|
|
||||||
rerender(<ParameterItem {...tagProps} />)
|
|
||||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'one,two' } })
|
|
||||||
expect(tagProps.onChange).toHaveBeenCalledWith(['one', 'two'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support int parameters and unknown type fallback', () => {
|
|
||||||
const intProps = createProps({
|
|
||||||
parameterRule: createRule({ type: 'int', min: 0, max: 500, default: 100 }),
|
|
||||||
value: 100,
|
|
||||||
})
|
|
||||||
const { rerender } = render(<ParameterItem {...intProps} />)
|
|
||||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '350' } })
|
|
||||||
expect(intProps.onChange).toHaveBeenCalledWith(350)
|
|
||||||
|
|
||||||
const unknownTypeProps = createProps({
|
|
||||||
parameterRule: createRule({ type: 'unsupported' }),
|
|
||||||
value: 0.7,
|
|
||||||
})
|
|
||||||
rerender(<ParameterItem {...unknownTypeProps} />)
|
|
||||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
|
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,19 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
|||||||
import { vi } from 'vitest'
|
import { vi } from 'vitest'
|
||||||
import PresetsParameter from './presets-parameter'
|
import PresetsParameter from './presets-parameter'
|
||||||
|
|
||||||
vi.mock('@/app/components/base/dropdown', () => ({
|
|
||||||
default: ({ renderTrigger, items, onSelect }: { renderTrigger: (open: boolean) => React.ReactNode, items: { value: number, text: string }[], onSelect: (item: { value: number }) => void }) => (
|
|
||||||
<div>
|
|
||||||
{renderTrigger(false)}
|
|
||||||
{items.map(item => (
|
|
||||||
<button key={item.value} onClick={() => onSelect(item)}>
|
|
||||||
{item.text}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('PresetsParameter', () => {
|
describe('PresetsParameter', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
@ -26,7 +13,39 @@ describe('PresetsParameter', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('common.modelProvider.loadPresets')).toBeInTheDocument()
|
expect(screen.getByText('common.modelProvider.loadPresets')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i }))
|
||||||
fireEvent.click(screen.getByText('common.model.tone.Creative'))
|
fireEvent.click(screen.getByText('common.model.tone.Creative'))
|
||||||
expect(onSelect).toHaveBeenCalledWith(1)
|
expect(onSelect).toHaveBeenCalledWith(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// open=true: trigger has bg-state-base-hover class
|
||||||
|
it('should apply hover background class when open is true', () => {
|
||||||
|
render(<PresetsParameter onSelect={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i }))
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i })
|
||||||
|
expect(button).toHaveClass('bg-state-base-hover')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tone map branch 2: Balanced → Scales02 icon
|
||||||
|
it('should call onSelect with tone id 2 when Balanced is clicked', () => {
|
||||||
|
const onSelect = vi.fn()
|
||||||
|
render(<PresetsParameter onSelect={onSelect} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i }))
|
||||||
|
fireEvent.click(screen.getByText('common.model.tone.Balanced'))
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tone map branch 3: Precise → Target04 icon
|
||||||
|
it('should call onSelect with tone id 3 when Precise is clicked', () => {
|
||||||
|
const onSelect = vi.fn()
|
||||||
|
render(<PresetsParameter onSelect={onSelect} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i }))
|
||||||
|
fireEvent.click(screen.getByText('common.model.tone.Precise'))
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(3)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
import { vi } from 'vitest'
|
import { vi } from 'vitest'
|
||||||
import StatusIndicators from './status-indicators'
|
import StatusIndicators from './status-indicators'
|
||||||
|
|
||||||
@ -8,10 +9,6 @@ vi.mock('@/service/use-plugins', () => ({
|
|||||||
useInstalledPluginList: () => ({ data: { plugins: installedPlugins } }),
|
useInstalledPluginList: () => ({ data: { plugins: installedPlugins } }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/tooltip', () => ({
|
|
||||||
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({
|
vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({
|
||||||
SwitchPluginVersion: ({ uniqueIdentifier }: { uniqueIdentifier: string }) => <div>{`SwitchVersion:${uniqueIdentifier}`}</div>,
|
SwitchPluginVersion: ({ uniqueIdentifier }: { uniqueIdentifier: string }) => <div>{`SwitchVersion:${uniqueIdentifier}`}</div>,
|
||||||
}))
|
}))
|
||||||
@ -38,57 +35,95 @@ describe('StatusIndicators', () => {
|
|||||||
expect(container).toBeEmptyDOMElement()
|
expect(container).toBeEmptyDOMElement()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render warning states when provider model is disabled', () => {
|
it('should render deprecated tooltip when provider model is disabled and in model list', async () => {
|
||||||
const parentClick = vi.fn()
|
const user = userEvent.setup()
|
||||||
const { rerender } = render(
|
const { container } = render(
|
||||||
<div onClick={parentClick}>
|
<StatusIndicators
|
||||||
<StatusIndicators
|
needsConfiguration={false}
|
||||||
needsConfiguration={false}
|
modelProvider={true}
|
||||||
modelProvider={true}
|
inModelList={true}
|
||||||
inModelList={true}
|
disabled={true}
|
||||||
disabled={true}
|
pluginInfo={null}
|
||||||
pluginInfo={null}
|
t={t}
|
||||||
t={t}
|
/>,
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
)
|
)
|
||||||
expect(screen.getByText('nodes.agent.modelSelectorTooltips.deprecated')).toBeInTheDocument()
|
|
||||||
|
|
||||||
rerender(
|
const trigger = container.querySelector('[data-state]')
|
||||||
<div onClick={parentClick}>
|
expect(trigger).toBeInTheDocument()
|
||||||
<StatusIndicators
|
await user.hover(trigger as HTMLElement)
|
||||||
needsConfiguration={false}
|
|
||||||
modelProvider={true}
|
|
||||||
inModelList={false}
|
|
||||||
disabled={true}
|
|
||||||
pluginInfo={null}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
)
|
|
||||||
expect(screen.getByText('nodes.agent.modelNotSupport.title')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('nodes.agent.linkToPlugin').closest('a')).toHaveAttribute('href', '/plugins')
|
|
||||||
fireEvent.click(screen.getByText('nodes.agent.modelNotSupport.title'))
|
|
||||||
fireEvent.click(screen.getByText('nodes.agent.linkToPlugin'))
|
|
||||||
expect(parentClick).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
rerender(
|
expect(await screen.findByText('nodes.agent.modelSelectorTooltips.deprecated')).toBeInTheDocument()
|
||||||
<div onClick={parentClick}>
|
})
|
||||||
<StatusIndicators
|
|
||||||
needsConfiguration={false}
|
it('should render model-not-support tooltip when disabled model is not in model list and has no pluginInfo', async () => {
|
||||||
modelProvider={true}
|
const user = userEvent.setup()
|
||||||
inModelList={false}
|
const { container } = render(
|
||||||
disabled={true}
|
<StatusIndicators
|
||||||
pluginInfo={{ name: 'demo-plugin' }}
|
needsConfiguration={false}
|
||||||
t={t}
|
modelProvider={true}
|
||||||
/>
|
inModelList={false}
|
||||||
</div>,
|
disabled={true}
|
||||||
|
pluginInfo={null}
|
||||||
|
t={t}
|
||||||
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const trigger = container.querySelector('[data-state]')
|
||||||
|
expect(trigger).toBeInTheDocument()
|
||||||
|
await user.hover(trigger as HTMLElement)
|
||||||
|
|
||||||
|
expect(await screen.findByText('nodes.agent.modelNotSupport.title')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render switch plugin version when pluginInfo exists for disabled unsupported model', () => {
|
||||||
|
render(
|
||||||
|
<StatusIndicators
|
||||||
|
needsConfiguration={false}
|
||||||
|
modelProvider={true}
|
||||||
|
inModelList={false}
|
||||||
|
disabled={true}
|
||||||
|
pluginInfo={{ name: 'demo-plugin' }}
|
||||||
|
t={t}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
expect(screen.getByText('SwitchVersion:demo@1.0.0')).toBeInTheDocument()
|
expect(screen.getByText('SwitchVersion:demo@1.0.0')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render marketplace warning when provider is unavailable', () => {
|
it('should render nothing when needsConfiguration is true even with disabled and modelProvider', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<StatusIndicators
|
||||||
|
needsConfiguration={true}
|
||||||
|
modelProvider={true}
|
||||||
|
inModelList={true}
|
||||||
|
disabled={true}
|
||||||
|
pluginInfo={null}
|
||||||
|
t={t}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(container).toBeEmptyDOMElement()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render SwitchVersion with empty identifier when plugin is not in installed list', () => {
|
||||||
|
installedPlugins = []
|
||||||
|
|
||||||
render(
|
render(
|
||||||
|
<StatusIndicators
|
||||||
|
needsConfiguration={false}
|
||||||
|
modelProvider={true}
|
||||||
|
inModelList={false}
|
||||||
|
disabled={true}
|
||||||
|
pluginInfo={{ name: 'missing-plugin' }}
|
||||||
|
t={t}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('SwitchVersion:')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render marketplace warning tooltip when provider is unavailable', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = render(
|
||||||
<StatusIndicators
|
<StatusIndicators
|
||||||
needsConfiguration={false}
|
needsConfiguration={false}
|
||||||
modelProvider={false}
|
modelProvider={false}
|
||||||
@ -98,6 +133,11 @@ describe('StatusIndicators', () => {
|
|||||||
t={t}
|
t={t}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
expect(screen.getByText('nodes.agent.modelNotInMarketplace.title')).toBeInTheDocument()
|
|
||||||
|
const trigger = container.querySelector('[data-state]')
|
||||||
|
expect(trigger).toBeInTheDocument()
|
||||||
|
await user.hover(trigger as HTMLElement)
|
||||||
|
|
||||||
|
expect(await screen.findByText('nodes.agent.modelNotInMarketplace.title')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { ComponentProps } from 'react'
|
import type { ComponentProps } from 'react'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
import Trigger from './trigger'
|
import Trigger from './trigger'
|
||||||
|
|
||||||
vi.mock('../hooks', () => ({
|
vi.mock('../hooks', () => ({
|
||||||
@ -24,6 +25,10 @@ describe('Trigger', () => {
|
|||||||
const currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } } as unknown as ComponentProps<typeof Trigger>['currentProvider']
|
const currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } } as unknown as ComponentProps<typeof Trigger>['currentProvider']
|
||||||
const currentModel = { model: 'gpt-4' } as unknown as ComponentProps<typeof Trigger>['currentModel']
|
const currentModel = { model: 'gpt-4' } as unknown as ComponentProps<typeof Trigger>['currentModel']
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
it('should render initialized state', () => {
|
it('should render initialized state', () => {
|
||||||
render(
|
render(
|
||||||
<Trigger
|
<Trigger
|
||||||
@ -44,4 +49,92 @@ describe('Trigger', () => {
|
|||||||
)
|
)
|
||||||
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// isInWorkflow=true: workflow border class + RiArrowDownSLine arrow
|
||||||
|
it('should render workflow styles when isInWorkflow is true', () => {
|
||||||
|
// Act
|
||||||
|
const { container } = render(
|
||||||
|
<Trigger
|
||||||
|
currentProvider={currentProvider}
|
||||||
|
currentModel={currentModel}
|
||||||
|
isInWorkflow
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(container.firstChild).toHaveClass('border-workflow-block-parma-bg')
|
||||||
|
expect(container.firstChild).toHaveClass('bg-workflow-block-parma-bg')
|
||||||
|
expect(container.querySelectorAll('svg').length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
// disabled=true + hasDeprecated=true: AlertTriangle + deprecated tooltip
|
||||||
|
it('should show deprecated warning when disabled with hasDeprecated', () => {
|
||||||
|
// Act
|
||||||
|
render(
|
||||||
|
<Trigger
|
||||||
|
currentProvider={currentProvider}
|
||||||
|
currentModel={currentModel}
|
||||||
|
disabled
|
||||||
|
hasDeprecated
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert - AlertTriangle renders with warning color
|
||||||
|
const warningIcon = document.querySelector('.text-\\[\\#F79009\\]')
|
||||||
|
expect(warningIcon).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// disabled=true + modelDisabled=true: status text tooltip
|
||||||
|
it('should show model status tooltip when disabled with modelDisabled', () => {
|
||||||
|
// Act
|
||||||
|
render(
|
||||||
|
<Trigger
|
||||||
|
currentProvider={currentProvider}
|
||||||
|
currentModel={{ ...currentModel, status: 'no-configure' } as unknown as typeof currentModel}
|
||||||
|
disabled
|
||||||
|
modelDisabled
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert - AlertTriangle warning icon should be present
|
||||||
|
const warningIcon = document.querySelector('.text-\\[\\#F79009\\]')
|
||||||
|
expect(warningIcon).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render empty tooltip content when disabled without deprecated or modelDisabled', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = render(
|
||||||
|
<Trigger
|
||||||
|
currentProvider={currentProvider}
|
||||||
|
currentModel={currentModel}
|
||||||
|
disabled
|
||||||
|
hasDeprecated={false}
|
||||||
|
modelDisabled={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const warningIcon = document.querySelector('.text-\\[\\#F79009\\]')
|
||||||
|
expect(warningIcon).toBeInTheDocument()
|
||||||
|
const trigger = container.querySelector('[data-state]')
|
||||||
|
expect(trigger).toBeInTheDocument()
|
||||||
|
await user.hover(trigger as HTMLElement)
|
||||||
|
const tooltip = screen.queryByRole('tooltip')
|
||||||
|
if (tooltip)
|
||||||
|
expect(tooltip).toBeEmptyDOMElement()
|
||||||
|
expect(screen.queryByText('modelProvider.deprecated')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('No Configure')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// providerName not matching any provider: find() returns undefined
|
||||||
|
it('should render without crashing when providerName does not match any provider', () => {
|
||||||
|
// Act
|
||||||
|
render(
|
||||||
|
<Trigger
|
||||||
|
modelId="gpt-4"
|
||||||
|
providerName="unknown-provider"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -10,4 +10,22 @@ describe('EmptyTrigger', () => {
|
|||||||
render(<EmptyTrigger open={false} />)
|
render(<EmptyTrigger open={false} />)
|
||||||
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
|
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// open=true: hover bg class present
|
||||||
|
it('should apply hover background class when open is true', () => {
|
||||||
|
// Act
|
||||||
|
const { container } = render(<EmptyTrigger open={true} />)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(container.firstChild).toHaveClass('bg-components-input-bg-hover')
|
||||||
|
})
|
||||||
|
|
||||||
|
// className prop truthy: custom className appears on root
|
||||||
|
it('should apply custom className when provided', () => {
|
||||||
|
// Act
|
||||||
|
const { container } = render(<EmptyTrigger open={false} className="custom-class" />)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(container.firstChild).toHaveClass('custom-class')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -10,12 +10,13 @@ import PopupItem from './popup-item'
|
|||||||
|
|
||||||
const mockUpdateModelList = vi.hoisted(() => vi.fn())
|
const mockUpdateModelList = vi.hoisted(() => vi.fn())
|
||||||
const mockUpdateModelProviders = vi.hoisted(() => vi.fn())
|
const mockUpdateModelProviders = vi.hoisted(() => vi.fn())
|
||||||
|
const mockLanguageRef = vi.hoisted(() => ({ value: 'en_US' }))
|
||||||
|
|
||||||
vi.mock('../hooks', async () => {
|
vi.mock('../hooks', async () => {
|
||||||
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
useLanguage: () => 'en_US',
|
useLanguage: () => mockLanguageRef.value,
|
||||||
useUpdateModelList: () => mockUpdateModelList,
|
useUpdateModelList: () => mockUpdateModelList,
|
||||||
useUpdateModelProviders: () => mockUpdateModelProviders,
|
useUpdateModelProviders: () => mockUpdateModelProviders,
|
||||||
}
|
}
|
||||||
@ -69,6 +70,7 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({
|
|||||||
describe('PopupItem', () => {
|
describe('PopupItem', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
mockLanguageRef.value = 'en_US'
|
||||||
mockUseProviderContext.mockReturnValue({
|
mockUseProviderContext.mockReturnValue({
|
||||||
modelProviders: [{ provider: 'openai' }],
|
modelProviders: [{ provider: 'openai' }],
|
||||||
})
|
})
|
||||||
@ -144,4 +146,87 @@ describe('PopupItem', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not show check icon when model matches but provider does not', () => {
|
||||||
|
const defaultModel: DefaultModel = { provider: 'anthropic', model: 'gpt-4' }
|
||||||
|
render(
|
||||||
|
<PopupItem
|
||||||
|
defaultModel={defaultModel}
|
||||||
|
model={makeModel()}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const checkIcons = document.querySelectorAll('.h-4.w-4.shrink-0.text-text-accent')
|
||||||
|
expect(checkIcons.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show mode badge when model_properties.mode is absent', () => {
|
||||||
|
const modelItem = makeModelItem({ model_properties: {} })
|
||||||
|
render(
|
||||||
|
<PopupItem
|
||||||
|
model={makeModel({ models: [modelItem] })}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fall back to en_US label when current locale translation is empty', () => {
|
||||||
|
mockLanguageRef.value = 'zh_Hans'
|
||||||
|
const model = makeModel({
|
||||||
|
label: { en_US: 'English Label', zh_Hans: '' },
|
||||||
|
})
|
||||||
|
render(<PopupItem model={model} onSelect={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('English Label')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show context_size badge when absent', () => {
|
||||||
|
const modelItem = makeModelItem({ model_properties: { mode: 'chat' } })
|
||||||
|
render(
|
||||||
|
<PopupItem
|
||||||
|
model={makeModel({ models: [modelItem] })}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.queryByText(/K$/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show capabilities section when features are empty', () => {
|
||||||
|
const modelItem = makeModelItem({ features: [] })
|
||||||
|
render(
|
||||||
|
<PopupItem
|
||||||
|
model={makeModel({ models: [modelItem] })}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.queryByText('common.model.capabilities')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show capabilities for non-qualifying model types', () => {
|
||||||
|
const modelItem = makeModelItem({
|
||||||
|
model_type: ModelTypeEnum.tts,
|
||||||
|
features: [ModelFeatureEnum.vision],
|
||||||
|
})
|
||||||
|
render(
|
||||||
|
<PopupItem
|
||||||
|
model={makeModel({ models: [modelItem] })}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.queryByText('common.model.capabilities')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show en_US label when language is fr_FR and fr_FR key is absent', () => {
|
||||||
|
mockLanguageRef.value = 'fr_FR'
|
||||||
|
const model = makeModel({ label: { en_US: 'FallbackLabel', zh_Hans: 'FallbackLabel' } })
|
||||||
|
render(<PopupItem model={model} onSelect={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('FallbackLabel')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { Model, ModelItem } from '../declarations'
|
import type { Model, ModelItem } from '../declarations'
|
||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { tooltipManager } from '@/app/components/base/tooltip/TooltipManager'
|
||||||
import {
|
import {
|
||||||
ConfigurationMethodEnum,
|
ConfigurationMethodEnum,
|
||||||
ModelFeatureEnum,
|
ModelFeatureEnum,
|
||||||
@ -22,21 +23,6 @@ vi.mock('@/utils/tool-call', () => ({
|
|||||||
supportFunctionCall: mockSupportFunctionCall,
|
supportFunctionCall: mockSupportFunctionCall,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const mockCloseActiveTooltip = vi.hoisted(() => vi.fn())
|
|
||||||
vi.mock('@/app/components/base/tooltip/TooltipManager', () => ({
|
|
||||||
tooltipManager: {
|
|
||||||
closeActiveTooltip: mockCloseActiveTooltip,
|
|
||||||
register: vi.fn(),
|
|
||||||
clear: vi.fn(),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({
|
|
||||||
XCircle: ({ onClick }: { onClick?: () => void }) => (
|
|
||||||
<button type="button" aria-label="clear-search" onClick={onClick} />
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('../hooks', async () => {
|
vi.mock('../hooks', async () => {
|
||||||
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
||||||
return {
|
return {
|
||||||
@ -70,10 +56,13 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('Popup', () => {
|
describe('Popup', () => {
|
||||||
|
let closeActiveTooltipSpy: ReturnType<typeof vi.spyOn>
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockLanguage = 'en_US'
|
mockLanguage = 'en_US'
|
||||||
mockSupportFunctionCall.mockReturnValue(true)
|
mockSupportFunctionCall.mockReturnValue(true)
|
||||||
|
closeActiveTooltipSpy = vi.spyOn(tooltipManager, 'closeActiveTooltip')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should filter models by search and allow clearing search', () => {
|
it('should filter models by search and allow clearing search', () => {
|
||||||
@ -91,8 +80,9 @@ describe('Popup', () => {
|
|||||||
fireEvent.change(input, { target: { value: 'not-found' } })
|
fireEvent.change(input, { target: { value: 'not-found' } })
|
||||||
expect(screen.getByText('No model found for “not-found”')).toBeInTheDocument()
|
expect(screen.getByText('No model found for “not-found”')).toBeInTheDocument()
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'clear-search' }))
|
fireEvent.change(input, { target: { value: '' } })
|
||||||
expect((input as HTMLInputElement).value).toBe('')
|
expect((input as HTMLInputElement).value).toBe('')
|
||||||
|
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should filter by scope features including toolCall and non-toolCall checks', () => {
|
it('should filter by scope features including toolCall and non-toolCall checks', () => {
|
||||||
@ -168,6 +158,24 @@ describe('Popup', () => {
|
|||||||
expect(screen.getByText('openai')).toBeInTheDocument()
|
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should filter out model when features array exists but does not include required scopeFeature', () => {
|
||||||
|
const modelWithToolCallOnly = makeModel({
|
||||||
|
models: [makeModelItem({ features: [ModelFeatureEnum.toolCall] })],
|
||||||
|
})
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Popup
|
||||||
|
modelList={[modelWithToolCallOnly]}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onHide={vi.fn()}
|
||||||
|
scopeFeatures={[ModelFeatureEnum.vision]}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The model item should be filtered out because it has toolCall but not vision
|
||||||
|
expect(screen.queryByText('openai')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('should close tooltip on scroll', () => {
|
it('should close tooltip on scroll', () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<Popup
|
<Popup
|
||||||
@ -178,7 +186,7 @@ describe('Popup', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
fireEvent.scroll(container.firstElementChild as HTMLElement)
|
fireEvent.scroll(container.firstElementChild as HTMLElement)
|
||||||
expect(mockCloseActiveTooltip).toHaveBeenCalled()
|
expect(closeActiveTooltipSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should open provider settings when clicking footer link', () => {
|
it('should open provider settings when clicking footer link', () => {
|
||||||
@ -196,4 +204,35 @@ describe('Popup', () => {
|
|||||||
payload: 'provider',
|
payload: 'provider',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should call onHide when footer settings link is clicked', () => {
|
||||||
|
const mockOnHide = vi.fn()
|
||||||
|
render(
|
||||||
|
<Popup
|
||||||
|
modelList={[makeModel()]}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onHide={mockOnHide}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('common.model.settingsLink'))
|
||||||
|
|
||||||
|
expect(mockOnHide).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should match model label when searchText is non-empty and label key exists for current language', () => {
|
||||||
|
render(
|
||||||
|
<Popup
|
||||||
|
modelList={[makeModel()]}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onHide={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// GPT-4 label has en_US key, so modelItem.label[language] is defined
|
||||||
|
const input = screen.getByPlaceholderText('datasetSettings.form.searchModel')
|
||||||
|
fireEvent.change(input, { target: { value: 'gpt' } })
|
||||||
|
|
||||||
|
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { ModelProvider } from '../declarations'
|
import type { ModelProvider } from '../declarations'
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { ToastContext } from '@/app/components/base/toast/context'
|
||||||
import { changeModelProviderPriority } from '@/service/common'
|
import { changeModelProviderPriority } from '@/service/common'
|
||||||
import { ConfigurationMethodEnum } from '../declarations'
|
import { ConfigurationMethodEnum } from '../declarations'
|
||||||
import CredentialPanel from './credential-panel'
|
import CredentialPanel from './credential-panel'
|
||||||
@ -24,11 +25,15 @@ vi.mock('@/config', async (importOriginal) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
vi.mock('@/app/components/base/toast/context', () => ({
|
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
|
||||||
useToastContext: () => ({
|
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
|
||||||
notify: mockNotify,
|
return {
|
||||||
}),
|
...actual,
|
||||||
}))
|
useToastContext: () => ({
|
||||||
|
notify: mockNotify,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('@/context/event-emitter', () => ({
|
vi.mock('@/context/event-emitter', () => ({
|
||||||
useEventEmitterContextContext: () => ({
|
useEventEmitterContextContext: () => ({
|
||||||
@ -93,8 +98,14 @@ describe('CredentialPanel', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const renderCredentialPanel = (provider: ModelProvider) => render(
|
||||||
|
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
|
||||||
|
<CredentialPanel provider={provider} />
|
||||||
|
</ToastContext.Provider>,
|
||||||
|
)
|
||||||
|
|
||||||
it('should show credential name and configuration actions', () => {
|
it('should show credential name and configuration actions', () => {
|
||||||
render(<CredentialPanel provider={mockProvider} />)
|
renderCredentialPanel(mockProvider)
|
||||||
|
|
||||||
expect(screen.getByText('test-credential')).toBeInTheDocument()
|
expect(screen.getByText('test-credential')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('config-provider')).toBeInTheDocument()
|
expect(screen.getByTestId('config-provider')).toBeInTheDocument()
|
||||||
@ -103,7 +114,7 @@ describe('CredentialPanel', () => {
|
|||||||
|
|
||||||
it('should show unauthorized status label when credential is missing', () => {
|
it('should show unauthorized status label when credential is missing', () => {
|
||||||
mockCredentialStatus.hasCredential = false
|
mockCredentialStatus.hasCredential = false
|
||||||
render(<CredentialPanel provider={mockProvider} />)
|
renderCredentialPanel(mockProvider)
|
||||||
|
|
||||||
expect(screen.getByText(/modelProvider\.auth\.unAuthorized/)).toBeInTheDocument()
|
expect(screen.getByText(/modelProvider\.auth\.unAuthorized/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@ -111,7 +122,7 @@ describe('CredentialPanel', () => {
|
|||||||
it('should show removed credential label and priority tip for custom preference', () => {
|
it('should show removed credential label and priority tip for custom preference', () => {
|
||||||
mockCredentialStatus.authorized = false
|
mockCredentialStatus.authorized = false
|
||||||
mockCredentialStatus.authRemoved = true
|
mockCredentialStatus.authRemoved = true
|
||||||
render(<CredentialPanel provider={{ ...mockProvider, preferred_provider_type: 'custom' } as ModelProvider} />)
|
renderCredentialPanel({ ...mockProvider, preferred_provider_type: 'custom' } as ModelProvider)
|
||||||
|
|
||||||
expect(screen.getByText(/modelProvider\.auth\.authRemoved/)).toBeInTheDocument()
|
expect(screen.getByText(/modelProvider\.auth\.authRemoved/)).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('priority-use-tip')).toBeInTheDocument()
|
expect(screen.getByTestId('priority-use-tip')).toBeInTheDocument()
|
||||||
@ -120,7 +131,7 @@ describe('CredentialPanel', () => {
|
|||||||
it('should change priority and refresh related data after success', async () => {
|
it('should change priority and refresh related data after success', async () => {
|
||||||
const mockChangePriority = changeModelProviderPriority as ReturnType<typeof vi.fn>
|
const mockChangePriority = changeModelProviderPriority as ReturnType<typeof vi.fn>
|
||||||
mockChangePriority.mockResolvedValue({ result: 'success' })
|
mockChangePriority.mockResolvedValue({ result: 'success' })
|
||||||
render(<CredentialPanel provider={mockProvider} />)
|
renderCredentialPanel(mockProvider)
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('priority-selector'))
|
fireEvent.click(screen.getByTestId('priority-selector'))
|
||||||
|
|
||||||
@ -138,8 +149,70 @@ describe('CredentialPanel', () => {
|
|||||||
...mockProvider,
|
...mockProvider,
|
||||||
provider_credential_schema: null,
|
provider_credential_schema: null,
|
||||||
} as unknown as ModelProvider
|
} as unknown as ModelProvider
|
||||||
render(<CredentialPanel provider={providerNoSchema} />)
|
renderCredentialPanel(providerNoSchema)
|
||||||
expect(screen.getByTestId('priority-selector')).toBeInTheDocument()
|
expect(screen.getByTestId('priority-selector')).toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('config-provider')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('config-provider')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should show gray indicator when notAllowedToUse is true', () => {
|
||||||
|
mockCredentialStatus.notAllowedToUse = true
|
||||||
|
renderCredentialPanel(mockProvider)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('indicator')).toHaveTextContent('gray')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not notify or update when priority change returns non-success', async () => {
|
||||||
|
const mockChangePriority = changeModelProviderPriority as ReturnType<typeof vi.fn>
|
||||||
|
mockChangePriority.mockResolvedValue({ result: 'error' })
|
||||||
|
renderCredentialPanel(mockProvider)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('priority-selector'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockChangePriority).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
expect(mockNotify).not.toHaveBeenCalled()
|
||||||
|
expect(mockUpdateModelProviders).not.toHaveBeenCalled()
|
||||||
|
expect(mockEventEmitter.emit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show empty label when authorized is false and authRemoved is false', () => {
|
||||||
|
mockCredentialStatus.authorized = false
|
||||||
|
mockCredentialStatus.authRemoved = false
|
||||||
|
renderCredentialPanel(mockProvider)
|
||||||
|
|
||||||
|
expect(screen.queryByText(/modelProvider\.auth\.unAuthorized/)).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/modelProvider\.auth\.authRemoved/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show PriorityUseTip when priorityUseType is system', () => {
|
||||||
|
renderCredentialPanel(mockProvider)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('priority-use-tip')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not iterate configurateMethods for non-predefinedModel methods', async () => {
|
||||||
|
const mockChangePriority = changeModelProviderPriority as ReturnType<typeof vi.fn>
|
||||||
|
mockChangePriority.mockResolvedValue({ result: 'success' })
|
||||||
|
const providerWithCustomMethod = {
|
||||||
|
...mockProvider,
|
||||||
|
configurate_methods: [ConfigurationMethodEnum.customizableModel],
|
||||||
|
} as unknown as ModelProvider
|
||||||
|
renderCredentialPanel(providerWithCustomMethod)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('priority-selector'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockChangePriority).toHaveBeenCalled()
|
||||||
|
expect(mockNotify).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
expect(mockUpdateModelList).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show red indicator when hasCredential is false', () => {
|
||||||
|
mockCredentialStatus.hasCredential = false
|
||||||
|
renderCredentialPanel(mockProvider)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('indicator')).toHaveTextContent('red')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -125,6 +125,48 @@ describe('ProviderAddedCard', () => {
|
|||||||
expect(await screen.findByTestId('model-list')).toBeInTheDocument()
|
expect(await screen.findByTestId('model-list')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should show loading spinner while model list is being fetched', async () => {
|
||||||
|
let resolvePromise: (value: unknown) => void = () => {}
|
||||||
|
const pendingPromise = new Promise((resolve) => {
|
||||||
|
resolvePromise = resolve
|
||||||
|
})
|
||||||
|
vi.mocked(fetchModelProviderModelList).mockReturnValue(pendingPromise as ReturnType<typeof fetchModelProviderModelList>)
|
||||||
|
|
||||||
|
render(<ProviderAddedCard provider={mockProvider} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('show-models-button'))
|
||||||
|
|
||||||
|
expect(document.querySelector('.i-ri-loader-2-line.animate-spin')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolvePromise({ data: [] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show modelsNum text after models have loaded', async () => {
|
||||||
|
const models = [
|
||||||
|
{ model: 'gpt-4' },
|
||||||
|
{ model: 'gpt-3.5' },
|
||||||
|
]
|
||||||
|
vi.mocked(fetchModelProviderModelList).mockResolvedValue({ data: models } as unknown as { data: ModelItem[] })
|
||||||
|
|
||||||
|
render(<ProviderAddedCard provider={mockProvider} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('show-models-button'))
|
||||||
|
|
||||||
|
await screen.findByTestId('model-list')
|
||||||
|
|
||||||
|
const collapseBtn = screen.getByRole('button', { name: 'collapse list' })
|
||||||
|
fireEvent.click(collapseBtn)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('model-list')).not.toBeInTheDocument())
|
||||||
|
|
||||||
|
const numTexts = screen.getAllByText(/modelProvider\.modelsNum/)
|
||||||
|
expect(numTexts.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
expect(screen.getByText(/modelProvider\.showModelsNum/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('should render configure tip when provider is not in quota list and not configured', () => {
|
it('should render configure tip when provider is not in quota list and not configured', () => {
|
||||||
const providerWithoutQuota = {
|
const providerWithoutQuota = {
|
||||||
...mockProvider,
|
...mockProvider,
|
||||||
@ -163,6 +205,16 @@ describe('ProviderAddedCard', () => {
|
|||||||
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
|
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should apply anthropic background class for anthropic provider', () => {
|
||||||
|
const anthropicProvider = {
|
||||||
|
...mockProvider,
|
||||||
|
provider: 'langgenius/anthropic/anthropic',
|
||||||
|
} as unknown as ModelProvider
|
||||||
|
const { container } = render(<ProviderAddedCard provider={anthropicProvider} />)
|
||||||
|
|
||||||
|
expect(container.querySelector('.bg-third-party-model-bg-anthropic')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('should render custom model actions for workspace managers', () => {
|
it('should render custom model actions for workspace managers', () => {
|
||||||
const customConfigProvider = {
|
const customConfigProvider = {
|
||||||
...mockProvider,
|
...mockProvider,
|
||||||
@ -177,4 +229,36 @@ describe('ProviderAddedCard', () => {
|
|||||||
rerender(<ProviderAddedCard provider={customConfigProvider} />)
|
rerender(<ProviderAddedCard provider={customConfigProvider} />)
|
||||||
expect(screen.queryByTestId('manage-custom-model')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('manage-custom-model')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render credential panel when showCredential is true', () => {
|
||||||
|
// Arrange: use ConfigurationMethodEnum.predefinedModel ('predefined-model') so showCredential=true
|
||||||
|
const predefinedProvider = {
|
||||||
|
...mockProvider,
|
||||||
|
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
|
||||||
|
} as unknown as ModelProvider
|
||||||
|
|
||||||
|
mockIsCurrentWorkspaceManager = true
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<ProviderAddedCard provider={predefinedProvider} />)
|
||||||
|
|
||||||
|
// Assert: credential-panel is rendered (showCredential = true branch)
|
||||||
|
expect(screen.getByTestId('credential-panel')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render credential panel when user is not workspace manager', () => {
|
||||||
|
// Arrange: predefined-model but manager=false so showCredential=false
|
||||||
|
const predefinedProvider = {
|
||||||
|
...mockProvider,
|
||||||
|
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
|
||||||
|
} as unknown as ModelProvider
|
||||||
|
|
||||||
|
mockIsCurrentWorkspaceManager = false
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<ProviderAddedCard provider={predefinedProvider} />)
|
||||||
|
|
||||||
|
// Assert: credential-panel is not rendered (showCredential = false)
|
||||||
|
expect(screen.queryByTestId('credential-panel')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { ModelStatusEnum } from '../declarations'
|
|||||||
import ModelListItem from './model-list-item'
|
import ModelListItem from './model-list-item'
|
||||||
|
|
||||||
let mockModelLoadBalancingEnabled = false
|
let mockModelLoadBalancingEnabled = false
|
||||||
|
let mockPlanType: string = 'pro'
|
||||||
|
|
||||||
vi.mock('@/context/app-context', () => ({
|
vi.mock('@/context/app-context', () => ({
|
||||||
useAppContext: () => ({
|
useAppContext: () => ({
|
||||||
@ -14,7 +15,7 @@ vi.mock('@/context/app-context', () => ({
|
|||||||
|
|
||||||
vi.mock('@/context/provider-context', () => ({
|
vi.mock('@/context/provider-context', () => ({
|
||||||
useProviderContext: () => ({
|
useProviderContext: () => ({
|
||||||
plan: { type: 'pro' },
|
plan: { type: mockPlanType },
|
||||||
}),
|
}),
|
||||||
useProviderContextSelector: () => mockModelLoadBalancingEnabled,
|
useProviderContextSelector: () => mockModelLoadBalancingEnabled,
|
||||||
}))
|
}))
|
||||||
@ -60,6 +61,7 @@ describe('ModelListItem', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockModelLoadBalancingEnabled = false
|
mockModelLoadBalancingEnabled = false
|
||||||
|
mockPlanType = 'pro'
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render model item with icon and name', () => {
|
it('should render model item with icon and name', () => {
|
||||||
@ -127,4 +129,127 @@ describe('ModelListItem', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'modify load balancing' }))
|
fireEvent.click(screen.getByRole('button', { name: 'modify load balancing' }))
|
||||||
expect(onModifyLoadBalancing).toHaveBeenCalledWith(mockModel)
|
expect(onModifyLoadBalancing).toHaveBeenCalledWith(mockModel)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Deprecated branches: opacity-60, disabled switch, no ConfigModel
|
||||||
|
it('should show deprecated model with opacity and disabled switch', () => {
|
||||||
|
// Arrange
|
||||||
|
const deprecatedModel = { ...mockModel, deprecated: true } as unknown as ModelItem
|
||||||
|
mockModelLoadBalancingEnabled = true
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { container } = render(
|
||||||
|
<ModelListItem
|
||||||
|
model={deprecatedModel}
|
||||||
|
provider={mockProvider}
|
||||||
|
isConfigurable={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(container.querySelector('.opacity-60')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'modify load balancing' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load balancing badge: visible when all 4 conditions met
|
||||||
|
it('should show load balancing badge when all conditions are met', () => {
|
||||||
|
// Arrange
|
||||||
|
mockModelLoadBalancingEnabled = true
|
||||||
|
const lbModel = {
|
||||||
|
...mockModel,
|
||||||
|
load_balancing_enabled: true,
|
||||||
|
has_invalid_load_balancing_configs: false,
|
||||||
|
deprecated: false,
|
||||||
|
} as unknown as ModelItem
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(
|
||||||
|
<ModelListItem
|
||||||
|
model={lbModel}
|
||||||
|
provider={mockProvider}
|
||||||
|
isConfigurable={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert - Badge component should render
|
||||||
|
const badge = document.querySelector('.border-text-accent-secondary')
|
||||||
|
expect(badge).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Plan.sandbox: ConfigModel shown without load balancing enabled
|
||||||
|
it('should show ConfigModel for sandbox plan even without load balancing enabled', () => {
|
||||||
|
// Arrange - set plan type to sandbox and keep load balancing disabled
|
||||||
|
mockModelLoadBalancingEnabled = false
|
||||||
|
mockPlanType = 'sandbox'
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(
|
||||||
|
<ModelListItem
|
||||||
|
model={mockModel}
|
||||||
|
provider={mockProvider}
|
||||||
|
isConfigurable={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert - ConfigModel should show because plan.type === 'sandbox'
|
||||||
|
expect(screen.getByRole('button', { name: 'modify load balancing' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Negative proof: non-sandbox plan without load balancing should NOT show ConfigModel
|
||||||
|
it('should hide ConfigModel for non-sandbox plan without load balancing enabled', () => {
|
||||||
|
// Arrange - set plan type to non-sandbox and keep load balancing disabled
|
||||||
|
mockModelLoadBalancingEnabled = false
|
||||||
|
mockPlanType = 'pro'
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(
|
||||||
|
<ModelListItem
|
||||||
|
model={mockModel}
|
||||||
|
provider={mockProvider}
|
||||||
|
isConfigurable={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert - ConfigModel should NOT show because plan.type !== 'sandbox' and load balancing is disabled
|
||||||
|
expect(screen.queryByRole('button', { name: 'modify load balancing' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// model.status=credentialRemoved: switch disabled, no ConfigModel
|
||||||
|
it('should disable switch and hide ConfigModel when status is credentialRemoved', () => {
|
||||||
|
// Arrange
|
||||||
|
const removedModel = { ...mockModel, status: ModelStatusEnum.credentialRemoved } as unknown as ModelItem
|
||||||
|
mockModelLoadBalancingEnabled = true
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(
|
||||||
|
<ModelListItem
|
||||||
|
model={removedModel}
|
||||||
|
provider={mockProvider}
|
||||||
|
isConfigurable={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert - ConfigModel should not render because status is not active/disabled
|
||||||
|
expect(screen.queryByRole('button', { name: 'modify load balancing' })).not.toBeInTheDocument()
|
||||||
|
const statusSwitch = screen.getByRole('switch')
|
||||||
|
expect(statusSwitch).toHaveClass('!cursor-not-allowed')
|
||||||
|
fireEvent.click(statusSwitch)
|
||||||
|
expect(statusSwitch).toHaveAttribute('aria-checked', 'false')
|
||||||
|
expect(enableModel).not.toHaveBeenCalled()
|
||||||
|
expect(disableModel).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// isConfigurable=true: hover class on row
|
||||||
|
it('should apply hover class when isConfigurable is true', () => {
|
||||||
|
// Act
|
||||||
|
const { container } = render(
|
||||||
|
<ModelListItem
|
||||||
|
model={mockModel}
|
||||||
|
provider={mockProvider}
|
||||||
|
isConfigurable={true}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(container.querySelector('.hover\\:bg-components-panel-on-panel-item-bg-hover')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { ModelItem, ModelProvider } from '../declarations'
|
import type { ModelItem, ModelProvider } from '../declarations'
|
||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { ConfigurationMethodEnum } from '../declarations'
|
||||||
import ModelList from './model-list'
|
import ModelList from './model-list'
|
||||||
|
|
||||||
const mockSetShowModelLoadBalancingModal = vi.fn()
|
const mockSetShowModelLoadBalancingModal = vi.fn()
|
||||||
@ -105,4 +106,120 @@ describe('ModelList', () => {
|
|||||||
expect(screen.queryByTestId('manage-credentials')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('manage-credentials')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// isConfigurable=false: predefinedModel only provider hides custom model actions
|
||||||
|
it('should hide custom model actions when provider uses predefinedModel only', () => {
|
||||||
|
// Arrange
|
||||||
|
const predefinedProvider = {
|
||||||
|
provider: 'test-provider',
|
||||||
|
configurate_methods: ['predefinedModel'],
|
||||||
|
} as unknown as ModelProvider
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(
|
||||||
|
<ModelList
|
||||||
|
provider={predefinedProvider}
|
||||||
|
models={mockModels}
|
||||||
|
onCollapse={mockOnCollapse}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(screen.queryByTestId('manage-credentials')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onSave (onChange) and onClose from the load balancing modal callbacks', () => {
|
||||||
|
render(
|
||||||
|
<ModelList
|
||||||
|
provider={mockProvider}
|
||||||
|
models={mockModels}
|
||||||
|
onCollapse={mockOnCollapse}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'gpt-4' }))
|
||||||
|
expect(mockSetShowModelLoadBalancingModal).toHaveBeenCalled()
|
||||||
|
|
||||||
|
const callArg = mockSetShowModelLoadBalancingModal.mock.calls[0][0]
|
||||||
|
|
||||||
|
callArg.onSave('test-provider')
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith('test-provider')
|
||||||
|
|
||||||
|
callArg.onClose()
|
||||||
|
expect(mockSetShowModelLoadBalancingModal).toHaveBeenCalledWith(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// fetchFromRemote filtered out: provider with only fetchFromRemote
|
||||||
|
it('should hide custom model actions when provider uses fetchFromRemote only', () => {
|
||||||
|
// Arrange
|
||||||
|
const fetchOnlyProvider = {
|
||||||
|
provider: 'test-provider',
|
||||||
|
configurate_methods: ['fetchFromRemote'],
|
||||||
|
} as unknown as ModelProvider
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(
|
||||||
|
<ModelList
|
||||||
|
provider={fetchOnlyProvider}
|
||||||
|
models={mockModels}
|
||||||
|
onCollapse={mockOnCollapse}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(screen.queryByTestId('manage-credentials')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show custom model actions when provider is configurable and user is workspace manager', () => {
|
||||||
|
// Arrange: use ConfigurationMethodEnum.customizableModel ('customizable-model') so isConfigurable=true
|
||||||
|
const configurableProvider = {
|
||||||
|
provider: 'test-provider',
|
||||||
|
configurate_methods: [ConfigurationMethodEnum.customizableModel],
|
||||||
|
} as unknown as ModelProvider
|
||||||
|
|
||||||
|
mockIsCurrentWorkspaceManager = true
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(
|
||||||
|
<ModelList
|
||||||
|
provider={configurableProvider}
|
||||||
|
models={mockModels}
|
||||||
|
onCollapse={mockOnCollapse}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert: custom model actions are shown (isConfigurable=true && isCurrentWorkspaceManager=true)
|
||||||
|
expect(screen.getByTestId('manage-credentials')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('add-custom-model')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide custom model actions when provider is configurable but user is not workspace manager', () => {
|
||||||
|
// Arrange: use ConfigurationMethodEnum.customizableModel ('customizable-model') so isConfigurable=true, but manager=false
|
||||||
|
const configurableProvider = {
|
||||||
|
provider: 'test-provider',
|
||||||
|
configurate_methods: [ConfigurationMethodEnum.customizableModel],
|
||||||
|
} as unknown as ModelProvider
|
||||||
|
|
||||||
|
mockIsCurrentWorkspaceManager = false
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(
|
||||||
|
<ModelList
|
||||||
|
provider={configurableProvider}
|
||||||
|
models={mockModels}
|
||||||
|
onCollapse={mockOnCollapse}
|
||||||
|
onChange={mockOnChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert: custom model actions are hidden (isCurrentWorkspaceManager=false covers the && short-circuit)
|
||||||
|
expect(screen.queryByTestId('manage-credentials')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('add-custom-model')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type {
|
|||||||
ModelLoadBalancingConfig,
|
ModelLoadBalancingConfig,
|
||||||
ModelProvider,
|
ModelProvider,
|
||||||
} from '../declarations'
|
} from '../declarations'
|
||||||
import { act, render, screen } from '@testing-library/react'
|
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { AddCredentialInLoadBalancing } from '@/app/components/header/account-setting/model-provider-page/model-auth'
|
import { AddCredentialInLoadBalancing } from '@/app/components/header/account-setting/model-provider-page/model-auth'
|
||||||
@ -261,6 +261,128 @@ describe('ModelLoadBalancingConfigs', () => {
|
|||||||
expect(screen.getByText('common.modelProvider.defaultConfig')).toBeInTheDocument()
|
expect(screen.getByText('common.modelProvider.defaultConfig')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should remove credential at index 0', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onRemove = vi.fn()
|
||||||
|
// Create config where the target credential is at index 0
|
||||||
|
const config: ModelLoadBalancingConfig = {
|
||||||
|
enabled: true,
|
||||||
|
configs: [
|
||||||
|
{ id: 'cfg-target', credential_id: 'cred-2', enabled: true, name: 'Key 2' },
|
||||||
|
{ id: 'cfg-other', credential_id: 'cred-1', enabled: true, name: 'Key 1' },
|
||||||
|
],
|
||||||
|
} as ModelLoadBalancingConfig
|
||||||
|
|
||||||
|
render(<StatefulHarness initialConfig={config} onRemove={onRemove} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'trigger remove' }))
|
||||||
|
|
||||||
|
expect(onRemove).toHaveBeenCalledWith('cred-2')
|
||||||
|
expect(screen.queryByText('Key 2')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not toggle load balancing when modelLoadBalancingEnabled=false and enabling via switch', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockModelLoadBalancingEnabled = false
|
||||||
|
render(<StatefulHarness initialConfig={createDraftConfig(false)} withSwitch />)
|
||||||
|
|
||||||
|
const mainSwitch = screen.getByTestId('load-balancing-switch-main')
|
||||||
|
await user.click(mainSwitch)
|
||||||
|
|
||||||
|
// Switch is disabled so toggling to true should not work
|
||||||
|
expect(mainSwitch).toHaveAttribute('aria-checked', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should toggle load balancing to false when modelLoadBalancingEnabled=false but enabled=true via switch', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockModelLoadBalancingEnabled = false
|
||||||
|
// When draftConfig.enabled=true and !enabled (toggling off): condition `(modelLoadBalancingEnabled || !enabled)` = (!enabled) = true
|
||||||
|
render(<StatefulHarness initialConfig={createDraftConfig(true)} withSwitch />)
|
||||||
|
|
||||||
|
const mainSwitch = screen.getByTestId('load-balancing-switch-main')
|
||||||
|
await user.click(mainSwitch)
|
||||||
|
|
||||||
|
expect(mainSwitch).toHaveAttribute('aria-checked', 'false')
|
||||||
|
expect(screen.queryByText('Key 1')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show provider badge when isProviderManaged=true but configurationMethod is customizableModel', () => {
|
||||||
|
const inheritConfig: ModelLoadBalancingConfig = {
|
||||||
|
enabled: true,
|
||||||
|
configs: [
|
||||||
|
{ id: 'cfg-inherit', credential_id: '', enabled: true, name: '__inherit__' },
|
||||||
|
],
|
||||||
|
} as ModelLoadBalancingConfig
|
||||||
|
|
||||||
|
render(
|
||||||
|
<StatefulHarness
|
||||||
|
initialConfig={inheritConfig}
|
||||||
|
configurationMethod={ConfigurationMethodEnum.customizableModel}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('common.modelProvider.defaultConfig')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('common.modelProvider.providerManaged')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show upgrade panel when modelLoadBalancingEnabled=false and not CE edition', () => {
|
||||||
|
mockModelLoadBalancingEnabled = false
|
||||||
|
|
||||||
|
render(<StatefulHarness initialConfig={createDraftConfig(false)} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('upgrade')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('common.modelProvider.upgradeForLoadBalancing')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass explicit boolean state to toggleConfigEntryEnabled (typeof state === boolean branch)', async () => {
|
||||||
|
// Arrange: render with a config entry; the Switch onChange passes explicit boolean value
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<StatefulHarness initialConfig={createDraftConfig(true)} />)
|
||||||
|
|
||||||
|
// Act: click the switch which calls toggleConfigEntryEnabled(index, value) where value is boolean
|
||||||
|
const entrySwitch = screen.getByTestId('load-balancing-switch-cfg-1')
|
||||||
|
await user.click(entrySwitch)
|
||||||
|
|
||||||
|
// Assert: component still renders after the toggle (state = explicit boolean true/false)
|
||||||
|
expect(screen.getByTestId('load-balancing-main-panel')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render with credential that has not_allowed_to_use flag (covers credential?.not_allowed_to_use ? false branch)', () => {
|
||||||
|
// Arrange: config where the credential is not allowed to use
|
||||||
|
const restrictedConfig: ModelLoadBalancingConfig = {
|
||||||
|
enabled: true,
|
||||||
|
configs: [
|
||||||
|
{ id: 'cfg-restricted', credential_id: 'cred-restricted', enabled: true, name: 'Restricted Key' },
|
||||||
|
],
|
||||||
|
} as ModelLoadBalancingConfig
|
||||||
|
|
||||||
|
const mockModelCredentialWithRestricted = {
|
||||||
|
available_credentials: [
|
||||||
|
{
|
||||||
|
credential_id: 'cred-restricted',
|
||||||
|
credential_name: 'Restricted Key',
|
||||||
|
not_allowed_to_use: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as ModelCredential
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(
|
||||||
|
<ModelLoadBalancingConfigs
|
||||||
|
draftConfig={restrictedConfig}
|
||||||
|
setDraftConfig={vi.fn()}
|
||||||
|
provider={mockProvider}
|
||||||
|
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
modelCredential={mockModelCredentialWithRestricted}
|
||||||
|
model={{ model: 'gpt-4', model_type: 'llm' } as CustomModelCredential}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert: Switch value should be false (credential?.not_allowed_to_use ? false branch)
|
||||||
|
const entrySwitch = screen.getByTestId('load-balancing-switch-cfg-restricted')
|
||||||
|
expect(entrySwitch).toHaveAttribute('aria-checked', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
it('should handle edge cases where draftConfig becomes null during callbacks', async () => {
|
it('should handle edge cases where draftConfig becomes null during callbacks', async () => {
|
||||||
let capturedAdd: ((credential: Credential) => void) | null = null
|
let capturedAdd: ((credential: Credential) => void) | null = null
|
||||||
let capturedUpdate: ((payload?: unknown, formValues?: Record<string, unknown>) => void) | null = null
|
let capturedUpdate: ((payload?: unknown, formValues?: Record<string, unknown>) => void) | null = null
|
||||||
@ -298,4 +420,82 @@ describe('ModelLoadBalancingConfigs', () => {
|
|||||||
|
|
||||||
// Should not throw and just return prev (which is undefined)
|
// Should not throw and just return prev (which is undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not toggle load balancing when modelLoadBalancingEnabled=false and clicking panel to enable', async () => {
|
||||||
|
// Arrange: load balancing not enabled in context, draftConfig.enabled=false (so panel is clickable)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockModelLoadBalancingEnabled = false
|
||||||
|
render(<StatefulHarness initialConfig={createDraftConfig(false)} withSwitch={false} />)
|
||||||
|
|
||||||
|
// Act: clicking the panel calls toggleModalBalancing(true)
|
||||||
|
// but (modelLoadBalancingEnabled || !enabled) = (false || false) = false → condition fails
|
||||||
|
const panel = screen.getByTestId('load-balancing-main-panel')
|
||||||
|
await user.click(panel)
|
||||||
|
|
||||||
|
expect(screen.queryByText('Key 1')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return early from addConfigEntry setDraftConfig when prev is undefined', async () => {
|
||||||
|
// Arrange: use a controlled wrapper that exposes a way to force draftConfig to undefined
|
||||||
|
let capturedAdd: ((credential: Credential) => void) | null = null
|
||||||
|
const MockChild = ({ onSelectCredential }: {
|
||||||
|
onSelectCredential: (credential: Credential) => void
|
||||||
|
}) => {
|
||||||
|
capturedAdd = onSelectCredential
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
vi.mocked(AddCredentialInLoadBalancing).mockImplementation(MockChild as unknown as typeof AddCredentialInLoadBalancing)
|
||||||
|
|
||||||
|
// Use a setDraftConfig spy that tracks calls and simulates null prev
|
||||||
|
const setDraftConfigSpy = vi.fn((updater: ((prev: ModelLoadBalancingConfig | undefined) => ModelLoadBalancingConfig | undefined) | ModelLoadBalancingConfig | undefined) => {
|
||||||
|
if (typeof updater === 'function')
|
||||||
|
updater(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ModelLoadBalancingConfigs
|
||||||
|
draftConfig={createDraftConfig(true)}
|
||||||
|
setDraftConfig={setDraftConfigSpy}
|
||||||
|
provider={mockProvider}
|
||||||
|
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
modelCredential={mockModelCredential}
|
||||||
|
model={{ model: 'gpt-4', model_type: 'llm' } as CustomModelCredential}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act: trigger addConfigEntry with undefined prev via the spy
|
||||||
|
act(() => {
|
||||||
|
if (capturedAdd)
|
||||||
|
(capturedAdd as (credential: Credential) => void)({ credential_id: 'new', credential_name: 'New' } as Credential)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Assert: setDraftConfig was called and the updater returned early (prev was undefined)
|
||||||
|
expect(setDraftConfigSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return early from updateConfigEntry setDraftConfig when prev is undefined', async () => {
|
||||||
|
// Arrange: use setDraftConfig spy that invokes updater with undefined prev
|
||||||
|
const setDraftConfigSpy = vi.fn((updater: ((prev: ModelLoadBalancingConfig | undefined) => ModelLoadBalancingConfig | undefined) | ModelLoadBalancingConfig | undefined) => {
|
||||||
|
if (typeof updater === 'function')
|
||||||
|
updater(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ModelLoadBalancingConfigs
|
||||||
|
draftConfig={createDraftConfig(true)}
|
||||||
|
setDraftConfig={setDraftConfigSpy}
|
||||||
|
provider={mockProvider}
|
||||||
|
configurationMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
modelCredential={mockModelCredential}
|
||||||
|
model={{ model: 'gpt-4', model_type: 'llm' } as CustomModelCredential}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act: click remove button which triggers updateConfigEntry → setDraftConfig with prev=undefined
|
||||||
|
const removeBtn = screen.getByTestId('load-balancing-remove-cfg-1')
|
||||||
|
fireEvent.click(removeBtn)
|
||||||
|
|
||||||
|
// Assert: setDraftConfig was called and handled undefined prev gracefully
|
||||||
|
expect(setDraftConfigSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -130,7 +130,7 @@ const ModelLoadBalancingConfigs = ({
|
|||||||
|
|
||||||
const handleRemove = useCallback((credentialId: string) => {
|
const handleRemove = useCallback((credentialId: string) => {
|
||||||
const index = draftConfig?.configs.findIndex(item => item.credential_id === credentialId && item.name !== '__inherit__')
|
const index = draftConfig?.configs.findIndex(item => item.credential_id === credentialId && item.name !== '__inherit__')
|
||||||
if (index && index > -1)
|
if (typeof index === 'number' && index > -1)
|
||||||
updateConfigEntry(index, () => undefined)
|
updateConfigEntry(index, () => undefined)
|
||||||
onRemove?.(credentialId)
|
onRemove?.(credentialId)
|
||||||
}, [draftConfig?.configs, updateConfigEntry, onRemove])
|
}, [draftConfig?.configs, updateConfigEntry, onRemove])
|
||||||
|
|||||||
@ -1,8 +1,18 @@
|
|||||||
import type { ModelItem, ModelProvider } from '../declarations'
|
import type { ModelItem, ModelProvider } from '../declarations'
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { ToastContext } from '@/app/components/base/toast/context'
|
||||||
import { ConfigurationMethodEnum } from '../declarations'
|
import { ConfigurationMethodEnum } from '../declarations'
|
||||||
import ModelLoadBalancingModal from './model-load-balancing-modal'
|
import ModelLoadBalancingModal from './model-load-balancing-modal'
|
||||||
|
|
||||||
|
vi.mock('@headlessui/react', () => ({
|
||||||
|
Transition: ({ show, children }: { show: boolean, children: React.ReactNode }) => (show ? <>{children}</> : null),
|
||||||
|
TransitionChild: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DialogPanel: ({ children, className }: { children: React.ReactNode, className?: string }) => <div className={className}>{children}</div>,
|
||||||
|
DialogTitle: ({ children, className }: { children: React.ReactNode, className?: string }) => <h3 className={className}>{children}</h3>,
|
||||||
|
}))
|
||||||
|
|
||||||
type CredentialData = {
|
type CredentialData = {
|
||||||
load_balancing: {
|
load_balancing: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@ -43,11 +53,15 @@ let mockCredentialData: CredentialData | undefined = {
|
|||||||
current_credential_name: 'Default',
|
current_credential_name: 'Default',
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock('@/app/components/base/toast/context', () => ({
|
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
|
||||||
useToastContext: () => ({
|
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
|
||||||
notify: mockNotify,
|
return {
|
||||||
}),
|
...actual,
|
||||||
}))
|
useToastContext: () => ({
|
||||||
|
notify: mockNotify,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('@/service/use-models', () => ({
|
vi.mock('@/service/use-models', () => ({
|
||||||
useGetModelCredential: () => ({
|
useGetModelCredential: () => ({
|
||||||
@ -102,6 +116,8 @@ vi.mock('../model-name', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
describe('ModelLoadBalancingModal', () => {
|
describe('ModelLoadBalancingModal', () => {
|
||||||
|
let user: ReturnType<typeof userEvent.setup>
|
||||||
|
|
||||||
const mockProvider = {
|
const mockProvider = {
|
||||||
provider: 'test-provider',
|
provider: 'test-provider',
|
||||||
provider_credential_schema: {
|
provider_credential_schema: {
|
||||||
@ -118,8 +134,15 @@ describe('ModelLoadBalancingModal', () => {
|
|||||||
fetch_from: 'predefined-model',
|
fetch_from: 'predefined-model',
|
||||||
} as unknown as ModelItem
|
} as unknown as ModelItem
|
||||||
|
|
||||||
|
const renderModal = (node: Parameters<typeof render>[0]) => render(
|
||||||
|
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
|
||||||
|
{node}
|
||||||
|
</ToastContext.Provider>,
|
||||||
|
)
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
user = userEvent.setup()
|
||||||
mockDeleteModel = null
|
mockDeleteModel = null
|
||||||
mockCredentialData = {
|
mockCredentialData = {
|
||||||
load_balancing: {
|
load_balancing: {
|
||||||
@ -143,7 +166,7 @@ describe('ModelLoadBalancingModal', () => {
|
|||||||
it('should show loading area while draft config is not ready', () => {
|
it('should show loading area while draft config is not ready', () => {
|
||||||
mockCredentialData = undefined
|
mockCredentialData = undefined
|
||||||
|
|
||||||
render(
|
renderModal(
|
||||||
<ModelLoadBalancingModal
|
<ModelLoadBalancingModal
|
||||||
provider={mockProvider}
|
provider={mockProvider}
|
||||||
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
@ -156,7 +179,7 @@ describe('ModelLoadBalancingModal', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should render predefined model content', () => {
|
it('should render predefined model content', () => {
|
||||||
render(
|
renderModal(
|
||||||
<ModelLoadBalancingModal
|
<ModelLoadBalancingModal
|
||||||
provider={mockProvider}
|
provider={mockProvider}
|
||||||
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
@ -173,7 +196,7 @@ describe('ModelLoadBalancingModal', () => {
|
|||||||
it('should render custom model actions and close when update has no credentials', async () => {
|
it('should render custom model actions and close when update has no credentials', async () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
mockRefetch.mockResolvedValue({ data: { available_credentials: [] } })
|
mockRefetch.mockResolvedValue({ data: { available_credentials: [] } })
|
||||||
render(
|
renderModal(
|
||||||
<ModelLoadBalancingModal
|
<ModelLoadBalancingModal
|
||||||
provider={mockProvider}
|
provider={mockProvider}
|
||||||
configurateMethod={ConfigurationMethodEnum.customizableModel}
|
configurateMethod={ConfigurationMethodEnum.customizableModel}
|
||||||
@ -185,7 +208,7 @@ describe('ModelLoadBalancingModal', () => {
|
|||||||
|
|
||||||
expect(screen.getByText(/modelProvider\.auth\.removeModel/)).toBeInTheDocument()
|
expect(screen.getByText(/modelProvider\.auth\.removeModel/)).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: 'switch credential' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: 'switch credential' })).toBeInTheDocument()
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'config add credential' }))
|
await user.click(screen.getByRole('button', { name: 'config add credential' }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onClose).toHaveBeenCalled()
|
expect(onClose).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@ -195,7 +218,7 @@ describe('ModelLoadBalancingModal', () => {
|
|||||||
const onSave = vi.fn()
|
const onSave = vi.fn()
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
|
|
||||||
render(
|
renderModal(
|
||||||
<ModelLoadBalancingModal
|
<ModelLoadBalancingModal
|
||||||
provider={mockProvider}
|
provider={mockProvider}
|
||||||
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
@ -206,9 +229,9 @@ describe('ModelLoadBalancingModal', () => {
|
|||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'config add credential' }))
|
await user.click(screen.getByRole('button', { name: 'config add credential' }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'config rename credential' }))
|
await user.click(screen.getByRole('button', { name: 'config rename credential' }))
|
||||||
fireEvent.click(screen.getByText(/operation\.save/))
|
await user.click(screen.getByText(/operation\.save/))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockRefetch).toHaveBeenCalled()
|
expect(mockRefetch).toHaveBeenCalled()
|
||||||
@ -226,7 +249,7 @@ describe('ModelLoadBalancingModal', () => {
|
|||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
mockRefetch.mockResolvedValue({ data: { available_credentials: [] } })
|
mockRefetch.mockResolvedValue({ data: { available_credentials: [] } })
|
||||||
|
|
||||||
render(
|
renderModal(
|
||||||
<ModelLoadBalancingModal
|
<ModelLoadBalancingModal
|
||||||
provider={mockProvider}
|
provider={mockProvider}
|
||||||
configurateMethod={ConfigurationMethodEnum.customizableModel}
|
configurateMethod={ConfigurationMethodEnum.customizableModel}
|
||||||
@ -236,7 +259,7 @@ describe('ModelLoadBalancingModal', () => {
|
|||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'switch credential' }))
|
await user.click(screen.getByRole('button', { name: 'switch credential' }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onClose).toHaveBeenCalled()
|
expect(onClose).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@ -246,7 +269,7 @@ describe('ModelLoadBalancingModal', () => {
|
|||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
mockDeleteModel = { model: 'gpt-4' }
|
mockDeleteModel = { model: 'gpt-4' }
|
||||||
|
|
||||||
render(
|
renderModal(
|
||||||
<ModelLoadBalancingModal
|
<ModelLoadBalancingModal
|
||||||
provider={mockProvider}
|
provider={mockProvider}
|
||||||
configurateMethod={ConfigurationMethodEnum.customizableModel}
|
configurateMethod={ConfigurationMethodEnum.customizableModel}
|
||||||
@ -256,8 +279,8 @@ describe('ModelLoadBalancingModal', () => {
|
|||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
|
||||||
fireEvent.click(screen.getByText(/modelProvider\.auth\.removeModel/))
|
await user.click(screen.getByText(/modelProvider\.auth\.removeModel/))
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockOpenConfirmDelete).toHaveBeenCalled()
|
expect(mockOpenConfirmDelete).toHaveBeenCalled()
|
||||||
@ -265,4 +288,479 @@ describe('ModelLoadBalancingModal', () => {
|
|||||||
expect(onClose).toHaveBeenCalled()
|
expect(onClose).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Disabled load balancing: title shows configModel text
|
||||||
|
it('should show configModel title when load balancing is disabled', () => {
|
||||||
|
mockCredentialData = {
|
||||||
|
...mockCredentialData!,
|
||||||
|
load_balancing: {
|
||||||
|
enabled: false,
|
||||||
|
configs: mockCredentialData!.load_balancing.configs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={mockProvider}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
model={mockModel}
|
||||||
|
open
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText(/modelProvider\.auth\.configModel/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Modal hidden when open=false
|
||||||
|
it('should not render modal content when open is false', () => {
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={mockProvider}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
model={mockModel}
|
||||||
|
open={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.queryByText(/modelProvider\.auth\.configLoadBalancing/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Config rename: updates name in draft config
|
||||||
|
it('should rename credential in draft config', async () => {
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={mockProvider}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
model={mockModel}
|
||||||
|
open
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'config rename credential' }))
|
||||||
|
await user.click(screen.getByText(/operation\.save/))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMutateAsync).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Config remove: removes credential from draft
|
||||||
|
it('should remove credential from draft config', async () => {
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={mockProvider}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
model={mockModel}
|
||||||
|
open
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'config remove' }))
|
||||||
|
await user.click(screen.getByText(/operation\.save/))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMutateAsync).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save error: shows error toast
|
||||||
|
it('should show error toast when save fails', async () => {
|
||||||
|
mockMutateAsync.mockResolvedValue({ result: 'error' })
|
||||||
|
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={mockProvider}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
model={mockModel}
|
||||||
|
open
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(screen.getByText(/operation\.save/))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMutateAsync).toHaveBeenCalled()
|
||||||
|
expect(mockNotify).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// No current_credential_id: modelCredential is undefined
|
||||||
|
it('should handle missing current_credential_id', () => {
|
||||||
|
mockCredentialData = {
|
||||||
|
...mockCredentialData!,
|
||||||
|
current_credential_id: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={mockProvider}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.customizableModel}
|
||||||
|
model={mockModel}
|
||||||
|
open
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'switch credential' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable save button when less than 2 configs are enabled', () => {
|
||||||
|
mockCredentialData = {
|
||||||
|
...mockCredentialData!,
|
||||||
|
load_balancing: {
|
||||||
|
enabled: true,
|
||||||
|
configs: [
|
||||||
|
{ id: 'cfg-1', credential_id: 'cred-1', enabled: true, name: 'Only One', credentials: { api_key: 'key' } },
|
||||||
|
{ id: 'cfg-2', credential_id: 'cred-2', enabled: false, name: 'Disabled', credentials: { api_key: 'key2' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={mockProvider}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
model={mockModel}
|
||||||
|
open
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText(/operation\.save/)).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should encode config entry without id as non-hidden value', async () => {
|
||||||
|
mockCredentialData = {
|
||||||
|
...mockCredentialData!,
|
||||||
|
load_balancing: {
|
||||||
|
enabled: true,
|
||||||
|
configs: [
|
||||||
|
{ id: '', credential_id: 'cred-new', enabled: true, name: 'New Entry', credentials: { api_key: 'new-key' } },
|
||||||
|
{ id: 'cfg-2', credential_id: 'cred-2', enabled: true, name: 'Backup', credentials: { api_key: 'backup-key' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={mockProvider}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
model={mockModel}
|
||||||
|
open
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(screen.getByText(/operation\.save/))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMutateAsync).toHaveBeenCalled()
|
||||||
|
const payload = mockMutateAsync.mock.calls[0][0] as { load_balancing: { configs: Array<{ credentials: { api_key: string } }> } }
|
||||||
|
// Entry without id should NOT be encoded as hidden
|
||||||
|
expect(payload.load_balancing.configs[0].credentials.api_key).toBe('new-key')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add new credential to draft config when update finds matching credential', async () => {
|
||||||
|
mockRefetch.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
available_credentials: [
|
||||||
|
{ credential_id: 'cred-new', credential_name: 'New Key' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={mockProvider}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
model={mockModel}
|
||||||
|
open
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'config add credential' }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRefetch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save after adding credential to verify it was added to draft
|
||||||
|
await user.click(screen.getByText(/operation\.save/))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMutateAsync).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not update draft config when handleUpdate credential name does not match any available credential', async () => {
|
||||||
|
mockRefetch.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
available_credentials: [
|
||||||
|
{ credential_id: 'cred-other', credential_name: 'Other Key' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={mockProvider}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
model={mockModel}
|
||||||
|
open
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// "config add credential" triggers onUpdate(undefined, { __authorization_name__: 'New Key' })
|
||||||
|
// But refetch returns 'Other Key' not 'New Key', so find() returns undefined → no config update
|
||||||
|
await user.click(screen.getByRole('button', { name: 'config add credential' }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRefetch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
await user.click(screen.getByText(/operation\.save/))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMutateAsync).toHaveBeenCalled()
|
||||||
|
// The payload configs should only have the original 2 entries (no new one added)
|
||||||
|
const payload = mockMutateAsync.mock.calls[0][0] as { load_balancing: { configs: unknown[] } }
|
||||||
|
expect(payload.load_balancing.configs).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should toggle modal from enabled to disabled when clicking the card', async () => {
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={mockProvider}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
model={mockModel}
|
||||||
|
open
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// draftConfig.enabled=true → title shows configLoadBalancing
|
||||||
|
expect(screen.getByText(/modelProvider\.auth\.configLoadBalancing/)).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Clicking the card when enabled=true toggles to disabled
|
||||||
|
const card = screen.getByText(/modelProvider\.auth\.providerManaged$/).closest('div[class]')!.closest('div[class]')!
|
||||||
|
await user.click(card)
|
||||||
|
|
||||||
|
// After toggling, title should show configModel (disabled state)
|
||||||
|
expect(screen.getByText(/modelProvider\.auth\.configModel/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use customModelCredential credential_id when present in handleSave', async () => {
|
||||||
|
// Arrange: set up credential data so customModelCredential is initialized from current_credential_id
|
||||||
|
mockCredentialData = {
|
||||||
|
...mockCredentialData!,
|
||||||
|
current_credential_id: 'cred-1',
|
||||||
|
current_credential_name: 'Default',
|
||||||
|
}
|
||||||
|
const onSave = vi.fn()
|
||||||
|
const onClose = vi.fn()
|
||||||
|
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={mockProvider}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.customizableModel}
|
||||||
|
model={mockModel}
|
||||||
|
open
|
||||||
|
onSave={onSave}
|
||||||
|
onClose={onClose}
|
||||||
|
credential={{ credential_id: 'cred-1', credential_name: 'Default' } as unknown as Parameters<typeof ModelLoadBalancingModal>[0]['credential']}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act: save triggers handleSave which uses customModelCredential?.credential_id
|
||||||
|
await user.click(screen.getByText(/operation\.save/))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMutateAsync).toHaveBeenCalled()
|
||||||
|
const payload = mockMutateAsync.mock.calls[0][0] as { credential_id: string }
|
||||||
|
// credential_id should come from customModelCredential
|
||||||
|
expect(payload.credential_id).toBe('cred-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use null fallback for available_credentials when result.data is missing in handleUpdate', async () => {
|
||||||
|
// Arrange: refetch returns data without available_credentials
|
||||||
|
const onClose = vi.fn()
|
||||||
|
mockRefetch.mockResolvedValue({ data: undefined })
|
||||||
|
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={mockProvider}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
model={mockModel}
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act: trigger handleUpdate which does `result.data?.available_credentials || []`
|
||||||
|
await user.click(screen.getByRole('button', { name: 'config add credential' }))
|
||||||
|
|
||||||
|
// Assert: available_credentials falls back to [], so onClose is called
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use null fallback for available_credentials in handleUpdateWhenSwitchCredential when result.data is missing', async () => {
|
||||||
|
// Arrange: refetch returns data without available_credentials
|
||||||
|
const onClose = vi.fn()
|
||||||
|
mockRefetch.mockResolvedValue({ data: undefined })
|
||||||
|
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={mockProvider}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.customizableModel}
|
||||||
|
model={mockModel}
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act: trigger handleUpdateWhenSwitchCredential which does `result.data?.available_credentials || []`
|
||||||
|
await user.click(screen.getByRole('button', { name: 'switch credential' }))
|
||||||
|
|
||||||
|
// Assert: available_credentials falls back to [], onClose is called
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use predefined provider schema without fallback when credential_form_schemas is undefined', () => {
|
||||||
|
// Arrange: provider with no credential_form_schemas → triggers ?? [] fallback
|
||||||
|
const providerWithoutSchemas = {
|
||||||
|
provider: 'test-provider',
|
||||||
|
provider_credential_schema: {
|
||||||
|
credential_form_schemas: undefined,
|
||||||
|
},
|
||||||
|
model_credential_schema: {
|
||||||
|
credential_form_schemas: undefined,
|
||||||
|
},
|
||||||
|
} as unknown as ModelProvider
|
||||||
|
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={providerWithoutSchemas}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
model={mockModel}
|
||||||
|
open
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert: component renders without error (extendedSecretFormSchemas = [])
|
||||||
|
expect(screen.getByText(/modelProvider\.auth\.configLoadBalancing/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use custom model credential schema without fallback when credential_form_schemas is undefined', () => {
|
||||||
|
// Arrange: provider with no model credential schemas → triggers ?? [] fallback for custom model path
|
||||||
|
const providerWithoutModelSchemas = {
|
||||||
|
provider: 'test-provider',
|
||||||
|
provider_credential_schema: {
|
||||||
|
credential_form_schemas: undefined,
|
||||||
|
},
|
||||||
|
model_credential_schema: {
|
||||||
|
credential_form_schemas: undefined,
|
||||||
|
},
|
||||||
|
} as unknown as ModelProvider
|
||||||
|
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={providerWithoutModelSchemas}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.customizableModel}
|
||||||
|
model={mockModel}
|
||||||
|
open
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert: component renders without error (extendedSecretFormSchemas = [])
|
||||||
|
expect(screen.getAllByText(/modelProvider\.auth\.specifyModelCredential/).length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not update draft config when rename finds no matching index in prevIndex', async () => {
|
||||||
|
// Arrange: credential in payload does not match any config (prevIndex = -1)
|
||||||
|
mockRefetch.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
available_credentials: [
|
||||||
|
{ credential_id: 'cred-99', credential_name: 'Unknown' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={mockProvider}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
model={mockModel}
|
||||||
|
open
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act: "config rename credential" triggers onUpdate with credential: { credential_id: 'cred-1' }
|
||||||
|
// but refetch returns cred-99, so newIndex for cred-1 is -1
|
||||||
|
await user.click(screen.getByRole('button', { name: 'config rename credential' }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRefetch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save to verify the config was not changed
|
||||||
|
await user.click(screen.getByText(/operation\.save/))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMutateAsync).toHaveBeenCalled()
|
||||||
|
const payload = mockMutateAsync.mock.calls[0][0] as { load_balancing: { configs: unknown[] } }
|
||||||
|
// Config count unchanged (still 2 from original)
|
||||||
|
expect(payload.load_balancing.configs).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should encode credential_name as empty string when available_credentials has no name', async () => {
|
||||||
|
// Arrange: available_credentials has a credential with no credential_name
|
||||||
|
mockRefetch.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
available_credentials: [
|
||||||
|
{ credential_id: 'cred-1', credential_name: '' },
|
||||||
|
{ credential_id: 'cred-2', credential_name: 'Backup' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
renderModal(
|
||||||
|
<ModelLoadBalancingModal
|
||||||
|
provider={mockProvider}
|
||||||
|
configurateMethod={ConfigurationMethodEnum.predefinedModel}
|
||||||
|
model={mockModel}
|
||||||
|
open
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act: rename cred-1 which now has empty credential_name
|
||||||
|
await user.click(screen.getByRole('button', { name: 'config rename credential' }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRefetch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
await user.click(screen.getByText(/operation\.save/))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMutateAsync).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -163,6 +163,18 @@ const ModelLoadBalancingModal = ({
|
|||||||
onSave?.(provider.provider)
|
onSave?.(provider.provider)
|
||||||
onClose?.()
|
onClose?.()
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
notify({
|
||||||
|
type: 'error',
|
||||||
|
message: (res as { error?: string })?.error || t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
notify({
|
||||||
|
type: 'error',
|
||||||
|
message: error instanceof Error ? error.message : t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@ -218,7 +230,7 @@ const ModelLoadBalancingModal = ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [refetch, credential])
|
}, [refetch, onClose])
|
||||||
|
|
||||||
const handleUpdateWhenSwitchCredential = useCallback(async () => {
|
const handleUpdateWhenSwitchCredential = useCallback(async () => {
|
||||||
const result = await refetch()
|
const result = await refetch()
|
||||||
@ -250,7 +262,7 @@ const ModelLoadBalancingModal = ({
|
|||||||
modelName={model!.model}
|
modelName={model!.model}
|
||||||
/>
|
/>
|
||||||
<ModelName
|
<ModelName
|
||||||
className="system-md-regular grow text-text-secondary"
|
className="grow text-text-secondary system-md-regular"
|
||||||
modelItem={model!}
|
modelItem={model!}
|
||||||
showModelType
|
showModelType
|
||||||
showMode
|
showMode
|
||||||
|
|||||||
@ -1,14 +1,45 @@
|
|||||||
import { render } from '@testing-library/react'
|
import type { i18n } from 'i18next'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import * as reactI18next from 'react-i18next'
|
||||||
import PriorityUseTip from './priority-use-tip'
|
import PriorityUseTip from './priority-use-tip'
|
||||||
|
|
||||||
describe('PriorityUseTip', () => {
|
describe('PriorityUseTip', () => {
|
||||||
it('should render tooltip with icon content', () => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render tooltip with icon content', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
const { container } = render(<PriorityUseTip />)
|
const { container } = render(<PriorityUseTip />)
|
||||||
expect(container.querySelector('[data-state]')).toBeInTheDocument()
|
const trigger = container.querySelector('.cursor-pointer')
|
||||||
|
expect(trigger).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.hover(trigger as HTMLElement)
|
||||||
|
|
||||||
|
expect(await screen.findByText('common.modelProvider.priorityUsing')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render the component without crashing', () => {
|
it('should render the component without crashing', () => {
|
||||||
const { container } = render(<PriorityUseTip />)
|
const { container } = render(<PriorityUseTip />)
|
||||||
expect(container.firstChild).toBeInTheDocument()
|
expect(container.firstChild).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should exercise || fallback when t() returns empty string', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
vi.spyOn(reactI18next, 'useTranslation').mockReturnValue({
|
||||||
|
t: () => '',
|
||||||
|
i18n: {} as unknown as i18n,
|
||||||
|
ready: true,
|
||||||
|
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
|
||||||
|
const { container } = render(<PriorityUseTip />)
|
||||||
|
const trigger = container.querySelector('.cursor-pointer')
|
||||||
|
expect(trigger).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.hover(trigger as HTMLElement)
|
||||||
|
|
||||||
|
expect(screen.queryByText('common.modelProvider.priorityUsing')).not.toBeInTheDocument()
|
||||||
|
expect(document.querySelector('.rounded-md.bg-components-panel-bg')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { ModelProvider } from '../declarations'
|
import type { ModelProvider } from '../declarations'
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
import QuotaPanel from './quota-panel'
|
import QuotaPanel from './quota-panel'
|
||||||
|
|
||||||
let mockWorkspace = {
|
let mockWorkspace = {
|
||||||
@ -13,18 +14,6 @@ let mockPlugins = [{
|
|||||||
latest_package_identifier: 'openai@1.0.0',
|
latest_package_identifier: 'openai@1.0.0',
|
||||||
}]
|
}]
|
||||||
|
|
||||||
vi.mock('@/app/components/base/icons/src/public/llm', () => {
|
|
||||||
const Icon = ({ label }: { label: string }) => <span>{label}</span>
|
|
||||||
return {
|
|
||||||
OpenaiSmall: () => <Icon label="openai" />,
|
|
||||||
AnthropicShortLight: () => <Icon label="anthropic" />,
|
|
||||||
Gemini: () => <Icon label="gemini" />,
|
|
||||||
Grok: () => <Icon label="x" />,
|
|
||||||
Deepseek: () => <Icon label="deepseek" />,
|
|
||||||
Tongyi: () => <Icon label="tongyi" />,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mock('@/context/app-context', () => ({
|
vi.mock('@/context/app-context', () => ({
|
||||||
useAppContext: () => ({
|
useAppContext: () => ({
|
||||||
currentWorkspace: mockWorkspace,
|
currentWorkspace: mockWorkspace,
|
||||||
@ -80,6 +69,18 @@ describe('QuotaPanel', () => {
|
|||||||
mockPlugins = [{ plugin_id: 'langgenius/openai', latest_package_identifier: 'openai@1.0.0' }]
|
mockPlugins = [{ plugin_id: 'langgenius/openai', latest_package_identifier: 'openai@1.0.0' }]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getTrialProviderIconTrigger = (container: HTMLElement) => {
|
||||||
|
const providerIcon = container.querySelector('svg.h-6.w-6.rounded-lg')
|
||||||
|
expect(providerIcon).toBeInTheDocument()
|
||||||
|
const trigger = providerIcon?.closest('[data-state]') as HTMLDivElement | null
|
||||||
|
expect(trigger).toBeInTheDocument()
|
||||||
|
return trigger as HTMLDivElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const clickFirstTrialProviderIcon = (container: HTMLElement) => {
|
||||||
|
fireEvent.click(getTrialProviderIconTrigger(container))
|
||||||
|
}
|
||||||
|
|
||||||
it('should render loading state', () => {
|
it('should render loading state', () => {
|
||||||
render(
|
render(
|
||||||
<QuotaPanel
|
<QuotaPanel
|
||||||
@ -116,17 +117,17 @@ describe('QuotaPanel', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should open install modal when clicking an unsupported trial provider', () => {
|
it('should open install modal when clicking an unsupported trial provider', () => {
|
||||||
render(<QuotaPanel providers={[]} />)
|
const { container } = render(<QuotaPanel providers={[]} />)
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('openai'))
|
clickFirstTrialProviderIcon(container)
|
||||||
|
|
||||||
expect(screen.getByText('install modal')).toBeInTheDocument()
|
expect(screen.getByText('install modal')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should close install modal when provider becomes installed', async () => {
|
it('should close install modal when provider becomes installed', async () => {
|
||||||
const { rerender } = render(<QuotaPanel providers={[]} />)
|
const { rerender, container } = render(<QuotaPanel providers={[]} />)
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('openai'))
|
clickFirstTrialProviderIcon(container)
|
||||||
expect(screen.getByText('install modal')).toBeInTheDocument()
|
expect(screen.getByText('install modal')).toBeInTheDocument()
|
||||||
|
|
||||||
rerender(<QuotaPanel providers={mockProviders} />)
|
rerender(<QuotaPanel providers={mockProviders} />)
|
||||||
@ -135,4 +136,61 @@ describe('QuotaPanel', () => {
|
|||||||
expect(screen.queryByText('install modal')).not.toBeInTheDocument()
|
expect(screen.queryByText('install modal')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not open install modal when clicking an already installed provider', () => {
|
||||||
|
const { container } = render(<QuotaPanel providers={mockProviders} />)
|
||||||
|
|
||||||
|
clickFirstTrialProviderIcon(container)
|
||||||
|
|
||||||
|
expect(screen.queryByText('install modal')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not open install modal when plugin is not found in marketplace', () => {
|
||||||
|
mockPlugins = []
|
||||||
|
const { container } = render(<QuotaPanel providers={[]} />)
|
||||||
|
|
||||||
|
clickFirstTrialProviderIcon(container)
|
||||||
|
|
||||||
|
expect(screen.queryByText('install modal')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show destructive border when credits are zero or negative', () => {
|
||||||
|
mockWorkspace = {
|
||||||
|
trial_credits: 0,
|
||||||
|
trial_credits_used: 0,
|
||||||
|
next_credit_reset_date: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(<QuotaPanel providers={mockProviders} />)
|
||||||
|
|
||||||
|
expect(container.querySelector('.border-state-destructive-border')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show modelAPI tooltip for configured provider with custom preference', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = render(<QuotaPanel providers={mockProviders} />)
|
||||||
|
|
||||||
|
const trigger = getTrialProviderIconTrigger(container)
|
||||||
|
await user.hover(trigger as HTMLElement)
|
||||||
|
|
||||||
|
expect(await screen.findByText(/common\.modelProvider\.card\.modelAPI/)).toHaveTextContent('OpenAI')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show modelSupported tooltip for installed provider without custom config', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const systemProviders = [
|
||||||
|
{
|
||||||
|
provider: 'langgenius/openai/openai',
|
||||||
|
preferred_provider_type: 'system',
|
||||||
|
custom_configuration: { available_credentials: [] },
|
||||||
|
},
|
||||||
|
] as unknown as ModelProvider[]
|
||||||
|
|
||||||
|
const { container } = render(<QuotaPanel providers={systemProviders} />)
|
||||||
|
|
||||||
|
const trigger = getTrialProviderIconTrigger(container)
|
||||||
|
await user.hover(trigger as HTMLElement)
|
||||||
|
|
||||||
|
expect(await screen.findByText(/common\.modelProvider\.card\.modelSupported/)).toHaveTextContent('OpenAI')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { DefaultModelResponse } from '../declarations'
|
import type { DefaultModelResponse } from '../declarations'
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { vi } from 'vitest'
|
import { vi } from 'vitest'
|
||||||
|
import { ToastContext } from '@/app/components/base/toast/context'
|
||||||
import { ModelTypeEnum } from '../declarations'
|
import { ModelTypeEnum } from '../declarations'
|
||||||
import SystemModel from './index'
|
import SystemModel from './index'
|
||||||
|
|
||||||
@ -42,11 +43,15 @@ vi.mock('@/context/provider-context', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/toast/context', () => ({
|
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
|
||||||
useToastContext: () => ({
|
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
|
||||||
notify: mockNotify,
|
return {
|
||||||
}),
|
...actual,
|
||||||
}))
|
useToastContext: () => ({
|
||||||
|
notify: mockNotify,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('../hooks', () => ({
|
vi.mock('../hooks', () => ({
|
||||||
useModelList: () => ({
|
useModelList: () => ({
|
||||||
@ -89,18 +94,24 @@ const defaultProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('SystemModel', () => {
|
describe('SystemModel', () => {
|
||||||
|
const renderSystemModel = (props: typeof defaultProps) => render(
|
||||||
|
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
|
||||||
|
<SystemModel {...props} />
|
||||||
|
</ToastContext.Provider>,
|
||||||
|
)
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockIsCurrentWorkspaceManager = true
|
mockIsCurrentWorkspaceManager = true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render settings button', () => {
|
it('should render settings button', () => {
|
||||||
render(<SystemModel {...defaultProps} />)
|
renderSystemModel(defaultProps)
|
||||||
expect(screen.getByRole('button', { name: /system model settings/i })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: /system model settings/i })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should open modal when button is clicked', async () => {
|
it('should open modal when button is clicked', async () => {
|
||||||
render(<SystemModel {...defaultProps} />)
|
renderSystemModel(defaultProps)
|
||||||
const button = screen.getByRole('button', { name: /system model settings/i })
|
const button = screen.getByRole('button', { name: /system model settings/i })
|
||||||
fireEvent.click(button)
|
fireEvent.click(button)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@ -109,12 +120,12 @@ describe('SystemModel', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should disable button when loading', () => {
|
it('should disable button when loading', () => {
|
||||||
render(<SystemModel {...defaultProps} isLoading />)
|
renderSystemModel({ ...defaultProps, isLoading: true })
|
||||||
expect(screen.getByRole('button', { name: /system model settings/i })).toBeDisabled()
|
expect(screen.getByRole('button', { name: /system model settings/i })).toBeDisabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should close modal when cancel is clicked', async () => {
|
it('should close modal when cancel is clicked', async () => {
|
||||||
render(<SystemModel {...defaultProps} />)
|
renderSystemModel(defaultProps)
|
||||||
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
|
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
|
||||||
@ -126,7 +137,7 @@ describe('SystemModel', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should save selected models and show success feedback', async () => {
|
it('should save selected models and show success feedback', async () => {
|
||||||
render(<SystemModel {...defaultProps} />)
|
renderSystemModel(defaultProps)
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
|
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@ -150,11 +161,103 @@ describe('SystemModel', () => {
|
|||||||
|
|
||||||
it('should disable save when user is not workspace manager', async () => {
|
it('should disable save when user is not workspace manager', async () => {
|
||||||
mockIsCurrentWorkspaceManager = false
|
mockIsCurrentWorkspaceManager = false
|
||||||
render(<SystemModel {...defaultProps} />)
|
renderSystemModel(defaultProps)
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
|
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled()
|
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render primary variant button when notConfigured is true', () => {
|
||||||
|
renderSystemModel({ ...defaultProps, notConfigured: true })
|
||||||
|
const button = screen.getByRole('button', { name: /system model settings/i })
|
||||||
|
expect(button.className).toContain('btn-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep modal open when save returns non-success result', async () => {
|
||||||
|
mockUpdateDefaultModel.mockResolvedValueOnce({ result: 'error' })
|
||||||
|
renderSystemModel(defaultProps)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectorButtons = screen.getAllByRole('button', { name: 'Mock Model Selector' })
|
||||||
|
selectorButtons.forEach(button => fireEvent.click(button))
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /save/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateDefaultModel).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockNotify).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Modal should still be open after failed save
|
||||||
|
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not add duplicate model type to changedModelTypes when same type is selected twice', async () => {
|
||||||
|
renderSystemModel(defaultProps)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click the first selector twice (textGeneration type)
|
||||||
|
const selectorButtons = screen.getAllByRole('button', { name: 'Mock Model Selector' })
|
||||||
|
fireEvent.click(selectorButtons[0])
|
||||||
|
fireEvent.click(selectorButtons[0])
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /save/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateDefaultModel).toHaveBeenCalledTimes(1)
|
||||||
|
// textGeneration was changed, so updateModelList is called once for it
|
||||||
|
expect(mockUpdateModelList).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call updateModelList for speech2text and tts types on save', async () => {
|
||||||
|
renderSystemModel(defaultProps)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click speech2text (index 3) and tts (index 4) selectors
|
||||||
|
const selectorButtons = screen.getAllByRole('button', { name: 'Mock Model Selector' })
|
||||||
|
fireEvent.click(selectorButtons[3])
|
||||||
|
fireEvent.click(selectorButtons[4])
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /save/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateModelList).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call updateModelList for each unique changed model type on save', async () => {
|
||||||
|
renderSystemModel(defaultProps)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click embedding and rerank selectors (indices 1 and 2)
|
||||||
|
const selectorButtons = screen.getAllByRole('button', { name: 'Mock Model Selector' })
|
||||||
|
fireEvent.click(selectorButtons[1])
|
||||||
|
fireEvent.click(selectorButtons[2])
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /save/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateModelList).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -33,7 +33,7 @@ vi.mock('@/service/common', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
describe('utils', () => {
|
describe('utils', () => {
|
||||||
afterEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -97,6 +97,18 @@ describe('utils', () => {
|
|||||||
const result = await validateCredentials(true, 'provider', {})
|
const result = await validateCredentials(true, 'provider', {})
|
||||||
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'network error' })
|
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'network error' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should return Unknown error when non-Error is thrown', async () => {
|
||||||
|
(validateModelProvider as unknown as Mock).mockRejectedValue('string error')
|
||||||
|
const result = await validateCredentials(true, 'provider', {})
|
||||||
|
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'Unknown error' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return default error message when error field is empty', async () => {
|
||||||
|
(validateModelProvider as unknown as Mock).mockResolvedValue({ result: 'error', error: '' })
|
||||||
|
const result = await validateCredentials(true, 'provider', {})
|
||||||
|
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'error' })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('validateLoadBalancingCredentials', () => {
|
describe('validateLoadBalancingCredentials', () => {
|
||||||
@ -140,6 +152,24 @@ describe('utils', () => {
|
|||||||
const result = await validateLoadBalancingCredentials(true, 'provider', {})
|
const result = await validateLoadBalancingCredentials(true, 'provider', {})
|
||||||
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'failed' })
|
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'failed' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should return Unknown error when non-Error is thrown', async () => {
|
||||||
|
(validateModelLoadBalancingCredentials as unknown as Mock).mockRejectedValue(42)
|
||||||
|
const result = await validateLoadBalancingCredentials(true, 'provider', {})
|
||||||
|
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'Unknown error' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle exception with Error', async () => {
|
||||||
|
(validateModelLoadBalancingCredentials as unknown as Mock).mockRejectedValue(new Error('Timeout'))
|
||||||
|
const result = await validateLoadBalancingCredentials(true, 'provider', {})
|
||||||
|
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'Timeout' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return default error message when error field is empty', async () => {
|
||||||
|
(validateModelLoadBalancingCredentials as unknown as Mock).mockResolvedValue({ result: 'error', error: '' })
|
||||||
|
const result = await validateLoadBalancingCredentials(true, 'provider', {})
|
||||||
|
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'error' })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('saveCredentials', () => {
|
describe('saveCredentials', () => {
|
||||||
@ -216,6 +246,19 @@ describe('utils', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should remove predefined credentials without credentialId', async () => {
|
||||||
|
await removeCredentials(true, 'provider', {})
|
||||||
|
expect(deleteModelProvider).toHaveBeenCalledWith({
|
||||||
|
url: '/workspaces/current/model-providers/provider/credentials',
|
||||||
|
body: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not call delete endpoint when non-predefined payload is falsy', async () => {
|
||||||
|
await removeCredentials(false, 'provider', null as unknown as Record<string, unknown>)
|
||||||
|
expect(deleteModelProvider).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('genModelTypeFormSchema', () => {
|
describe('genModelTypeFormSchema', () => {
|
||||||
@ -228,11 +271,22 @@ describe('utils', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('genModelNameFormSchema', () => {
|
describe('genModelNameFormSchema', () => {
|
||||||
it('should generate form schema', () => {
|
it('should generate default form schema when no model provided', () => {
|
||||||
const schema = genModelNameFormSchema()
|
const schema = genModelNameFormSchema()
|
||||||
expect(schema.type).toBe(FormTypeEnum.textInput)
|
expect(schema.type).toBe(FormTypeEnum.textInput)
|
||||||
expect(schema.variable).toBe('__model_name')
|
expect(schema.variable).toBe('__model_name')
|
||||||
expect(schema.required).toBe(true)
|
expect(schema.required).toBe(true)
|
||||||
|
expect(schema.label.en_US).toBe('Model Name')
|
||||||
|
expect(schema.placeholder!.en_US).toBe('Please enter model name')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use provided label and placeholder when model is given', () => {
|
||||||
|
const schema = genModelNameFormSchema({
|
||||||
|
label: { en_US: 'Custom', zh_Hans: 'Custom' },
|
||||||
|
placeholder: { en_US: 'Enter custom', zh_Hans: 'Enter custom' },
|
||||||
|
})
|
||||||
|
expect(schema.label.en_US).toBe('Custom')
|
||||||
|
expect(schema.placeholder!.en_US).toBe('Enter custom')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -146,14 +146,15 @@ export const removeCredentials = async (predefined: boolean, provider: string, v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
if (v) {
|
if (!v)
|
||||||
const { __model_name, __model_type } = v
|
return
|
||||||
body = {
|
|
||||||
model: __model_name,
|
const { __model_name, __model_type } = v
|
||||||
model_type: __model_type,
|
body = {
|
||||||
}
|
model: __model_name,
|
||||||
url = `/workspaces/current/model-providers/${provider}/models`
|
model_type: __model_type,
|
||||||
}
|
}
|
||||||
|
url = `/workspaces/current/model-providers/${provider}/models`
|
||||||
}
|
}
|
||||||
|
|
||||||
return deleteModelProvider({ url, body })
|
return deleteModelProvider({ url, body })
|
||||||
|
|||||||
@ -20,9 +20,13 @@ const mockEventEmitter = vi.hoisted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
vi.mock('@/app/components/base/toast/context', () => ({
|
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
|
||||||
useToastContext: vi.fn(),
|
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
|
||||||
}))
|
return {
|
||||||
|
...actual,
|
||||||
|
useToastContext: vi.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('@/context/app-context', () => ({
|
vi.mock('@/context/app-context', () => ({
|
||||||
useAppContext: vi.fn(),
|
useAppContext: vi.fn(),
|
||||||
|
|||||||
@ -14,11 +14,15 @@ vi.mock('@/context/app-context', () => ({
|
|||||||
useAppContext: vi.fn(),
|
useAppContext: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/base/toast/context', () => ({
|
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
|
||||||
useToastContext: () => ({
|
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
|
||||||
notify: vi.fn(),
|
return {
|
||||||
}),
|
...actual,
|
||||||
}))
|
useToastContext: () => ({
|
||||||
|
notify: vi.fn(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('@/context/event-emitter', () => ({
|
vi.mock('@/context/event-emitter', () => ({
|
||||||
useEventEmitterContextContext: () => ({
|
useEventEmitterContextContext: () => ({
|
||||||
|
|||||||
@ -264,4 +264,78 @@ describe('AppNav', () => {
|
|||||||
await user.click(screen.getByTestId('load-more'))
|
await user.click(screen.getByTestId('load-more'))
|
||||||
expect(fetchNextPage).not.toHaveBeenCalled()
|
expect(fetchNextPage).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Non-editor link path: isCurrentWorkspaceEditor=false → link ends with /overview
|
||||||
|
it('should build overview links when user is not editor', () => {
|
||||||
|
// Arrange
|
||||||
|
setupDefaultMocks({ isEditor: false })
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<AppNav />)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(screen.getByText('App 1 -> /app/app-1/overview')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// !!appId false: query disabled, no nav items
|
||||||
|
it('should render no nav items when appId is undefined', () => {
|
||||||
|
// Arrange
|
||||||
|
setupDefaultMocks()
|
||||||
|
mockUseParams.mockReturnValue({} as ReturnType<typeof useParams>)
|
||||||
|
mockUseInfiniteAppList.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
fetchNextPage: vi.fn(),
|
||||||
|
hasNextPage: false,
|
||||||
|
isFetchingNextPage: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
} as unknown as ReturnType<typeof useInfiniteAppList>)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<AppNav />)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const navItems = screen.getByTestId('nav-items')
|
||||||
|
expect(navItems.children).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ADVANCED_CHAT OR branch: editor + ADVANCED_CHAT mode → link ends with /workflow
|
||||||
|
it('should build workflow link for ADVANCED_CHAT mode when user is editor', () => {
|
||||||
|
// Arrange
|
||||||
|
setupDefaultMocks({
|
||||||
|
isEditor: true,
|
||||||
|
appData: [
|
||||||
|
{
|
||||||
|
id: 'app-3',
|
||||||
|
name: 'Chat App',
|
||||||
|
mode: AppModeEnum.ADVANCED_CHAT,
|
||||||
|
icon_type: 'emoji',
|
||||||
|
icon: '💬',
|
||||||
|
icon_background: null,
|
||||||
|
icon_url: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<AppNav />)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(screen.getByText('Chat App -> /app/app-3/workflow')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// No-match update path: appDetail.id doesn't match any nav item
|
||||||
|
it('should not change nav item names when appDetail id does not match any item', async () => {
|
||||||
|
// Arrange
|
||||||
|
setupDefaultMocks({ isEditor: true })
|
||||||
|
const { rerender } = render(<AppNav />)
|
||||||
|
|
||||||
|
// Act - set appDetail to a non-matching id
|
||||||
|
mockAppDetail = { id: 'non-existent-id', name: 'Unknown' }
|
||||||
|
rerender(<AppNav />)
|
||||||
|
|
||||||
|
// Assert - original name should be unchanged
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('App 1 -> /app/app-1/configuration')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -6,10 +6,6 @@ function createMockComponent(testId: string) {
|
|||||||
return () => <div data-testid={testId} />
|
return () => <div data-testid={testId} />
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock('@/app/components/base/logo/dify-logo', () => ({
|
|
||||||
default: createMockComponent('dify-logo'),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/app/components/header/account-dropdown/workplace-selector', () => ({
|
vi.mock('@/app/components/header/account-dropdown/workplace-selector', () => ({
|
||||||
default: createMockComponent('workplace-selector'),
|
default: createMockComponent('workplace-selector'),
|
||||||
}))
|
}))
|
||||||
@ -129,7 +125,7 @@ describe('Header', () => {
|
|||||||
it('should render header with main nav components', () => {
|
it('should render header with main nav components', () => {
|
||||||
render(<Header />)
|
render(<Header />)
|
||||||
|
|
||||||
expect(screen.getByTestId('dify-logo')).toBeInTheDocument()
|
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('workplace-selector')).toBeInTheDocument()
|
expect(screen.getByTestId('workplace-selector')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('app-nav')).toBeInTheDocument()
|
expect(screen.getByTestId('app-nav')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('account-dropdown')).toBeInTheDocument()
|
expect(screen.getByTestId('account-dropdown')).toBeInTheDocument()
|
||||||
@ -173,7 +169,7 @@ describe('Header', () => {
|
|||||||
mockMedia = 'mobile'
|
mockMedia = 'mobile'
|
||||||
render(<Header />)
|
render(<Header />)
|
||||||
|
|
||||||
expect(screen.getByTestId('dify-logo')).toBeInTheDocument()
|
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('env-nav')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('env-nav')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -186,6 +182,70 @@ describe('Header', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('Acme Workspace')).toBeInTheDocument()
|
expect(screen.getByText('Acme Workspace')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('img', { name: /logo/i })).toBeInTheDocument()
|
expect(screen.getByRole('img', { name: /logo/i })).toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('dify-logo')).not.toBeInTheDocument()
|
expect(screen.queryByRole('img', { name: /dify logo/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show default Dify logo when branding is enabled but no workspace_logo', () => {
|
||||||
|
mockBrandingEnabled = true
|
||||||
|
mockBrandingTitle = 'Custom Title'
|
||||||
|
mockBrandingLogo = null
|
||||||
|
|
||||||
|
render(<Header />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Custom Title')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show default Dify text when branding enabled but no application_title', () => {
|
||||||
|
mockBrandingEnabled = true
|
||||||
|
mockBrandingTitle = null
|
||||||
|
mockBrandingLogo = null
|
||||||
|
|
||||||
|
render(<Header />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Dify')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show dataset nav for editor who is not dataset operator', () => {
|
||||||
|
mockIsWorkspaceEditor = true
|
||||||
|
mockIsDatasetOperator = false
|
||||||
|
|
||||||
|
render(<Header />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('dataset-nav')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('explore-nav')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('app-nav')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide dataset nav when neither editor nor dataset operator', () => {
|
||||||
|
mockIsWorkspaceEditor = false
|
||||||
|
mockIsDatasetOperator = false
|
||||||
|
|
||||||
|
render(<Header />)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('dataset-nav')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render mobile layout with dataset operator nav restrictions', () => {
|
||||||
|
mockMedia = 'mobile'
|
||||||
|
mockIsDatasetOperator = true
|
||||||
|
|
||||||
|
render(<Header />)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('explore-nav')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('app-nav')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('tools-nav')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('dataset-nav')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render mobile layout with billing enabled', () => {
|
||||||
|
mockMedia = 'mobile'
|
||||||
|
mockEnableBilling = true
|
||||||
|
mockPlanType = 'sandbox'
|
||||||
|
|
||||||
|
render(<Header />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('plan-badge')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('license-nav')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
61
web/app/components/header/utils/util.spec.ts
Normal file
61
web/app/components/header/utils/util.spec.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { generateMailToLink, mailToSupport } from './util'
|
||||||
|
|
||||||
|
describe('generateMailToLink', () => {
|
||||||
|
// Email-only: both subject and body branches false
|
||||||
|
it('should return mailto link with email only when no subject or body provided', () => {
|
||||||
|
// Act
|
||||||
|
const result = generateMailToLink('test@example.com')
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toBe('mailto:test@example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Subject provided, body not: subject branch true, body branch false
|
||||||
|
it('should append subject when subject is provided without body', () => {
|
||||||
|
// Act
|
||||||
|
const result = generateMailToLink('test@example.com', 'Hello World')
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toBe('mailto:test@example.com?subject=Hello%20World')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Body provided, no subject: subject branch false, body branch true
|
||||||
|
it('should append body with question mark when body is provided without subject', () => {
|
||||||
|
// Act
|
||||||
|
const result = generateMailToLink('test@example.com', undefined, 'Some body text')
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toBe('mailto:test@example.com&body=Some%20body%20text')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Both subject and body provided: both branches true
|
||||||
|
it('should append both subject and body when both are provided', () => {
|
||||||
|
// Act
|
||||||
|
const result = generateMailToLink('test@example.com', 'Subject', 'Body text')
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toBe('mailto:test@example.com?subject=Subject&body=Body%20text')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mailToSupport', () => {
|
||||||
|
// Transitive coverage: exercises generateMailToLink with all params
|
||||||
|
it('should generate a mailto link with support recipient, plan, account, and version info', () => {
|
||||||
|
// Act
|
||||||
|
const result = mailToSupport('user@test.com', 'Pro', '1.0.0')
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.startsWith('mailto:support@dify.ai?')).toBe(true)
|
||||||
|
|
||||||
|
const query = result.split('?')[1]
|
||||||
|
expect(query).toBeDefined()
|
||||||
|
|
||||||
|
const params = new URLSearchParams(query)
|
||||||
|
expect(params.get('subject')).toBe('Technical Support Request Pro user@test.com')
|
||||||
|
|
||||||
|
const body = params.get('body')
|
||||||
|
expect(body).toContain('Current Plan: Pro')
|
||||||
|
expect(body).toContain('Account: user@test.com')
|
||||||
|
expect(body).toContain('Version: 1.0.0')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -4752,9 +4752,6 @@
|
|||||||
"no-restricted-imports": {
|
"no-restricted-imports": {
|
||||||
"count": 2
|
"count": 2
|
||||||
},
|
},
|
||||||
"tailwindcss/enforce-consistent-class-order": {
|
|
||||||
"count": 10
|
|
||||||
},
|
|
||||||
"ts/no-explicit-any": {
|
"ts/no-explicit-any": {
|
||||||
"count": 6
|
"count": 6
|
||||||
}
|
}
|
||||||
@ -4931,9 +4928,6 @@
|
|||||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
"tailwindcss/enforce-consistent-class-order": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"ts/no-explicit-any": {
|
"ts/no-explicit-any": {
|
||||||
"count": 3
|
"count": 3
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user