mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 01:45:13 +00:00
test: tighten user-visible specs and raise coverage for key-validator… (#32281)
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { ValidatedStatus } from './declarations'
|
||||
import KeyInput from './KeyInput'
|
||||
|
||||
type Props = ComponentProps<typeof KeyInput>
|
||||
|
||||
const createProps = (overrides: Partial<Props> = {}): Props => ({
|
||||
name: 'API key',
|
||||
placeholder: 'Enter API key',
|
||||
value: 'initial-value',
|
||||
onChange: vi.fn(),
|
||||
onFocus: undefined,
|
||||
validating: false,
|
||||
validatedStatusState: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('KeyInput', () => {
|
||||
it('shows the label and placeholder value', () => {
|
||||
const props = createProps()
|
||||
render(<KeyInput {...props} />)
|
||||
|
||||
expect(screen.getByText('API key')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Enter API key')).toHaveValue('initial-value')
|
||||
})
|
||||
|
||||
it('updates the visible input value when user types', () => {
|
||||
const ControlledKeyInput = () => {
|
||||
const [value, setValue] = useState('initial-value')
|
||||
return (
|
||||
<KeyInput
|
||||
{...createProps({
|
||||
value,
|
||||
onChange: setValue,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ControlledKeyInput />)
|
||||
fireEvent.change(screen.getByPlaceholderText('Enter API key'), { target: { value: 'updated' } })
|
||||
|
||||
expect(screen.getByPlaceholderText('Enter API key')).toHaveValue('updated')
|
||||
})
|
||||
|
||||
it('cycles through validating and error messaging', () => {
|
||||
const props = createProps()
|
||||
const { rerender } = render(
|
||||
<KeyInput {...props} validating validatedStatusState={{}} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.provider.validating')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<KeyInput
|
||||
{...props}
|
||||
validating={false}
|
||||
validatedStatusState={{ status: ValidatedStatus.Error, message: 'bad-request' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.provider.validatedErrorbad-request')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show an error tip for exceed status', () => {
|
||||
render(
|
||||
<KeyInput
|
||||
{...createProps({
|
||||
validating: false,
|
||||
validatedStatusState: { status: ValidatedStatus.Exceed, message: 'quota' },
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/common\.provider\.validatedError/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show validating or error text for success status', () => {
|
||||
render(
|
||||
<KeyInput
|
||||
{...createProps({
|
||||
validating: false,
|
||||
validatedStatusState: { status: ValidatedStatus.Success },
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('common.provider.validating')).toBeNull()
|
||||
expect(screen.queryByText(/common\.provider\.validatedError/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows fallback error text when error message is missing', () => {
|
||||
render(
|
||||
<KeyInput
|
||||
{...createProps({
|
||||
validating: false,
|
||||
validatedStatusState: { status: ValidatedStatus.Error },
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.provider.validatedError')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Operate from './Operate'
|
||||
|
||||
describe('Operate', () => {
|
||||
it('renders cancel and save when editing', () => {
|
||||
render(
|
||||
<Operate
|
||||
isOpen
|
||||
status="add"
|
||||
onAdd={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows add key prompt when closed', () => {
|
||||
render(
|
||||
<Operate
|
||||
isOpen={false}
|
||||
status="add"
|
||||
onAdd={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.provider.addKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows invalid state indicator and edit prompt when status is fail', () => {
|
||||
render(
|
||||
<Operate
|
||||
isOpen={false}
|
||||
status="fail"
|
||||
onAdd={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.provider.invalidApiKey')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.provider.editKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows edit prompt without error text when status is success', () => {
|
||||
render(
|
||||
<Operate
|
||||
isOpen={false}
|
||||
status="success"
|
||||
onAdd={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.provider.editKey')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.provider.invalidApiKey')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no actions for unsupported status', () => {
|
||||
render(
|
||||
<Operate
|
||||
isOpen={false}
|
||||
status={'unknown' as never}
|
||||
onAdd={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('common.provider.addKey')).toBeNull()
|
||||
expect(screen.queryByText('common.provider.editKey')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import {
|
||||
ValidatedErrorIcon,
|
||||
ValidatedErrorMessage,
|
||||
ValidatedSuccessIcon,
|
||||
ValidatingTip,
|
||||
} from './ValidateStatus'
|
||||
|
||||
describe('ValidateStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should show validating text while validation is running', () => {
|
||||
render(<ValidatingTip />)
|
||||
|
||||
expect(screen.getByText('common.provider.validating')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show translated error text with the backend message', () => {
|
||||
render(<ValidatedErrorMessage errorMessage="invalid-token" />)
|
||||
|
||||
expect(screen.getByText('common.provider.validatedErrorinvalid-token')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render decorative icon for success and error states', () => {
|
||||
const { container, rerender } = render(<ValidatedSuccessIcon />)
|
||||
|
||||
expect(container.firstElementChild).toBeTruthy()
|
||||
|
||||
rerender(<ValidatedErrorIcon />)
|
||||
|
||||
expect(container.firstElementChild).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ValidatedStatus } from './declarations'
|
||||
|
||||
describe('declarations', () => {
|
||||
describe('ValidatedStatus', () => {
|
||||
it('should expose expected status values', () => {
|
||||
expect(ValidatedStatus.Success).toBe('success')
|
||||
expect(ValidatedStatus.Error).toBe('error')
|
||||
expect(ValidatedStatus.Exceed).toBe('exceed')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { ValidatedStatus } from './declarations'
|
||||
import { useValidate } from './hooks'
|
||||
|
||||
describe('useValidate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should clear validation state when before returns false', async () => {
|
||||
const { result } = renderHook(() => useValidate({ apiKey: 'value' }))
|
||||
|
||||
act(() => {
|
||||
result.current[0]({ before: () => false })
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
|
||||
expect(result.current[1]).toBe(false)
|
||||
expect(result.current[2]).toEqual({})
|
||||
})
|
||||
|
||||
it('should expose success status after a successful validation', async () => {
|
||||
const run = vi.fn().mockResolvedValue({ status: ValidatedStatus.Success })
|
||||
const { result } = renderHook(() => useValidate({ apiKey: 'value' }))
|
||||
|
||||
act(() => {
|
||||
result.current[0]({
|
||||
before: () => true,
|
||||
run,
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
|
||||
expect(result.current[1]).toBe(false)
|
||||
expect(result.current[2]).toEqual({ status: ValidatedStatus.Success })
|
||||
})
|
||||
|
||||
it('should expose error status and message when validation fails', async () => {
|
||||
const run = vi.fn().mockResolvedValue({ status: ValidatedStatus.Error, message: 'bad-key' })
|
||||
const { result } = renderHook(() => useValidate({ apiKey: 'value' }))
|
||||
|
||||
act(() => {
|
||||
result.current[0]({
|
||||
before: () => true,
|
||||
run,
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
|
||||
expect(result.current[1]).toBe(false)
|
||||
expect(result.current[2]).toEqual({ status: ValidatedStatus.Error, message: 'bad-key' })
|
||||
})
|
||||
|
||||
it('should keep validating state true when run is not provided', async () => {
|
||||
const { result } = renderHook(() => useValidate({ apiKey: 'value' }))
|
||||
|
||||
act(() => {
|
||||
result.current[0]({ before: () => true })
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
|
||||
expect(result.current[1]).toBe(true)
|
||||
expect(result.current[2]).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { Form } from './declarations'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import KeyValidator from './index'
|
||||
|
||||
let subscriptionCallback: ((value: string) => void) | null = null
|
||||
const mockEmit = vi.fn((value: string) => {
|
||||
subscriptionCallback?.(value)
|
||||
})
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
emit: mockEmit,
|
||||
useSubscription: (cb: (value: string) => void) => {
|
||||
subscriptionCallback = cb
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockValidate = vi.fn()
|
||||
const mockUseValidate = vi.fn()
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
useValidate: (...args: unknown[]) => mockUseValidate(...args),
|
||||
}))
|
||||
|
||||
describe('KeyValidator', () => {
|
||||
const formValidate = {
|
||||
before: () => true,
|
||||
}
|
||||
|
||||
const forms: Form[] = [
|
||||
{
|
||||
key: 'apiKey',
|
||||
title: 'API key',
|
||||
placeholder: 'Enter API key',
|
||||
value: 'initial-key',
|
||||
validate: formValidate,
|
||||
handleFocus: (_value, setValue) => {
|
||||
setValue(prev => ({ ...prev, apiKey: 'focused-key' }))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const createProps = (overrides: Partial<ComponentProps<typeof KeyValidator>> = {}) => ({
|
||||
type: 'test-provider',
|
||||
title: <div>Provider key</div>,
|
||||
status: 'add' as const,
|
||||
forms,
|
||||
keyFrom: {
|
||||
text: 'Get key',
|
||||
link: 'https://example.com/key',
|
||||
},
|
||||
onSave: vi.fn().mockResolvedValue(true),
|
||||
disabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
subscriptionCallback = null
|
||||
mockValidate.mockImplementation((config?: { before?: () => boolean }) => config?.before?.())
|
||||
mockUseValidate.mockReturnValue([mockValidate, false, {}])
|
||||
})
|
||||
|
||||
it('should open and close the editor from add and cancel actions', () => {
|
||||
render(<KeyValidator {...createProps()} />)
|
||||
|
||||
fireEvent.click(screen.getByText('common.provider.addKey'))
|
||||
|
||||
expect(screen.getByPlaceholderText('Enter API key')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Get key' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
expect(screen.queryByPlaceholderText('Enter API key')).toBeNull()
|
||||
})
|
||||
|
||||
it('should submit the updated value when save is clicked', async () => {
|
||||
render(<KeyValidator {...createProps()} />)
|
||||
|
||||
fireEvent.click(screen.getByText('common.provider.addKey'))
|
||||
const input = screen.getByPlaceholderText('Enter API key')
|
||||
|
||||
fireEvent.focus(input)
|
||||
expect(input).toHaveValue('focused-key')
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'updated-key' },
|
||||
})
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('Enter API key')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep the editor open when save does not succeed', async () => {
|
||||
const formsWithoutValidation: Form[] = [
|
||||
{
|
||||
key: 'apiKey',
|
||||
title: 'API key',
|
||||
placeholder: 'Enter API key',
|
||||
},
|
||||
]
|
||||
const props = createProps({
|
||||
forms: formsWithoutValidation,
|
||||
onSave: vi.fn().mockResolvedValue(false),
|
||||
})
|
||||
render(<KeyValidator {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText('common.provider.addKey'))
|
||||
const input = screen.getByPlaceholderText('Enter API key')
|
||||
|
||||
expect(input).toHaveValue('')
|
||||
|
||||
fireEvent.focus(input)
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'typed-without-validator' },
|
||||
})
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
expect(screen.getByPlaceholderText('Enter API key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close and reset edited values when another validator emits a trigger', () => {
|
||||
render(<KeyValidator {...createProps()} />)
|
||||
|
||||
fireEvent.click(screen.getByText('common.provider.addKey'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Enter API key'), {
|
||||
target: { value: 'changed' },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
subscriptionCallback?.('plugins/another-provider')
|
||||
})
|
||||
|
||||
expect(screen.queryByPlaceholderText('Enter API key')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByText('common.provider.addKey'))
|
||||
|
||||
expect(screen.getByPlaceholderText('Enter API key')).toHaveValue('initial-key')
|
||||
})
|
||||
|
||||
it('should prevent opening key editor when disabled', () => {
|
||||
render(<KeyValidator {...createProps()} disabled />)
|
||||
|
||||
fireEvent.click(screen.getByText('common.provider.addKey'))
|
||||
|
||||
expect(screen.queryByPlaceholderText('Enter API key')).toBeNull()
|
||||
})
|
||||
|
||||
it('should open the editor from edit action when validator is in success state', () => {
|
||||
render(<KeyValidator {...createProps({ status: 'success' })} />)
|
||||
|
||||
fireEvent.click(screen.getByText('common.provider.editKey'))
|
||||
|
||||
expect(screen.getByPlaceholderText('Enter API key')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { UserProfileResponse } from '@/models/common'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { ToastProvider } from '@/app/components/base/toast'
|
||||
import { languages } from '@/i18n-config/language'
|
||||
import { updateUserProfile } from '@/service/common'
|
||||
import { timezones } from '@/utils/timezone'
|
||||
import LanguagePage from './index'
|
||||
|
||||
const mockRefresh = vi.fn()
|
||||
const mockMutateUserProfile = vi.fn()
|
||||
let mockLocale: string | undefined = 'en-US'
|
||||
let mockUserProfile: UserProfileResponse
|
||||
|
||||
vi.mock('@/app/components/base/select', async () => {
|
||||
const React = await import('react')
|
||||
|
||||
return {
|
||||
SimpleSelect: ({
|
||||
items = [],
|
||||
defaultValue,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
items?: Array<{ value: string | number, name: string }>
|
||||
defaultValue?: string | number
|
||||
onSelect: (item: { value: string | number, name: string }) => void
|
||||
disabled?: boolean
|
||||
}) => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [selectedValue, setSelectedValue] = React.useState<string | number | undefined>(defaultValue)
|
||||
const selected = items.find(item => item.value === selectedValue)
|
||||
?? items.find(item => item.value === defaultValue)
|
||||
?? null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" disabled={disabled} onClick={() => setOpen(prev => !prev)}>
|
||||
{selected?.name ?? ''}
|
||||
</button>
|
||||
{open && (
|
||||
<div>
|
||||
{items.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
role="option"
|
||||
onClick={() => {
|
||||
setSelectedValue(item.value)
|
||||
onSelect(item)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ refresh: mockRefresh }),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
userProfile: mockUserProfile,
|
||||
mutateUserProfile: mockMutateUserProfile,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => mockLocale,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
updateUserProfile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config', () => ({
|
||||
setLocaleOnClient: vi.fn(),
|
||||
}))
|
||||
|
||||
const updateUserProfileMock = vi.mocked(updateUserProfile)
|
||||
|
||||
const createUserProfile = (overrides: Partial<UserProfileResponse> = {}): UserProfileResponse => ({
|
||||
id: 'user-id',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
avatar: '',
|
||||
avatar_url: null,
|
||||
is_password_set: false,
|
||||
interface_language: 'en-US',
|
||||
timezone: 'Pacific/Niue',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderPage = () => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<LanguagePage />
|
||||
</ToastProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
const getSectionByLabel = (sectionLabel: string) => {
|
||||
const label = screen.getByText(sectionLabel)
|
||||
const section = label.closest('div')?.parentElement
|
||||
if (!section)
|
||||
throw new Error(`Missing select section: ${sectionLabel}`)
|
||||
return section
|
||||
}
|
||||
|
||||
const selectOption = async (sectionLabel: string, optionName: string) => {
|
||||
const section = getSectionByLabel(sectionLabel)
|
||||
await act(async () => {
|
||||
fireEvent.click(within(section).getByRole('button'))
|
||||
})
|
||||
await act(async () => {
|
||||
fireEvent.click(await within(section).findByRole('option', { name: optionName }))
|
||||
})
|
||||
}
|
||||
|
||||
const getLanguageOption = (value: string) => {
|
||||
const option = languages.find(item => item.value === value)
|
||||
if (!option)
|
||||
throw new Error(`Missing language option: ${value}`)
|
||||
return option
|
||||
}
|
||||
|
||||
const getTimezoneOption = (value: string) => {
|
||||
const option = timezones.find(item => item.value === value)
|
||||
if (!option)
|
||||
throw new Error(`Missing timezone option: ${value}`)
|
||||
return option
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
mockLocale = 'en-US'
|
||||
mockUserProfile = createUserProfile()
|
||||
})
|
||||
|
||||
// Rendering
|
||||
describe('LanguagePage - Rendering', () => {
|
||||
it('should render default language and timezone labels', () => {
|
||||
const english = getLanguageOption('en-US')
|
||||
const niueTimezone = getTimezoneOption('Pacific/Niue')
|
||||
mockLocale = undefined
|
||||
mockUserProfile = createUserProfile({
|
||||
interface_language: english.value.toString(),
|
||||
timezone: niueTimezone.value.toString(),
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByText('common.language.displayLanguage')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.language.timezone')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: english.name })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: niueTimezone.name })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Interactions
|
||||
describe('LanguagePage - Interactions', () => {
|
||||
it('should show success toast when language updates', async () => {
|
||||
const chinese = getLanguageOption('zh-Hans')
|
||||
mockUserProfile = createUserProfile({ interface_language: 'en-US' })
|
||||
updateUserProfileMock.mockResolvedValueOnce({ result: 'success' })
|
||||
|
||||
renderPage()
|
||||
|
||||
await selectOption('common.language.displayLanguage', chinese.name)
|
||||
|
||||
expect(await screen.findByText('common.actionMsg.modifiedSuccessfully')).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(updateUserProfileMock).toHaveBeenCalledWith({
|
||||
url: '/account/interface-language',
|
||||
body: { interface_language: chinese.value },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error toast when language update fails', async () => {
|
||||
const chinese = getLanguageOption('zh-Hans')
|
||||
updateUserProfileMock.mockRejectedValueOnce(new Error('Update failed'))
|
||||
|
||||
renderPage()
|
||||
|
||||
await selectOption('common.language.displayLanguage', chinese.name)
|
||||
|
||||
expect(await screen.findByText('Update failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show success toast when timezone updates', async () => {
|
||||
const midwayTimezone = getTimezoneOption('Pacific/Midway')
|
||||
updateUserProfileMock.mockResolvedValueOnce({ result: 'success' })
|
||||
|
||||
renderPage()
|
||||
|
||||
await selectOption('common.language.timezone', midwayTimezone.name)
|
||||
|
||||
expect(await screen.findByText('common.actionMsg.modifiedSuccessfully')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: midwayTimezone.name })).toBeInTheDocument()
|
||||
}, 15000)
|
||||
|
||||
it('should show error toast when timezone update fails', async () => {
|
||||
const midwayTimezone = getTimezoneOption('Pacific/Midway')
|
||||
updateUserProfileMock.mockRejectedValueOnce(new Error('Timezone failed'))
|
||||
|
||||
renderPage()
|
||||
|
||||
await selectOption('common.language.timezone', midwayTimezone.name)
|
||||
|
||||
expect(await screen.findByText('Timezone failed')).toBeInTheDocument()
|
||||
}, 15000)
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { PluginProvider } from '@/models/common'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import SerpapiPlugin from './SerpapiPlugin'
|
||||
import { updatePluginKey, validatePluginKey } from './utils'
|
||||
|
||||
const mockEventEmitter = vi.hoisted(() => {
|
||||
let subscriber: ((value: string) => void) | undefined
|
||||
return {
|
||||
useSubscription: vi.fn((callback: (value: string) => void) => {
|
||||
subscriber = callback
|
||||
}),
|
||||
emit: vi.fn((value: string) => {
|
||||
subscriber?.(value)
|
||||
}),
|
||||
reset: () => {
|
||||
subscriber = undefined
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./utils', () => ({
|
||||
updatePluginKey: vi.fn(),
|
||||
validatePluginKey: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: vi.fn(() => ({
|
||||
eventEmitter: mockEventEmitter,
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('SerpapiPlugin', () => {
|
||||
const mockOnUpdate = vi.fn()
|
||||
const mockNotify = vi.fn()
|
||||
const mockUpdatePluginKey = updatePluginKey as ReturnType<typeof vi.fn>
|
||||
const mockValidatePluginKey = validatePluginKey as ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEventEmitter.reset()
|
||||
const mockUseAppContext = useAppContext as ReturnType<typeof vi.fn>
|
||||
const mockUseToastContext = useToastContext as ReturnType<typeof vi.fn>
|
||||
mockUseAppContext.mockReturnValue({
|
||||
isCurrentWorkspaceManager: true,
|
||||
})
|
||||
mockUseToastContext.mockReturnValue({
|
||||
notify: mockNotify,
|
||||
})
|
||||
mockValidatePluginKey.mockResolvedValue({ status: 'success' })
|
||||
mockUpdatePluginKey.mockResolvedValue({ status: 'success' })
|
||||
})
|
||||
|
||||
it('should show key input when manager clicks edit key', () => {
|
||||
const mockPlugin: PluginProvider = {
|
||||
tool_name: 'serpapi',
|
||||
credentials: {
|
||||
api_key: 'existing-key',
|
||||
},
|
||||
} as PluginProvider
|
||||
|
||||
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
|
||||
|
||||
fireEvent.click(screen.getByText('common.provider.editKey'))
|
||||
expect(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should clear existing key on focus and show validation error for invalid key', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
mockValidatePluginKey.mockResolvedValue({ status: 'error', message: 'Invalid API key' })
|
||||
|
||||
const mockPlugin: PluginProvider = {
|
||||
tool_name: 'serpapi',
|
||||
credentials: {
|
||||
api_key: 'existing-key',
|
||||
},
|
||||
} as PluginProvider
|
||||
|
||||
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
|
||||
|
||||
fireEvent.click(screen.getByText('common.provider.editKey'))
|
||||
const input = screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')
|
||||
|
||||
expect(input).toHaveValue('existing-key')
|
||||
fireEvent.focus(input)
|
||||
expect(input).toHaveValue('')
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'invalid-key' },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
|
||||
expect(screen.getByText(/Invalid API key/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.focus(input)
|
||||
expect(input).toHaveValue('invalid-key')
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: '' },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
|
||||
expect(screen.queryByText(/Invalid API key/)).toBeNull()
|
||||
}
|
||||
finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('should not open key input when user is not workspace manager', () => {
|
||||
const mockUseAppContext = useAppContext as ReturnType<typeof vi.fn>
|
||||
mockUseAppContext.mockReturnValue({
|
||||
isCurrentWorkspaceManager: false,
|
||||
})
|
||||
|
||||
const mockPlugin = {
|
||||
tool_name: 'serpapi',
|
||||
is_enabled: true,
|
||||
credentials: null,
|
||||
} satisfies PluginProvider
|
||||
|
||||
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
|
||||
|
||||
fireEvent.click(screen.getByText('common.provider.addKey'))
|
||||
|
||||
expect(screen.queryByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')).toBeNull()
|
||||
})
|
||||
|
||||
it('should save changed key and trigger success feedback', async () => {
|
||||
const mockPlugin: PluginProvider = {
|
||||
tool_name: 'serpapi',
|
||||
credentials: {
|
||||
api_key: 'existing-key',
|
||||
},
|
||||
} as PluginProvider
|
||||
|
||||
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
|
||||
|
||||
fireEvent.click(screen.getByText('common.provider.editKey'))
|
||||
fireEvent.change(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder'), {
|
||||
target: { value: 'new-key' },
|
||||
})
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep editor open when save request fails', async () => {
|
||||
mockUpdatePluginKey.mockResolvedValue({ status: 'error', message: 'update failed' })
|
||||
|
||||
const mockPlugin: PluginProvider = {
|
||||
tool_name: 'serpapi',
|
||||
credentials: {
|
||||
api_key: 'existing-key',
|
||||
},
|
||||
} as PluginProvider
|
||||
|
||||
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
|
||||
|
||||
fireEvent.click(screen.getByText('common.provider.editKey'))
|
||||
fireEvent.change(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder'), {
|
||||
target: { value: 'new-key' },
|
||||
})
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep editor open when key value is unchanged', async () => {
|
||||
const mockPlugin: PluginProvider = {
|
||||
tool_name: 'serpapi',
|
||||
credentials: {
|
||||
api_key: 'existing-key',
|
||||
},
|
||||
} as PluginProvider
|
||||
|
||||
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
|
||||
|
||||
fireEvent.click(screen.getByText('common.provider.editKey'))
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import PluginPage from './index'
|
||||
import { updatePluginKey, validatePluginKey } from './utils'
|
||||
|
||||
const mockUsePluginProviders = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
usePluginProviders: mockUsePluginProviders,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
emit: vi.fn(),
|
||||
useSubscription: vi.fn(),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./utils', () => ({
|
||||
updatePluginKey: vi.fn(),
|
||||
validatePluginKey: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('PluginPage', () => {
|
||||
const mockUpdatePluginKey = updatePluginKey as ReturnType<typeof vi.fn>
|
||||
const mockValidatePluginKey = validatePluginKey as ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const mockUseAppContext = useAppContext as ReturnType<typeof vi.fn>
|
||||
mockUseAppContext.mockReturnValue({
|
||||
isCurrentWorkspaceManager: true,
|
||||
})
|
||||
mockValidatePluginKey.mockResolvedValue({ status: 'success' })
|
||||
mockUpdatePluginKey.mockResolvedValue({ status: 'success' })
|
||||
})
|
||||
|
||||
it('should render plugin settings with edit action when serpapi key exists', () => {
|
||||
mockUsePluginProviders.mockReturnValue({
|
||||
data: [
|
||||
{ tool_name: 'serpapi', credentials: { api_key: 'test-key' } },
|
||||
],
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
render(<PluginPage />)
|
||||
expect(screen.getByText('common.provider.editKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plugin settings with add action when serpapi key is missing', () => {
|
||||
mockUsePluginProviders.mockReturnValue({
|
||||
data: [
|
||||
{ tool_name: 'serpapi', credentials: null },
|
||||
],
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
render(<PluginPage />)
|
||||
expect(screen.getByText('common.provider.addKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display encryption notice with PKCS1_OAEP link', () => {
|
||||
mockUsePluginProviders.mockReturnValue({
|
||||
data: [],
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
render(<PluginPage />)
|
||||
expect(screen.getByText(/common\.provider\.encrypted\.front/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/common\.provider\.encrypted\.back/)).toBeInTheDocument()
|
||||
const link = screen.getByRole('link', { name: 'PKCS1_OAEP' })
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html')
|
||||
})
|
||||
|
||||
it('should show reload state after saving key', async () => {
|
||||
let showReloadedState = () => {}
|
||||
const Wrapper = () => {
|
||||
const [reloaded, setReloaded] = useState(false)
|
||||
showReloadedState = () => setReloaded(true)
|
||||
return (
|
||||
<>
|
||||
<PluginPage />
|
||||
{reloaded && <div>providers-reloaded</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
mockUsePluginProviders.mockImplementation(() => ({
|
||||
data: [{ tool_name: 'serpapi', credentials: { api_key: 'existing-key' } }],
|
||||
refetch: () => showReloadedState(),
|
||||
}))
|
||||
|
||||
render(<Wrapper />)
|
||||
|
||||
fireEvent.click(screen.getByText('common.provider.editKey'))
|
||||
fireEvent.change(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder'), {
|
||||
target: { value: 'new-key' },
|
||||
})
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('providers-reloaded')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { updatePluginProviderAIKey, validatePluginProviderKey } from '@/service/common'
|
||||
import { ValidatedStatus } from '../key-validator/declarations'
|
||||
import { updatePluginKey, validatePluginKey } from './utils'
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
validatePluginProviderKey: vi.fn(),
|
||||
updatePluginProviderAIKey: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockValidatePluginProviderKey = validatePluginProviderKey as ReturnType<typeof vi.fn>
|
||||
const mockUpdatePluginProviderAIKey = updatePluginProviderAIKey as ReturnType<typeof vi.fn>
|
||||
|
||||
describe('Plugin Utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe.each([
|
||||
{
|
||||
name: 'validatePluginKey',
|
||||
utilFn: validatePluginKey,
|
||||
serviceMock: mockValidatePluginProviderKey,
|
||||
successBody: { credentials: { api_key: 'test-key' } },
|
||||
failureBody: { credentials: { api_key: 'invalid' } },
|
||||
exceptionBody: { credentials: { api_key: 'test' } },
|
||||
serviceErrorMessage: 'Invalid API key',
|
||||
thrownErrorMessage: 'Network error',
|
||||
},
|
||||
{
|
||||
name: 'updatePluginKey',
|
||||
utilFn: updatePluginKey,
|
||||
serviceMock: mockUpdatePluginProviderAIKey,
|
||||
successBody: { credentials: { api_key: 'new-key' } },
|
||||
failureBody: { credentials: { api_key: 'test' } },
|
||||
exceptionBody: { credentials: { api_key: 'test' } },
|
||||
serviceErrorMessage: 'Update failed',
|
||||
thrownErrorMessage: 'Request failed',
|
||||
},
|
||||
])('$name', ({ utilFn, serviceMock, successBody, failureBody, exceptionBody, serviceErrorMessage, thrownErrorMessage }) => {
|
||||
it('should return success status when service succeeds', async () => {
|
||||
serviceMock.mockResolvedValue({ result: 'success' })
|
||||
|
||||
const result = await utilFn('serpapi', successBody)
|
||||
|
||||
expect(result.status).toBe(ValidatedStatus.Success)
|
||||
})
|
||||
|
||||
it('should return error status with message when service returns an error', async () => {
|
||||
serviceMock.mockResolvedValue({
|
||||
result: 'error',
|
||||
error: serviceErrorMessage,
|
||||
})
|
||||
|
||||
const result = await utilFn('serpapi', failureBody)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: ValidatedStatus.Error,
|
||||
message: serviceErrorMessage,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return error status when service throws exception', async () => {
|
||||
serviceMock.mockRejectedValue(new Error(thrownErrorMessage))
|
||||
|
||||
const result = await utilFn('serpapi', exceptionBody)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: ValidatedStatus.Error,
|
||||
message: thrownErrorMessage,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user