mirror of
https://github.com/langgenius/dify.git
synced 2026-05-02 16:38:04 +08:00
feat(tests): add integration tests for API key management and develop page flow
This commit is contained in:
192
web/__tests__/develop/api-key-management-flow.test.tsx
Normal file
192
web/__tests__/develop/api-key-management-flow.test.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Integration test: API Key management flow
|
||||
*
|
||||
* Tests the cross-component interaction:
|
||||
* ApiServer → SecretKeyButton → SecretKeyModal
|
||||
*
|
||||
* Renders real ApiServer, SecretKeyButton, and SecretKeyModal together
|
||||
* with only service-layer mocks. Deep modal interactions (create/delete)
|
||||
* are covered by unit tests in secret-key-modal.spec.tsx.
|
||||
*/
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ApiServer from '@/app/components/develop/ApiServer'
|
||||
|
||||
// ---------- fake timers (HeadlessUI Dialog transitions) ----------
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function flushUI() {
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- mocks ----------
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { id: 'ws-1', name: 'Workspace' },
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: vi.fn((val: number) => `Time:${val}`),
|
||||
formatDate: vi.fn((val: string) => `Date:${val}`),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-token-1234567890abcdef' }),
|
||||
delApikey: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }),
|
||||
delApikey: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
const mockApiKeys = vi.fn().mockReturnValue({ data: [] })
|
||||
const mockIsLoading = vi.fn().mockReturnValue(false)
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useAppApiKeys: () => ({
|
||||
data: mockApiKeys(),
|
||||
isLoading: mockIsLoading(),
|
||||
}),
|
||||
useInvalidateAppApiKeys: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetApiKeys: () => ({ data: null, isLoading: false }),
|
||||
useInvalidateDatasetApiKeys: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------- tests ----------
|
||||
|
||||
describe('API Key management flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockApiKeys.mockReturnValue({ data: [] })
|
||||
mockIsLoading.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('ApiServer renders URL, status badge, and API Key button', () => {
|
||||
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
|
||||
|
||||
expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.ok')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clicking API Key button opens SecretKeyModal with real modal content', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
|
||||
|
||||
// Click API Key button (rendered by SecretKeyButton)
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
// SecretKeyModal should render with real HeadlessUI Dialog
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKeyTips')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKeyModal.createNewSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('modal shows loading state when API keys are being fetched', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
mockIsLoading.mockReturnValue(true)
|
||||
|
||||
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
|
||||
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Loading indicator should be present
|
||||
expect(document.body.querySelector('[role="status"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('modal can be closed by clicking X icon', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
render(<ApiServer apiBaseUrl="https://api.dify.ai/v1" appId="app-1" />)
|
||||
|
||||
// Open modal
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click X icon to close
|
||||
const xIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
expect(xIcon).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
await user.click(xIcon!)
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
// Modal should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('appApi.apiKeyModal.apiSecretKeyTips')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders correctly with different API URLs', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
const { rerender } = render(
|
||||
<ApiServer apiBaseUrl="http://localhost:5001/v1" appId="app-dev" />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('http://localhost:5001/v1')).toBeInTheDocument()
|
||||
|
||||
// Open modal and verify it works with the same appId
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Close modal, update URL and re-verify
|
||||
const xIcon = document.body.querySelector('svg.cursor-pointer')
|
||||
await act(async () => {
|
||||
await user.click(xIcon!)
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
rerender(
|
||||
<ApiServer apiBaseUrl="https://api.production.com/v1" appId="app-prod" />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('https://api.production.com/v1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
241
web/__tests__/develop/develop-page-flow.test.tsx
Normal file
241
web/__tests__/develop/develop-page-flow.test.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Integration test: DevelopMain page flow
|
||||
*
|
||||
* Tests the full page lifecycle:
|
||||
* Loading state → App loaded → Header (ApiServer) + Content (Doc) rendered
|
||||
*
|
||||
* Uses real DevelopMain, ApiServer, and Doc components with minimal mocks.
|
||||
*/
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DevelopMain from '@/app/components/develop'
|
||||
import { AppModeEnum, Theme } from '@/types/app'
|
||||
|
||||
// ---------- fake timers ----------
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function flushUI() {
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- store mock ----------
|
||||
|
||||
let storeAppDetail: unknown
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
return selector({ appDetail: storeAppDetail })
|
||||
},
|
||||
}))
|
||||
|
||||
// ---------- Doc dependencies ----------
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: Theme.light }),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
LanguagesSupported: ['en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP'],
|
||||
}))
|
||||
|
||||
// ---------- SecretKeyModal dependencies ----------
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { id: 'ws-1', name: 'Workspace' },
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: vi.fn((val: number) => `Time:${val}`),
|
||||
formatDate: vi.fn((val: string) => `Date:${val}`),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
createApikey: vi.fn().mockResolvedValue({ token: 'sk-new-1234567890' }),
|
||||
delApikey: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
createApikey: vi.fn().mockResolvedValue({ token: 'dk-new' }),
|
||||
delApikey: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useAppApiKeys: () => ({ data: { data: [] }, isLoading: false }),
|
||||
useInvalidateAppApiKeys: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetApiKeys: () => ({ data: null, isLoading: false }),
|
||||
useInvalidateDatasetApiKeys: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------- tests ----------
|
||||
|
||||
describe('DevelopMain page flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
storeAppDetail = undefined
|
||||
})
|
||||
|
||||
it('should show loading indicator when appDetail is not available', () => {
|
||||
storeAppDetail = undefined
|
||||
render(<DevelopMain appId="app-1" />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
// No content should be visible
|
||||
expect(screen.queryByText('appApi.apiServer')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render full page when appDetail is loaded', () => {
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
}
|
||||
|
||||
render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// ApiServer section should be visible
|
||||
expect(screen.getByText('appApi.apiServer')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://api.test.com/v1')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.ok')).toBeInTheDocument()
|
||||
expect(screen.getByText('appApi.apiKey')).toBeInTheDocument()
|
||||
|
||||
// Loading should NOT be visible
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Doc component with correct app mode template', () => {
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Chat App',
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
}
|
||||
|
||||
const { container } = render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// Doc renders an article element with prose classes
|
||||
const article = container.querySelector('article')
|
||||
expect(article).toBeInTheDocument()
|
||||
expect(article?.className).toContain('prose')
|
||||
})
|
||||
|
||||
it('should transition from loading to content when appDetail becomes available', () => {
|
||||
// Start with no data
|
||||
storeAppDetail = undefined
|
||||
const { rerender } = render(<DevelopMain appId="app-1" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
// Simulate store update
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'My App',
|
||||
api_base_url: 'https://api.example.com/v1',
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
}
|
||||
rerender(<DevelopMain appId="app-1" />)
|
||||
|
||||
// Content should now be visible
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('https://api.example.com/v1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open API key modal from the page', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
}
|
||||
|
||||
render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// Click API Key button in the header
|
||||
await act(async () => {
|
||||
await user.click(screen.getByText('appApi.apiKey'))
|
||||
})
|
||||
await flushUI()
|
||||
|
||||
// SecretKeyModal should open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('appApi.apiKeyModal.apiSecretKey')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render correctly for different app modes', () => {
|
||||
const modes = [
|
||||
AppModeEnum.CHAT,
|
||||
AppModeEnum.COMPLETION,
|
||||
AppModeEnum.ADVANCED_CHAT,
|
||||
AppModeEnum.WORKFLOW,
|
||||
]
|
||||
|
||||
for (const mode of modes) {
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: `${mode} App`,
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode,
|
||||
}
|
||||
|
||||
const { container, unmount } = render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// ApiServer should always be present
|
||||
expect(screen.getByText('appApi.apiServer')).toBeInTheDocument()
|
||||
|
||||
// Doc should render an article
|
||||
expect(container.querySelector('article')).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('should have correct page layout structure', () => {
|
||||
storeAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Test App',
|
||||
api_base_url: 'https://api.test.com/v1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
}
|
||||
|
||||
const { container } = render(<DevelopMain appId="app-1" />)
|
||||
|
||||
// Main container: flex column with full height
|
||||
const mainDiv = container.firstChild as HTMLElement
|
||||
expect(mainDiv.className).toContain('flex')
|
||||
expect(mainDiv.className).toContain('flex-col')
|
||||
expect(mainDiv.className).toContain('h-full')
|
||||
|
||||
// Header section with border
|
||||
const header = container.querySelector('.border-b')
|
||||
expect(header).toBeInTheDocument()
|
||||
|
||||
// Content section with overflow scroll
|
||||
const content = container.querySelector('.overflow-auto')
|
||||
expect(content).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user