From aad980f267f2e6df837482c6cafffae80d498ef3 Mon Sep 17 00:00:00 2001 From: akashseth-ifp Date: Mon, 23 Feb 2026 09:45:34 +0530 Subject: [PATCH] =?UTF-8?q?test:=20tighten=20user-visible=20specs=20and=20?= =?UTF-8?q?raise=20coverage=20for=20key-validator=E2=80=A6=20(#32281)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../key-validator/KeyInput.spec.tsx | 106 +++++++++ .../key-validator/Operate.spec.tsx | 83 +++++++ .../key-validator/ValidateStatus.spec.tsx | 35 +++ .../key-validator/declarations.spec.ts | 12 + .../key-validator/hooks.spec.ts | 82 +++++++ .../key-validator/index.spec.tsx | 162 +++++++++++++ .../language-page/index.spec.tsx | 221 ++++++++++++++++++ .../plugin-page/SerpapiPlugin.spec.tsx | 206 ++++++++++++++++ .../plugin-page/index.spec.tsx | 118 ++++++++++ .../account-setting/plugin-page/utils.spec.ts | 73 ++++++ 10 files changed, 1098 insertions(+) create mode 100644 web/app/components/header/account-setting/key-validator/KeyInput.spec.tsx create mode 100644 web/app/components/header/account-setting/key-validator/Operate.spec.tsx create mode 100644 web/app/components/header/account-setting/key-validator/ValidateStatus.spec.tsx create mode 100644 web/app/components/header/account-setting/key-validator/declarations.spec.ts create mode 100644 web/app/components/header/account-setting/key-validator/hooks.spec.ts create mode 100644 web/app/components/header/account-setting/key-validator/index.spec.tsx create mode 100644 web/app/components/header/account-setting/language-page/index.spec.tsx create mode 100644 web/app/components/header/account-setting/plugin-page/SerpapiPlugin.spec.tsx create mode 100644 web/app/components/header/account-setting/plugin-page/index.spec.tsx create mode 100644 web/app/components/header/account-setting/plugin-page/utils.spec.ts diff --git a/web/app/components/header/account-setting/key-validator/KeyInput.spec.tsx b/web/app/components/header/account-setting/key-validator/KeyInput.spec.tsx new file mode 100644 index 0000000000..60aafd1813 --- /dev/null +++ b/web/app/components/header/account-setting/key-validator/KeyInput.spec.tsx @@ -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 + +const createProps = (overrides: Partial = {}): 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() + + 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 ( + + ) + } + + render() + 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( + , + ) + + expect(screen.getByText('common.provider.validating')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.getByText('common.provider.validatedErrorbad-request')).toBeInTheDocument() + }) + + it('does not show an error tip for exceed status', () => { + render( + , + ) + + expect(screen.queryByText(/common\.provider\.validatedError/i)).toBeNull() + }) + + it('does not show validating or error text for success status', () => { + render( + , + ) + + 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( + , + ) + + expect(screen.getByText('common.provider.validatedError')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/key-validator/Operate.spec.tsx b/web/app/components/header/account-setting/key-validator/Operate.spec.tsx new file mode 100644 index 0000000000..8ecd1a9f0e --- /dev/null +++ b/web/app/components/header/account-setting/key-validator/Operate.spec.tsx @@ -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( + , + ) + + expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() + expect(screen.getByText('common.operation.save')).toBeInTheDocument() + }) + + it('shows add key prompt when closed', () => { + render( + , + ) + + expect(screen.getByText('common.provider.addKey')).toBeInTheDocument() + }) + + it('shows invalid state indicator and edit prompt when status is fail', () => { + render( + , + ) + + 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( + , + ) + + expect(screen.getByText('common.provider.editKey')).toBeInTheDocument() + expect(screen.queryByText('common.provider.invalidApiKey')).toBeNull() + }) + + it('shows no actions for unsupported status', () => { + render( + , + ) + + expect(screen.queryByText('common.provider.addKey')).toBeNull() + expect(screen.queryByText('common.provider.editKey')).toBeNull() + }) +}) diff --git a/web/app/components/header/account-setting/key-validator/ValidateStatus.spec.tsx b/web/app/components/header/account-setting/key-validator/ValidateStatus.spec.tsx new file mode 100644 index 0000000000..78ff6b06e1 --- /dev/null +++ b/web/app/components/header/account-setting/key-validator/ValidateStatus.spec.tsx @@ -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() + + expect(screen.getByText('common.provider.validating')).toBeInTheDocument() + }) + + it('should show translated error text with the backend message', () => { + render() + + expect(screen.getByText('common.provider.validatedErrorinvalid-token')).toBeInTheDocument() + }) + + it('should render decorative icon for success and error states', () => { + const { container, rerender } = render() + + expect(container.firstElementChild).toBeTruthy() + + rerender() + + expect(container.firstElementChild).toBeTruthy() + }) +}) diff --git a/web/app/components/header/account-setting/key-validator/declarations.spec.ts b/web/app/components/header/account-setting/key-validator/declarations.spec.ts new file mode 100644 index 0000000000..c7621ff265 --- /dev/null +++ b/web/app/components/header/account-setting/key-validator/declarations.spec.ts @@ -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') + }) + }) +}) diff --git a/web/app/components/header/account-setting/key-validator/hooks.spec.ts b/web/app/components/header/account-setting/key-validator/hooks.spec.ts new file mode 100644 index 0000000000..1beddf02f0 --- /dev/null +++ b/web/app/components/header/account-setting/key-validator/hooks.spec.ts @@ -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({}) + }) +}) diff --git a/web/app/components/header/account-setting/key-validator/index.spec.tsx b/web/app/components/header/account-setting/key-validator/index.spec.tsx new file mode 100644 index 0000000000..740b21169c --- /dev/null +++ b/web/app/components/header/account-setting/key-validator/index.spec.tsx @@ -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> = {}) => ({ + type: 'test-provider', + title:
Provider key
, + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + fireEvent.click(screen.getByText('common.provider.editKey')) + + expect(screen.getByPlaceholderText('Enter API key')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/language-page/index.spec.tsx b/web/app/components/header/account-setting/language-page/index.spec.tsx new file mode 100644 index 0000000000..1748987570 --- /dev/null +++ b/web/app/components/header/account-setting/language-page/index.spec.tsx @@ -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(defaultValue) + const selected = items.find(item => item.value === selectedValue) + ?? items.find(item => item.value === defaultValue) + ?? null + + return ( +
+ + {open && ( +
+ {items.map(item => ( + + ))} +
+ )} +
+ ) + }, + } +}) + +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 => ({ + 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( + + + , + ) +} + +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) +}) diff --git a/web/app/components/header/account-setting/plugin-page/SerpapiPlugin.spec.tsx b/web/app/components/header/account-setting/plugin-page/SerpapiPlugin.spec.tsx new file mode 100644 index 0000000000..de480d47a1 --- /dev/null +++ b/web/app/components/header/account-setting/plugin-page/SerpapiPlugin.spec.tsx @@ -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 + const mockValidatePluginKey = validatePluginKey as ReturnType + + beforeEach(() => { + vi.clearAllMocks() + mockEventEmitter.reset() + const mockUseAppContext = useAppContext as ReturnType + const mockUseToastContext = useToastContext as ReturnType + 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() + + 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() + + 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 + mockUseAppContext.mockReturnValue({ + isCurrentWorkspaceManager: false, + }) + + const mockPlugin = { + tool_name: 'serpapi', + is_enabled: true, + credentials: null, + } satisfies PluginProvider + + render() + + 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() + + 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() + + 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() + + fireEvent.click(screen.getByText('common.provider.editKey')) + fireEvent.click(screen.getByText('common.operation.save')) + + await waitFor(() => { + expect(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/header/account-setting/plugin-page/index.spec.tsx b/web/app/components/header/account-setting/plugin-page/index.spec.tsx new file mode 100644 index 0000000000..654292443f --- /dev/null +++ b/web/app/components/header/account-setting/plugin-page/index.spec.tsx @@ -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 + const mockValidatePluginKey = validatePluginKey as ReturnType + + beforeEach(() => { + vi.clearAllMocks() + const mockUseAppContext = useAppContext as ReturnType + 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() + 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() + expect(screen.getByText('common.provider.addKey')).toBeInTheDocument() + }) + + it('should display encryption notice with PKCS1_OAEP link', () => { + mockUsePluginProviders.mockReturnValue({ + data: [], + refetch: vi.fn(), + }) + + render() + 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 ( + <> + + {reloaded &&
providers-reloaded
} + + ) + } + mockUsePluginProviders.mockImplementation(() => ({ + data: [{ tool_name: 'serpapi', credentials: { api_key: 'existing-key' } }], + refetch: () => showReloadedState(), + })) + + render() + + 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() + }) + }) +}) diff --git a/web/app/components/header/account-setting/plugin-page/utils.spec.ts b/web/app/components/header/account-setting/plugin-page/utils.spec.ts new file mode 100644 index 0000000000..720bc956b8 --- /dev/null +++ b/web/app/components/header/account-setting/plugin-page/utils.spec.ts @@ -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 +const mockUpdatePluginProviderAIKey = updatePluginProviderAIKey as ReturnType + +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, + }) + }) + }) +})