test: tighten user-visible specs and raise coverage for key-validator… (#32281)

This commit is contained in:
akashseth-ifp
2026-02-23 09:45:34 +05:30
committed by GitHub
parent 8141e3af99
commit aad980f267
10 changed files with 1098 additions and 0 deletions

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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')
})
})
})

View File

@@ -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({})
})
})

View File

@@ -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()
})
})

View File

@@ -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)
})

View File

@@ -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()
})
})
})

View File

@@ -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()
})
})
})

View File

@@ -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,
})
})
})
})