mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 21:35:45 +08:00
test(web): exercise external API dialogs and chat input owners (#44721)
This commit is contained in:
1 parent
0b06322423
commit
4208034b07
4 files changed
+502
-751
No files matched your search
+239
-404
@@ -1,429 +1,264 @@
|
||||
import type { ChatWithHistoryContextValue } from '../../context'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { useRef, useState } from 'react'
|
||||
import { fileUpload } from '@/app/components/base/file-uploader/utils'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { createChatConfigFixture, createChatFileFixture } from '@/test/fixtures/chat'
|
||||
import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { ChatWithHistoryContext } from '../../context'
|
||||
import { ChatWithHistoryContext, useChatWithHistoryContext } from '../../context'
|
||||
import InputsFormContent from '../content'
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({
|
||||
onChange,
|
||||
value,
|
||||
placeholder,
|
||||
}: {
|
||||
onChange: (v: string) => void
|
||||
value: string
|
||||
placeholder?: React.ReactNode
|
||||
}) => (
|
||||
<div>
|
||||
<textarea
|
||||
data-testid="mock-code-editor"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
{!!placeholder && (
|
||||
<div data-testid="mock-code-editor-placeholder">
|
||||
{React.isValidElement<{ children?: React.ReactNode }>(placeholder)
|
||||
? placeholder.props.children
|
||||
: ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
vi.mock('@/next/navigation', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/next/navigation')>()),
|
||||
useParams: () => ({}),
|
||||
usePathname: () => '/chat',
|
||||
}))
|
||||
|
||||
const uploadedFile: FileEntity = {
|
||||
id: 'uploaded-file-1',
|
||||
uploadedId: 'server-file-1',
|
||||
vi.mock('@/app/components/base/file-uploader/utils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/app/components/base/file-uploader/utils')>()),
|
||||
fileUpload: vi.fn(),
|
||||
}))
|
||||
|
||||
const uploadedFile = createChatFileFixture({
|
||||
name: 'report.pdf',
|
||||
size: 100,
|
||||
type: 'application/pdf',
|
||||
supportFileType: 'document',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
}
|
||||
|
||||
// MOCK: file-uploader (stable, deterministic for unit tests)
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileUploaderInAttachmentWrapper: ({
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
onChange: (files: FileEntity[]) => void
|
||||
value?: FileEntity[]
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mock-file-uploader"
|
||||
onClick={() => onChange([...(value ?? []), uploadedFile])}
|
||||
data-value-count={value?.length ?? 0}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
const mockSetCurrentConversationInputs = vi.fn()
|
||||
const mockHandleNewConversationInputsChange = vi.fn()
|
||||
|
||||
const defaultSystemParameters = {
|
||||
audio_file_size_limit: 1,
|
||||
file_size_limit: 1,
|
||||
image_file_size_limit: 1,
|
||||
video_file_size_limit: 1,
|
||||
workflow_file_upload_limit: 1,
|
||||
}
|
||||
|
||||
const createMockContext = (
|
||||
overrides: Partial<ChatWithHistoryContextValue> = {},
|
||||
): ChatWithHistoryContextValue => {
|
||||
const base: ChatWithHistoryContextValue = {
|
||||
appParams: {
|
||||
system_parameters: defaultSystemParameters,
|
||||
} as unknown as ChatWithHistoryContextValue['appParams'],
|
||||
inputsForms: [
|
||||
{ variable: 'text_var', type: InputVarType.textInput, label: 'Text Label', required: true },
|
||||
],
|
||||
currentConversationId: '123',
|
||||
currentConversationInputs: { text_var: 'current-value' },
|
||||
newConversationInputs: { text_var: 'new-value' },
|
||||
newConversationInputsRef: { current: { text_var: 'ref-value' } } as React.RefObject<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
setCurrentConversationInputs: mockSetCurrentConversationInputs,
|
||||
handleNewConversationInputsChange: mockHandleNewConversationInputsChange,
|
||||
allInputsHidden: false,
|
||||
appPrevChatTree: [],
|
||||
pinnedConversationList: [],
|
||||
conversationList: [],
|
||||
handleNewConversation: vi.fn(),
|
||||
handleStartChat: vi.fn(),
|
||||
handleChangeConversation: vi.fn(),
|
||||
handlePinConversation: vi.fn(),
|
||||
handleUnpinConversation: vi.fn(),
|
||||
handleDeleteConversation: vi.fn(),
|
||||
conversationRenaming: false,
|
||||
handleRenameConversation: vi.fn(),
|
||||
handleNewConversationCompleted: vi.fn(),
|
||||
chatShouldReloadKey: '',
|
||||
isMobile: false,
|
||||
isInstalledApp: false,
|
||||
handleFeedback: vi.fn(),
|
||||
currentChatInstanceRef: { current: { handleStop: vi.fn() } } as React.RefObject<{
|
||||
handleStop: () => void
|
||||
}>,
|
||||
sidebarCollapseState: false,
|
||||
handleSidebarCollapse: vi.fn(),
|
||||
setClearChatList: vi.fn(),
|
||||
setIsResponding: vi.fn(),
|
||||
...overrides,
|
||||
uploadedId: 'server-file-1',
|
||||
})
|
||||
type Options = Partial<
|
||||
Pick<
|
||||
ChatWithHistoryContextValue,
|
||||
'currentConversationId' | 'currentConversationInputs' | 'newConversationInputs' | 'inputsForms'
|
||||
>
|
||||
>
|
||||
function setup(options: Options = {}, showTip = false) {
|
||||
function Caller() {
|
||||
const defaults = useChatWithHistoryContext()
|
||||
const [currentInputs, setCurrentInputs] = useState(
|
||||
options.currentConversationInputs ?? { text_var: 'current-value' },
|
||||
)
|
||||
const [newInputs, setNewInputs] = useState(
|
||||
options.newConversationInputs ?? { text_var: 'new-value' },
|
||||
)
|
||||
const newInputsRef = useRef(newInputs)
|
||||
return (
|
||||
<ChatWithHistoryContext.Provider
|
||||
value={{
|
||||
...defaults,
|
||||
...options,
|
||||
appParams: createChatConfigFixture({
|
||||
system_parameters: createFileUploadConfigFixture(),
|
||||
}),
|
||||
inputsForms: options.inputsForms ?? [
|
||||
{
|
||||
variable: 'text_var',
|
||||
type: InputVarType.textInput,
|
||||
label: 'Text Label',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
currentConversationId: options.currentConversationId ?? '123',
|
||||
currentConversationInputs: currentInputs,
|
||||
newConversationInputs: newInputs,
|
||||
newConversationInputsRef: newInputsRef,
|
||||
setCurrentConversationInputs: setCurrentInputs,
|
||||
handleNewConversationInputsChange: (value) => {
|
||||
newInputsRef.current = value
|
||||
setNewInputs(value)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<InputsFormContent showTip={showTip} />
|
||||
<output aria-label="Current conversation inputs">{JSON.stringify(currentInputs)}</output>
|
||||
<output aria-label="New conversation inputs">{JSON.stringify(newInputs)}</output>
|
||||
</ChatWithHistoryContext.Provider>
|
||||
)
|
||||
}
|
||||
return base
|
||||
return renderWithConsoleQuery(<Caller />)
|
||||
}
|
||||
const currentInputs = () =>
|
||||
JSON.parse(screen.getByLabelText('Current conversation inputs').textContent!)
|
||||
const newInputs = () => JSON.parse(screen.getByLabelText('New conversation inputs').textContent!)
|
||||
|
||||
const MockContextProvider = ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
value: ChatWithHistoryContextValue
|
||||
}) => {
|
||||
// We need to manage state locally to support controlled components
|
||||
const [currentInputs, setCurrentInputs] = React.useState(value.currentConversationInputs)
|
||||
const [newInputs, setNewInputs] = React.useState(value.newConversationInputs)
|
||||
beforeEach(() => {
|
||||
vi.mocked(fileUpload)
|
||||
.mockReset()
|
||||
.mockImplementation(({ file, onSuccessCallback }) => {
|
||||
onSuccessCallback({
|
||||
id: 'server-file-1',
|
||||
name: file.name,
|
||||
mime_type: file.type,
|
||||
extension: 'pdf',
|
||||
size: file.size,
|
||||
created_at: 0,
|
||||
created_by: 'user-1',
|
||||
source_url: '',
|
||||
preview_url: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const newInputsRef = React.useRef(newInputs)
|
||||
|
||||
const contextValue: ChatWithHistoryContextValue = {
|
||||
...value,
|
||||
currentConversationInputs: currentInputs,
|
||||
newConversationInputs: newInputs,
|
||||
newConversationInputsRef: newInputsRef as React.RefObject<Record<string, unknown>>,
|
||||
setCurrentConversationInputs: (v: Record<string, unknown>) => {
|
||||
setCurrentInputs(v)
|
||||
value.setCurrentConversationInputs(v)
|
||||
},
|
||||
handleNewConversationInputsChange: (v: Record<string, unknown>) => {
|
||||
newInputsRef.current = v
|
||||
setNewInputs(v)
|
||||
value.handleNewConversationInputsChange(v)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatWithHistoryContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ChatWithHistoryContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('InputsFormContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const renderWithContext = (
|
||||
component: React.ReactNode,
|
||||
contextValue: ChatWithHistoryContextValue,
|
||||
) => {
|
||||
return render(<MockContextProvider value={contextValue}>{component}</MockContextProvider>)
|
||||
}
|
||||
|
||||
it('renders only visible forms and ignores hidden ones', () => {
|
||||
const context = createMockContext({
|
||||
it('renders only visible inputs and includes the footer tip when requested', () => {
|
||||
setup(
|
||||
{
|
||||
inputsForms: [
|
||||
{ variable: 'text_var', type: InputVarType.textInput, label: 'Text Label', required: true },
|
||||
{ variable: 'hidden_var', type: InputVarType.textInput, label: 'Hidden', hide: true },
|
||||
],
|
||||
})
|
||||
},
|
||||
true,
|
||||
)
|
||||
expect(screen.getByRole('textbox', { name: 'Text Label' })).toBeVisible()
|
||||
expect(screen.queryByText('Hidden')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('share.chat.chatFormTip')).toBeVisible()
|
||||
})
|
||||
|
||||
renderWithContext(<InputsFormContent />, context)
|
||||
|
||||
expect(screen.getByText('Text Label')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Hidden')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses currentConversationInputs when currentConversationId is present', () => {
|
||||
const context = createMockContext()
|
||||
renderWithContext(<InputsFormContent />, context)
|
||||
const input = screen.getByRole('textbox', { name: 'Text Label' }) as HTMLInputElement
|
||||
expect(input.value).toBe('current-value')
|
||||
})
|
||||
|
||||
it('falls back to newConversationInputs when currentConversationId is empty', () => {
|
||||
const context = createMockContext({
|
||||
currentConversationId: '',
|
||||
newConversationInputs: { text_var: 'new-value' },
|
||||
})
|
||||
|
||||
renderWithContext(<InputsFormContent />, context)
|
||||
const input = screen.getByRole('textbox', { name: 'Text Label' }) as HTMLInputElement
|
||||
expect(input.value).toBe('new-value')
|
||||
})
|
||||
|
||||
it('updates both current and new inputs when form content changes', async () => {
|
||||
it.each([
|
||||
{ currentConversationId: '123', value: 'current-value' },
|
||||
{ currentConversationId: '', value: 'new-value' },
|
||||
])(
|
||||
'edits the active input source and preserves both conversation drafts (%s)',
|
||||
async ({ currentConversationId, value }) => {
|
||||
const user = userEvent.setup()
|
||||
const context = createMockContext()
|
||||
renderWithContext(<InputsFormContent />, context)
|
||||
const input = screen.getByRole('textbox', { name: 'Text Label' }) as HTMLInputElement
|
||||
|
||||
setup({ currentConversationId })
|
||||
const input = screen.getByRole('textbox', { name: 'Text Label' })
|
||||
expect(input).toHaveValue(value)
|
||||
expect(screen.queryByText('share.chat.chatFormTip')).not.toBeInTheDocument()
|
||||
await user.clear(input)
|
||||
await user.type(input, 'updated')
|
||||
expect(input).toHaveValue('updated')
|
||||
expect(currentInputs()).toEqual({ text_var: 'updated' })
|
||||
expect(newInputs()).toEqual({ text_var: 'updated' })
|
||||
},
|
||||
)
|
||||
|
||||
expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ text_var: 'updated' }),
|
||||
)
|
||||
expect(mockHandleNewConversationInputsChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ text_var: 'updated' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders and handles number input updates', async () => {
|
||||
const user = userEvent.setup()
|
||||
const context = createMockContext({
|
||||
inputsForms: [{ variable: 'num', type: InputVarType.number, label: 'Num' }],
|
||||
currentConversationInputs: {},
|
||||
})
|
||||
|
||||
renderWithContext(<InputsFormContent />, context)
|
||||
const input = screen.getByRole('spinbutton', { name: 'Num' }) as HTMLInputElement
|
||||
expect(input).toHaveAttribute('type', 'number')
|
||||
|
||||
await user.type(input, '123')
|
||||
|
||||
expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ num: '123' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders numeric zero as zero', () => {
|
||||
const context = createMockContext({
|
||||
inputsForms: [{ variable: 'num', type: InputVarType.number, label: 'Num' }],
|
||||
currentConversationInputs: { num: 0 },
|
||||
})
|
||||
|
||||
renderWithContext(<InputsFormContent />, context)
|
||||
|
||||
expect(screen.getByRole('spinbutton', { name: 'Num' })).toHaveValue(0)
|
||||
})
|
||||
|
||||
it('renders and handles paragraph input updates', async () => {
|
||||
const user = userEvent.setup()
|
||||
const context = createMockContext({
|
||||
inputsForms: [{ variable: 'para', type: InputVarType.paragraph, label: 'Para' }],
|
||||
currentConversationInputs: {},
|
||||
})
|
||||
|
||||
renderWithContext(<InputsFormContent />, context)
|
||||
const textarea = screen.getByRole('textbox', { name: 'Para' }) as HTMLTextAreaElement
|
||||
await user.type(textarea, 'hello')
|
||||
|
||||
expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ para: 'hello' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('updates boolean inputs through the real checkbox', async () => {
|
||||
const user = userEvent.setup()
|
||||
const context = createMockContext({
|
||||
inputsForms: [{ variable: 'bool', type: InputVarType.checkbox, label: 'Bool' }],
|
||||
})
|
||||
|
||||
renderWithContext(<InputsFormContent />, context)
|
||||
const boolNode = screen.getByRole('checkbox', { name: /Bool/ })
|
||||
await user.click(boolNode)
|
||||
expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ bool: true }),
|
||||
)
|
||||
expect(boolNode).toBeChecked()
|
||||
})
|
||||
|
||||
it('handles select input with default value and updates', async () => {
|
||||
const user = userEvent.setup()
|
||||
const context = createMockContext({
|
||||
inputsForms: [
|
||||
{
|
||||
variable: 'sel',
|
||||
type: InputVarType.select,
|
||||
label: 'Sel',
|
||||
options: ['A', 'B'],
|
||||
default: 'B',
|
||||
},
|
||||
],
|
||||
currentConversationInputs: {},
|
||||
})
|
||||
|
||||
renderWithContext(<InputsFormContent />, context)
|
||||
// Click Select to open
|
||||
await user.click(screen.getByText('B'))
|
||||
|
||||
// Now option A should be available
|
||||
const optionA = screen.getByText('A')
|
||||
await user.click(optionA)
|
||||
|
||||
expect(mockSetCurrentConversationInputs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sel: 'A' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a saved selection even after the option is removed', () => {
|
||||
const context = createMockContext({
|
||||
inputsForms: [
|
||||
{
|
||||
variable: 'sel',
|
||||
type: InputVarType.select,
|
||||
label: 'Sel',
|
||||
options: ['A'],
|
||||
default: undefined,
|
||||
},
|
||||
],
|
||||
currentConversationInputs: { sel: 'existing' },
|
||||
})
|
||||
|
||||
renderWithContext(<InputsFormContent />, context)
|
||||
expect(screen.getByText('existing')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders and handles JSON object updates (uses mocked CodeEditor)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const context = createMockContext({
|
||||
inputsForms: [
|
||||
{
|
||||
variable: 'json',
|
||||
type: InputVarType.jsonObject,
|
||||
label: 'Json',
|
||||
json_schema: '{ "a": 1 }',
|
||||
},
|
||||
],
|
||||
currentConversationInputs: {},
|
||||
})
|
||||
|
||||
renderWithContext(<InputsFormContent />, context)
|
||||
expect(screen.getByTestId('mock-code-editor-placeholder').textContent).toContain('{ "a": 1 }')
|
||||
|
||||
const jsonEditor = screen.getByTestId('mock-code-editor') as HTMLTextAreaElement
|
||||
await user.clear(jsonEditor)
|
||||
await user.paste('{"a":2}')
|
||||
expect(mockSetCurrentConversationInputs).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ json: '{"a":2}' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('handles single file uploader with existing value (using mocked uploader)', () => {
|
||||
const context = createMockContext({
|
||||
inputsForms: [
|
||||
{
|
||||
variable: 'single',
|
||||
type: InputVarType.singleFile,
|
||||
label: 'Single',
|
||||
allowed_file_types: [],
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_upload_methods: [],
|
||||
},
|
||||
],
|
||||
currentConversationInputs: { single: uploadedFile },
|
||||
})
|
||||
|
||||
renderWithContext(<InputsFormContent />, context)
|
||||
expect(screen.getByTestId('mock-file-uploader')).toHaveAttribute('data-value-count', '1')
|
||||
})
|
||||
|
||||
it('handles single file uploader with no value and updates (using mocked uploader)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const context = createMockContext({
|
||||
inputsForms: [
|
||||
{
|
||||
variable: 'single',
|
||||
type: InputVarType.singleFile,
|
||||
label: 'Single',
|
||||
allowed_file_types: [],
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_upload_methods: [],
|
||||
},
|
||||
],
|
||||
currentConversationInputs: {},
|
||||
})
|
||||
|
||||
renderWithContext(<InputsFormContent />, context)
|
||||
expect(screen.getByTestId('mock-file-uploader')).toHaveAttribute('data-value-count', '0')
|
||||
|
||||
const uploader = screen.getByTestId('mock-file-uploader')
|
||||
await user.click(uploader)
|
||||
expect(mockSetCurrentConversationInputs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ single: uploadedFile }),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders and handles multi files uploader updates (using mocked uploader)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const context = createMockContext({
|
||||
inputsForms: [
|
||||
{ variable: 'multi', type: InputVarType.multiFiles, label: 'Multi', max_length: 3 },
|
||||
],
|
||||
currentConversationInputs: {},
|
||||
})
|
||||
|
||||
renderWithContext(<InputsFormContent />, context)
|
||||
const uploader = screen.getByTestId('mock-file-uploader')
|
||||
await user.click(uploader)
|
||||
|
||||
expect(mockSetCurrentConversationInputs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ multi: [uploadedFile] }),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders footer tip only when showTip prop is true', () => {
|
||||
const context = createMockContext()
|
||||
const { rerender } = renderWithContext(<InputsFormContent showTip={false} />, context)
|
||||
expect(screen.queryByText('share.chat.chatFormTip')).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<MockContextProvider value={context}>
|
||||
<InputsFormContent showTip={true} />
|
||||
</MockContextProvider>,
|
||||
)
|
||||
expect(screen.getByText('share.chat.chatFormTip')).toBeInTheDocument()
|
||||
it('updates numeric and paragraph fields without losing the other input', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup({
|
||||
inputsForms: [
|
||||
{ variable: 'num', type: InputVarType.number, label: 'Num' },
|
||||
{ variable: 'para', type: InputVarType.paragraph, label: 'Para' },
|
||||
],
|
||||
currentConversationInputs: { num: 0 },
|
||||
newConversationInputs: { num: 0 },
|
||||
})
|
||||
const number = screen.getByRole('spinbutton', { name: 'Num' })
|
||||
expect(number).toHaveValue(0)
|
||||
await user.clear(number)
|
||||
await user.type(number, '123')
|
||||
await user.type(screen.getByRole('textbox', { name: 'Para' }), 'hello')
|
||||
expect(number).toHaveValue(123)
|
||||
expect(screen.getByRole('textbox', { name: 'Para' })).toHaveValue('hello')
|
||||
expect(currentInputs()).toEqual({ num: '123', para: 'hello' })
|
||||
expect(newInputs()).toEqual({ num: '123', para: 'hello' })
|
||||
})
|
||||
|
||||
it('updates boolean inputs through the real checkbox', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup({ inputsForms: [{ variable: 'bool', type: InputVarType.checkbox, label: 'Bool' }] })
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Bool/ })
|
||||
await user.click(checkbox)
|
||||
expect(checkbox).toBeChecked()
|
||||
expect(currentInputs()).toEqual({ text_var: 'current-value', bool: true })
|
||||
expect(newInputs()).toEqual({ text_var: 'new-value', bool: true })
|
||||
})
|
||||
|
||||
it('replaces the select default with the chosen value', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup({
|
||||
inputsForms: [
|
||||
{
|
||||
variable: 'sel',
|
||||
type: InputVarType.select,
|
||||
label: 'Sel',
|
||||
options: ['A', 'B'],
|
||||
default: 'B',
|
||||
},
|
||||
],
|
||||
currentConversationInputs: {},
|
||||
})
|
||||
const select = screen.getByRole('combobox', { name: 'Sel' })
|
||||
expect(select).toHaveTextContent('B')
|
||||
await user.click(select)
|
||||
await user.click(screen.getByRole('option', { name: 'A' }))
|
||||
expect(select).toHaveTextContent('A')
|
||||
expect(currentInputs()).toEqual({ sel: 'A' })
|
||||
expect(newInputs()).toEqual({ text_var: 'new-value', sel: 'A' })
|
||||
})
|
||||
|
||||
it('preserves a saved selection after its option is removed', () => {
|
||||
setup({
|
||||
inputsForms: [{ variable: 'sel', type: InputVarType.select, label: 'Sel', options: ['A'] }],
|
||||
currentConversationInputs: { sel: 'existing' },
|
||||
})
|
||||
expect(screen.getByRole('combobox', { name: 'Sel' })).toHaveTextContent('existing')
|
||||
})
|
||||
|
||||
it('edits JSON through the real code-editor wrapper', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup({
|
||||
inputsForms: [
|
||||
{ variable: 'json', type: InputVarType.jsonObject, label: 'Json', json_schema: '{ "a": 1 }' },
|
||||
],
|
||||
currentConversationInputs: {},
|
||||
})
|
||||
expect(screen.getByText('{ "a": 1 }')).toBeVisible()
|
||||
// Monaco itself is an external editor boundary supplied by vitest.setup.ts.
|
||||
const editor = screen.getByTestId('monaco-editor')
|
||||
await user.click(editor)
|
||||
await user.paste('{"a":2}')
|
||||
expect(editor).toHaveValue('{"a":2}')
|
||||
expect(currentInputs()).toEqual({ json: '{"a":2}' })
|
||||
expect(newInputs()).toEqual({ text_var: 'new-value', json: '{"a":2}' })
|
||||
})
|
||||
|
||||
const fileForm = (type: typeof InputVarType.singleFile | typeof InputVarType.multiFiles) => ({
|
||||
variable: 'attachment',
|
||||
type,
|
||||
label: 'Attachment',
|
||||
max_length: 3,
|
||||
allowed_file_types: ['document'],
|
||||
allowed_file_extensions: [],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
})
|
||||
|
||||
it('shows an existing single-file value', () => {
|
||||
setup({
|
||||
inputsForms: [fileForm(InputVarType.singleFile)],
|
||||
currentConversationInputs: { attachment: uploadedFile },
|
||||
})
|
||||
expect(screen.getByText('report.pdf')).toBeVisible()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'common.fileUploader.uploadFromComputer' }),
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it.each([InputVarType.singleFile, InputVarType.multiFiles])(
|
||||
'uploads %s through the real file owner and stores its value shape',
|
||||
async (type) => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = setup({
|
||||
inputsForms: [fileForm(type)],
|
||||
currentConversationInputs: {},
|
||||
newConversationInputs: {},
|
||||
})
|
||||
expect(screen.queryByText('report.pdf')).not.toBeInTheDocument()
|
||||
// Native file inputs have no useful role/name in the current uploader.
|
||||
await user.upload(
|
||||
container.querySelector<HTMLInputElement>('input[type="file"]')!,
|
||||
new File(['report'], 'report.pdf', { type: 'application/pdf' }),
|
||||
)
|
||||
const file = expect.objectContaining({
|
||||
name: 'report.pdf',
|
||||
uploadedId: 'server-file-1',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
})
|
||||
const attachment = type === InputVarType.singleFile ? file : [file]
|
||||
await waitFor(() => expect(currentInputs()).toEqual({ attachment }))
|
||||
expect(newInputs()).toEqual({ attachment })
|
||||
expect(screen.getByText('report.pdf')).toBeVisible()
|
||||
},
|
||||
)
|
||||
+26
-14
@@ -1,13 +1,15 @@
|
||||
import type { ExternalKnowledgeApiListResponse } from '@dify/contracts/api/console/datasets/types.gen'
|
||||
import { act, screen } from '@testing-library/react'
|
||||
import { act, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { createExternalAPI } from '@/service/datasets'
|
||||
import { renderWithConfigurationModals as render } from '@/test/configuration-modals'
|
||||
import ExternalAPIPanel from '../index'
|
||||
|
||||
const { getApis, openModal } = vi.hoisted(() => ({ getApis: vi.fn(), openModal: vi.fn() }))
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({ setShowExternalKnowledgeAPIModal: openModal }),
|
||||
const { getApis } = vi.hoisted(() => ({ getApis: vi.fn() }))
|
||||
vi.mock('@/service/datasets', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/service/datasets')>()),
|
||||
createExternalAPI: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
@@ -40,7 +42,10 @@ const response: ExternalKnowledgeApiListResponse = {
|
||||
}
|
||||
|
||||
describe('ExternalAPIPanel', () => {
|
||||
beforeEach(() => getApis.mockResolvedValue(response))
|
||||
beforeEach(() => {
|
||||
getApis.mockResolvedValue(response)
|
||||
vi.mocked(createExternalAPI).mockResolvedValue(response.data[0]!)
|
||||
})
|
||||
|
||||
it('replaces loading with API cards and allows the panel to close', async () => {
|
||||
const user = userEvent.setup()
|
||||
@@ -78,14 +83,21 @@ describe('ExternalAPIPanel', () => {
|
||||
getApis.mockResolvedValueOnce({ ...response, data: [], total: 0 })
|
||||
render(<ExternalAPIPanel canManageExternalKnowledgeApi onClose={vi.fn()} />)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.createExternalAPI' }))
|
||||
expect(openModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: { name: '', settings: { endpoint: '', api_key: '' } },
|
||||
datasetBindings: [],
|
||||
isEditMode: false,
|
||||
}),
|
||||
)
|
||||
await act(async () => openModal.mock.calls[0]![0].onSaveCallback())
|
||||
const dialog = await screen.findByRole('dialog', { name: 'dataset.createExternalAPI' })
|
||||
expect(within(dialog).getByLabelText(/Name/)).toHaveValue('')
|
||||
expect(within(dialog).getByLabelText(/API Endpoint/)).toHaveValue('')
|
||||
expect(within(dialog).getByLabelText(/API Key/)).toHaveValue('')
|
||||
await user.type(within(dialog).getByLabelText(/Name/), 'Product knowledge')
|
||||
await user.type(within(dialog).getByLabelText(/API Endpoint/), 'https://knowledge.example.com')
|
||||
await user.type(within(dialog).getByLabelText(/API Key/), 'secret-key')
|
||||
await user.click(within(dialog).getByRole('button', { name: 'dataset.externalAPIForm.save' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(createExternalAPI).toHaveBeenCalledWith({
|
||||
body: {
|
||||
name: 'Product knowledge',
|
||||
settings: { endpoint: 'https://knowledge.example.com', api_key: 'secret-key' },
|
||||
},
|
||||
})
|
||||
expect(await screen.findByText('Product knowledge')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /common.operation.edit Product knowledge/ }),
|
||||
|
||||
+194
-307
@@ -1,337 +1,224 @@
|
||||
import type { ExternalKnowledgeApiResponse } from '@dify/contracts/api/console/datasets/types.gen'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { act, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { consoleQuery } from '@/service/console'
|
||||
// Import mocked services
|
||||
import {
|
||||
checkUsageExternalAPI,
|
||||
deleteExternalAPI,
|
||||
fetchExternalAPI,
|
||||
updateExternalAPI,
|
||||
} from '@/service/datasets'
|
||||
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { renderWithConfigurationModals } from '@/test/configuration-modals'
|
||||
import { createConsoleQueryClient } from '@/test/console/query-data'
|
||||
import { createDeferred } from '@/test/deferred'
|
||||
import ExternalKnowledgeAPICard from '../index'
|
||||
|
||||
// Mock API services
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
vi.mock('@/service/datasets', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/service/datasets')>()),
|
||||
fetchExternalAPI: vi.fn(),
|
||||
updateExternalAPI: vi.fn(),
|
||||
deleteExternalAPI: vi.fn(),
|
||||
checkUsageExternalAPI: vi.fn(),
|
||||
}))
|
||||
const { getApis } = vi.hoisted(() => ({ getApis: vi.fn() }))
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
const { withConsoleResponses } = await import('@/test/console/service-responses')
|
||||
return {
|
||||
...actual,
|
||||
consoleQuery: withConsoleResponses(actual.consoleQuery, {
|
||||
'datasets.externalKnowledgeApi.get': getApis,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock contexts
|
||||
const mockSetShowExternalKnowledgeAPIModal = vi.fn()
|
||||
const externalKnowledgeApiQueryKey = consoleQuery.datasets.externalKnowledgeApi.get.queryOptions({
|
||||
input: {},
|
||||
}).queryKey
|
||||
let queryClient: ReturnType<typeof createConsoleQueryClient>
|
||||
const render = (ui: React.ReactElement) => {
|
||||
queryClient = createConsoleQueryClient()
|
||||
queryClient.setQueryData(externalKnowledgeApiQueryKey, {
|
||||
data: [],
|
||||
has_more: false,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
})
|
||||
return renderWithConsoleQuery(ui, { queryClient })
|
||||
const api = {
|
||||
id: 'api-123',
|
||||
tenant_id: 'tenant-1',
|
||||
name: 'Test External API',
|
||||
description: 'Test API description',
|
||||
settings: { endpoint: 'https://api.example.com/knowledge', api_key: 'secret-key-123' },
|
||||
dataset_bindings: [],
|
||||
created_by: 'user-1',
|
||||
created_at: '2021-01-01T00:00:00Z',
|
||||
} satisfies ExternalKnowledgeApiResponse
|
||||
const listResponse = (data: ExternalKnowledgeApiResponse[]) => ({
|
||||
data,
|
||||
has_more: false,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: data.length,
|
||||
})
|
||||
const listOptions = consoleQuery.datasets.externalKnowledgeApi.get.queryOptions({ input: {} })
|
||||
function setup({ apis = [api], canManage = true } = {}) {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
queryClient.setQueryData(listOptions.queryKey, listResponse(apis))
|
||||
function ApiCards() {
|
||||
const { data } = useQuery(listOptions)
|
||||
return data?.data.map((item, index) => (
|
||||
<ExternalKnowledgeAPICard
|
||||
key={item.id}
|
||||
api={item}
|
||||
position={index + 1}
|
||||
canManageExternalKnowledgeApi={canManage}
|
||||
/>
|
||||
))
|
||||
}
|
||||
return renderWithConfigurationModals(<ApiCards />, { queryClient })
|
||||
}
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('ExternalKnowledgeAPICard', () => {
|
||||
const mockApi: ExternalKnowledgeApiResponse = {
|
||||
id: 'api-123',
|
||||
tenant_id: 'tenant-1',
|
||||
name: 'Test External API',
|
||||
description: 'Test API description',
|
||||
settings: {
|
||||
endpoint: 'https://api.example.com/knowledge',
|
||||
api_key: 'secret-key-123',
|
||||
},
|
||||
dataset_bindings: [],
|
||||
created_by: 'user-1',
|
||||
created_at: '2021-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
api: mockApi,
|
||||
canManageExternalKnowledgeApi: true,
|
||||
position: 1,
|
||||
}
|
||||
|
||||
const editButtonName =
|
||||
'common.operation.edit Test External API https://api.example.com/knowledge 1'
|
||||
const deleteButtonName =
|
||||
'common.operation.delete Test External API https://api.example.com/knowledge 1'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const edit = () =>
|
||||
screen.getByRole('button', {
|
||||
name: `common.operation.edit ${api.name} ${api.settings.endpoint} 1`,
|
||||
})
|
||||
const remove = () =>
|
||||
screen.getByRole('button', {
|
||||
name: `common.operation.delete ${api.name} ${api.settings.endpoint} 1`,
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('targets the correct API when cards have duplicate names and endpoints', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
|
||||
const duplicateApi = {
|
||||
...mockApi,
|
||||
id: 'api-456',
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchExternalAPI).mockReset().mockResolvedValue(api)
|
||||
vi.mocked(updateExternalAPI).mockReset().mockResolvedValue(api)
|
||||
vi.mocked(deleteExternalAPI).mockReset().mockResolvedValue({ result: 'success' })
|
||||
vi.mocked(checkUsageExternalAPI).mockReset().mockResolvedValue({ is_using: false, count: 0 })
|
||||
getApis.mockReset().mockResolvedValue(listResponse([api]))
|
||||
})
|
||||
|
||||
render(
|
||||
<>
|
||||
<ExternalKnowledgeAPICard {...defaultProps} />
|
||||
<ExternalKnowledgeAPICard
|
||||
api={duplicateApi}
|
||||
canManageExternalKnowledgeApi={true}
|
||||
position={2}
|
||||
/>
|
||||
</>,
|
||||
)
|
||||
it('targets the correct API when cards have duplicate names and endpoints', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup({ apis: [api, { ...api, id: 'api-456' }] })
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: `common.operation.delete ${api.name} ${api.settings.endpoint} 2`,
|
||||
}),
|
||||
)
|
||||
expect(checkUsageExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-456' })
|
||||
expect(await screen.findByRole('alertdialog')).toBeVisible()
|
||||
})
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'common.operation.delete Test External API https://api.example.com/knowledge 2',
|
||||
}),
|
||||
)
|
||||
expect(checkUsageExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-456' })
|
||||
expect(await screen.findByRole('alertdialog')).toBeInTheDocument()
|
||||
})
|
||||
it('hides management actions without permission', () => {
|
||||
setup({ canManage: false })
|
||||
expect(screen.getByText(api.name)).toBeVisible()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /common.operation.(edit|delete)/ }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide edit and delete buttons when external knowledge API management is unavailable', () => {
|
||||
render(<ExternalKnowledgeAPICard {...defaultProps} canManageExternalKnowledgeApi={false} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: editButtonName })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: deleteButtonName })).not.toBeInTheDocument()
|
||||
})
|
||||
it('loads API details and bindings into the real edit dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(fetchExternalAPI).mockResolvedValue({
|
||||
...api,
|
||||
dataset_bindings: [{ id: 'ds-1', name: 'Dataset 1' }],
|
||||
})
|
||||
setup()
|
||||
await user.click(edit())
|
||||
const dialog = await screen.findByRole('dialog', { name: 'dataset.editExternalAPIFormTitle' })
|
||||
expect(fetchExternalAPI).toHaveBeenCalledWith({ apiTemplateId: api.id })
|
||||
expect(within(dialog).getByLabelText(/Name/)).toHaveValue(api.name)
|
||||
expect(within(dialog).getByLabelText(/API Endpoint/)).toHaveValue(api.settings.endpoint)
|
||||
expect(within(dialog).getByLabelText(/API Key/)).toHaveValue(api.settings.api_key)
|
||||
expect(dialog).toHaveTextContent('1 dataset.editExternalAPIFormWarning.end')
|
||||
})
|
||||
|
||||
describe('User Interactions - Edit', () => {
|
||||
it('should fetch API details and open modal when edit button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockResponse: ExternalKnowledgeApiResponse = {
|
||||
id: 'api-123',
|
||||
tenant_id: 'tenant-1',
|
||||
name: 'Test External API',
|
||||
description: 'Test API description',
|
||||
settings: {
|
||||
endpoint: 'https://api.example.com/knowledge',
|
||||
api_key: 'secret-key-123',
|
||||
},
|
||||
dataset_bindings: [{ id: 'ds-1', name: 'Dataset 1' }],
|
||||
created_by: 'user-1',
|
||||
created_at: '2021-01-01T00:00:00Z',
|
||||
}
|
||||
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
|
||||
it('saves edits through the modal and refreshes the displayed card', async () => {
|
||||
const user = userEvent.setup()
|
||||
const updated = {
|
||||
...api,
|
||||
name: 'Updated External API',
|
||||
settings: { endpoint: 'https://updated.example.com/knowledge', api_key: 'updated-secret-key' },
|
||||
}
|
||||
getApis.mockResolvedValue(listResponse([updated]))
|
||||
setup()
|
||||
await user.click(edit())
|
||||
const dialog = await screen.findByRole('dialog', { name: 'dataset.editExternalAPIFormTitle' })
|
||||
for (const [label, value] of [
|
||||
[/Name/, updated.name],
|
||||
[/API Endpoint/, updated.settings.endpoint],
|
||||
[/API Key/, updated.settings.api_key],
|
||||
] as const) {
|
||||
await user.clear(within(dialog).getByLabelText(label))
|
||||
await user.type(within(dialog).getByLabelText(label), value)
|
||||
}
|
||||
await user.click(within(dialog).getByRole('button', { name: 'dataset.externalAPIForm.save' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(updateExternalAPI).toHaveBeenCalledWith({
|
||||
apiTemplateId: api.id,
|
||||
body: { name: updated.name, settings: updated.settings },
|
||||
})
|
||||
expect(await screen.findByText(updated.name)).toBeVisible()
|
||||
expect(screen.getByText(updated.settings.endpoint)).toBeVisible()
|
||||
expect(screen.queryByText(api.name)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const editButton = screen.getByRole('button', { name: editButtonName })
|
||||
it('shows the usage count before deleting a connected API', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: true, count: 3 })
|
||||
setup()
|
||||
await user.click(remove())
|
||||
expect(await screen.findByRole('alertdialog')).toHaveTextContent('3')
|
||||
expect(deleteExternalAPI).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await user.click(editButton!)
|
||||
it('deletes a confirmed API and removes its card after refreshing the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
getApis.mockResolvedValue(listResponse([]))
|
||||
setup()
|
||||
await user.click(remove())
|
||||
await user.click(
|
||||
within(await screen.findByRole('alertdialog')).getByRole('button', {
|
||||
name: 'common.operation.confirm',
|
||||
}),
|
||||
)
|
||||
await waitFor(() => expect(screen.queryByText(api.name)).not.toBeInTheDocument())
|
||||
expect(deleteExternalAPI).toHaveBeenCalledWith({ apiTemplateId: api.id })
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
|
||||
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: {
|
||||
name: 'Test External API',
|
||||
settings: {
|
||||
endpoint: 'https://api.example.com/knowledge',
|
||||
api_key: 'secret-key-123',
|
||||
},
|
||||
},
|
||||
isEditMode: true,
|
||||
datasetBindings: [{ id: 'ds-1', name: 'Dataset 1' }],
|
||||
it('cancels deletion without changing the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup()
|
||||
await user.click(remove())
|
||||
await user.click(
|
||||
within(await screen.findByRole('alertdialog')).getByRole('button', {
|
||||
name: 'common.operation.cancel',
|
||||
}),
|
||||
)
|
||||
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
|
||||
expect(screen.getByText(api.name)).toBeVisible()
|
||||
expect(deleteExternalAPI).not.toHaveBeenCalled()
|
||||
expect(getApis).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['fetch', 'usage', 'delete'] as const)(
|
||||
'preserves the card when the %s request fails',
|
||||
async (operation) => {
|
||||
const user = userEvent.setup()
|
||||
const failure = new Error('Request failed')
|
||||
const service =
|
||||
operation === 'fetch'
|
||||
? fetchExternalAPI
|
||||
: operation === 'usage'
|
||||
? checkUsageExternalAPI
|
||||
: deleteExternalAPI
|
||||
const request = createDeferred<never>()
|
||||
vi.mocked(service).mockReturnValueOnce(request.promise)
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
setup()
|
||||
await user.click(operation === 'fetch' ? edit() : remove())
|
||||
if (operation === 'delete')
|
||||
await user.click(
|
||||
within(await screen.findByRole('alertdialog')).getByRole('button', {
|
||||
name: 'common.operation.confirm',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle fetch error gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(fetchExternalAPI).mockRejectedValue(new Error('Fetch failed'))
|
||||
|
||||
render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const editButton = screen.getByRole('button', { name: editButtonName })
|
||||
|
||||
await user.click(editButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Error fetching external knowledge API data:',
|
||||
expect.any(Error),
|
||||
)
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryState(externalKnowledgeApiQueryKey)?.isInvalidated).toBe(false)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should invalidate the generated list query after editing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockResponse: ExternalKnowledgeApiResponse = {
|
||||
id: 'api-123',
|
||||
tenant_id: 'tenant-1',
|
||||
name: 'Test External API',
|
||||
description: 'Test API description',
|
||||
settings: {
|
||||
endpoint: 'https://api.example.com/knowledge',
|
||||
api_key: 'secret-key-123',
|
||||
},
|
||||
dataset_bindings: [],
|
||||
created_by: 'user-1',
|
||||
created_at: '2021-01-01T00:00:00Z',
|
||||
}
|
||||
vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse)
|
||||
|
||||
render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const editButton = screen.getByRole('button', { name: editButtonName })
|
||||
|
||||
await user.click(editButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0]![0]
|
||||
await modalCall.onEditCallback({
|
||||
name: 'Updated External API',
|
||||
settings: {
|
||||
endpoint: 'https://updated.example.com/knowledge',
|
||||
api_key: 'updated-secret-key',
|
||||
},
|
||||
})
|
||||
|
||||
expect(updateExternalAPI).toHaveBeenCalledWith({
|
||||
apiTemplateId: 'api-123',
|
||||
body: expect.objectContaining({
|
||||
name: 'Updated External API',
|
||||
settings: {
|
||||
endpoint: 'https://updated.example.com/knowledge',
|
||||
api_key: 'updated-secret-key',
|
||||
},
|
||||
}),
|
||||
})
|
||||
expect(queryClient.getQueryState(externalKnowledgeApiQueryKey)?.isInvalidated).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions - Delete', () => {
|
||||
it('should show usage count in confirm dialog when API is in use', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: true, count: 3 })
|
||||
|
||||
render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const deleteButton = screen.getByRole('button', { name: deleteButtonName })
|
||||
|
||||
await user.click(deleteButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/3/))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should delete API and refresh list when confirmed', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
|
||||
vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'success' })
|
||||
|
||||
render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const deleteButton = screen.getByRole('button', { name: deleteButtonName })
|
||||
|
||||
await user.click(deleteButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /confirm/i }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm/i })
|
||||
await user.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' })
|
||||
expect(queryClient.getQueryState(externalKnowledgeApiQueryKey)?.isInvalidated).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should close confirm dialog when cancel is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
|
||||
|
||||
render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const deleteButton = screen.getByRole('button', { name: deleteButtonName })
|
||||
|
||||
await user.click(deleteButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /cancel/i }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i })
|
||||
await user.click(cancelButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /confirm/i })).not.toBeInTheDocument()
|
||||
expect(deleteExternalAPI).not.toHaveBeenCalled()
|
||||
expect(queryClient.getQueryState(externalKnowledgeApiQueryKey)?.isInvalidated).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle delete error gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 })
|
||||
vi.mocked(deleteExternalAPI).mockRejectedValue(new Error('Delete failed'))
|
||||
|
||||
render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const deleteButton = screen.getByRole('button', { name: deleteButtonName })
|
||||
|
||||
await user.click(deleteButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /confirm/i }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm/i })
|
||||
await user.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Error deleting external knowledge API:',
|
||||
expect.any(Error),
|
||||
)
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryState(externalKnowledgeApiQueryKey)?.isInvalidated).toBe(false)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle check usage error gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(checkUsageExternalAPI).mockRejectedValue(new Error('Check failed'))
|
||||
|
||||
render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const deleteButton = screen.getByRole('button', { name: deleteButtonName })
|
||||
|
||||
await user.click(deleteButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Error checking external API usage:',
|
||||
expect.any(Error),
|
||||
)
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryState(externalKnowledgeApiQueryKey)?.isInvalidated).toBe(false)
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
await act(async () => request.reject(failure))
|
||||
expect(screen.getByText(api.name)).toBeVisible()
|
||||
expect(getApis).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
if (operation !== 'delete') expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
},
|
||||
)
|
||||
+43
-26
@@ -1,14 +1,16 @@
|
||||
import type { ExternalKnowledgeApiListResponse } from '@dify/contracts/api/console/datasets/types.gen'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
|
||||
import { createExternalAPI } from '@/service/datasets'
|
||||
import { renderWithConfigurationModals as render } from '@/test/configuration-modals'
|
||||
import ExternalApiSelection from '../ExternalApiSelection'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ getApis: vi.fn(), refresh: vi.fn(), openModal: vi.fn() }))
|
||||
const mocks = vi.hoisted(() => ({ getApis: vi.fn(), refresh: vi.fn() }))
|
||||
vi.mock('@/next/navigation', () => ({ useRouter: () => ({ refresh: mocks.refresh }) }))
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({ setShowExternalKnowledgeAPIModal: mocks.openModal }),
|
||||
vi.mock('@/service/datasets', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/service/datasets')>()),
|
||||
createExternalAPI: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
@@ -38,25 +40,29 @@ const response: ExternalKnowledgeApiListResponse = {
|
||||
total: 2,
|
||||
}
|
||||
|
||||
function Harness({ onChange = vi.fn() }: { onChange?: (value: object) => void }) {
|
||||
function Harness() {
|
||||
const [value, setValue] = useState({ external_knowledge_api_id: '', external_knowledge_id: '' })
|
||||
return (
|
||||
<ExternalApiSelection
|
||||
{...value}
|
||||
onChange={(next) => {
|
||||
setValue((current) => ({ ...current, ...next }))
|
||||
onChange(next)
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<ExternalApiSelection
|
||||
{...value}
|
||||
onChange={(next) => {
|
||||
setValue((current) => ({ ...current, ...next }))
|
||||
}}
|
||||
/>
|
||||
<output aria-label="Knowledge connection">{JSON.stringify(value)}</output>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => mocks.getApis.mockResolvedValue(response))
|
||||
beforeEach(() => {
|
||||
mocks.getApis.mockResolvedValue(response)
|
||||
vi.mocked(createExternalAPI).mockResolvedValue(response.data[0]!)
|
||||
})
|
||||
|
||||
it('defaults to the first API and keeps the chosen API while entering the knowledge ID', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<Harness onChange={onChange} />)
|
||||
render(<Harness />)
|
||||
const selector = await screen.findByRole('button', { name: 'dataset.externalAPIPanelTitle' })
|
||||
await waitFor(() => expect(selector).toHaveTextContent('API One'))
|
||||
await user.click(selector)
|
||||
@@ -64,7 +70,7 @@ it('defaults to the first API and keeps the chosen API while entering the knowle
|
||||
expect(selector).toHaveTextContent('API Two')
|
||||
expect(screen.queryByText('https://api2.com')).not.toBeInTheDocument()
|
||||
await user.type(screen.getByRole('textbox', { name: 'dataset.externalKnowledgeId' }), 'kb-123')
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
expect(JSON.parse(screen.getByLabelText('Knowledge connection').textContent!)).toEqual({
|
||||
external_knowledge_api_id: 'api-2',
|
||||
external_knowledge_id: 'kb-123',
|
||||
})
|
||||
@@ -74,8 +80,11 @@ it.each(['empty list', 'selector'] as const)(
|
||||
'refreshes APIs after creation from the %s',
|
||||
async (entry) => {
|
||||
const user = userEvent.setup()
|
||||
if (entry === 'empty list')
|
||||
mocks.getApis.mockResolvedValueOnce({ ...response, data: [], total: 0 })
|
||||
mocks.getApis.mockResolvedValueOnce({
|
||||
...response,
|
||||
data: entry === 'empty list' ? [] : [response.data[1]!],
|
||||
total: entry === 'empty list' ? 0 : 1,
|
||||
})
|
||||
render(<Harness />)
|
||||
if (entry === 'selector') {
|
||||
await user.click(await screen.findByRole('button', { name: 'dataset.externalAPIPanelTitle' }))
|
||||
@@ -83,14 +92,22 @@ it.each(['empty list', 'selector'] as const)(
|
||||
} else {
|
||||
await user.click(await screen.findByRole('button', { name: 'dataset.noExternalKnowledge' }))
|
||||
}
|
||||
expect(mocks.openModal).toHaveBeenCalledWith(expect.objectContaining({ isEditMode: false }))
|
||||
await act(async () => mocks.openModal.mock.calls[0]![0].onSaveCallback())
|
||||
const dialog = await screen.findByRole('dialog', { name: 'dataset.createExternalAPI' })
|
||||
await user.type(within(dialog).getByLabelText(/Name/), 'API One')
|
||||
await user.type(within(dialog).getByLabelText(/API Endpoint/), 'https://api1.com')
|
||||
await user.type(within(dialog).getByLabelText(/API Key/), 'secret-key')
|
||||
await user.click(within(dialog).getByRole('button', { name: 'dataset.externalAPIForm.save' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(createExternalAPI).toHaveBeenCalledWith({
|
||||
body: { name: 'API One', settings: { endpoint: 'https://api1.com', api_key: 'secret-key' } },
|
||||
})
|
||||
await waitFor(() => expect(mocks.getApis).toHaveBeenCalledTimes(2))
|
||||
expect(mocks.refresh).toHaveBeenCalledOnce()
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'dataset.externalAPIPanelTitle' }),
|
||||
).toHaveTextContent('API One'),
|
||||
)
|
||||
const selector = screen.getByRole('button', { name: 'dataset.externalAPIPanelTitle' })
|
||||
if (entry === 'empty list') await waitFor(() => expect(selector).toHaveTextContent('API One'))
|
||||
else {
|
||||
expect(selector).toHaveTextContent('API Two')
|
||||
expect(await screen.findByRole('button', { name: 'API One https://api1.com' })).toBeVisible()
|
||||
}
|
||||
},
|
||||
)
|
||||
Reference in new issue
Block a user