mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 17:08:03 +08:00
test(web): add and enhance frontend automated tests across multiple modules (#32268)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
@ -1,140 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Credential, PluginPayload } from '../types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AuthCategory, CredentialTypeEnum } from '../types'
|
||||
|
||||
const mockGetPluginCredentialInfo = vi.fn()
|
||||
const mockDeletePluginCredential = vi.fn()
|
||||
const mockSetPluginDefaultCredential = vi.fn()
|
||||
const mockUpdatePluginCredential = vi.fn()
|
||||
const mockInvalidPluginCredentialInfo = vi.fn()
|
||||
const mockGetPluginOAuthUrl = vi.fn()
|
||||
const mockGetPluginOAuthClientSchema = vi.fn()
|
||||
const mockSetPluginOAuthCustomClient = vi.fn()
|
||||
const mockDeletePluginOAuthCustomClient = vi.fn()
|
||||
const mockInvalidPluginOAuthClientSchema = vi.fn()
|
||||
const mockAddPluginCredential = vi.fn()
|
||||
const mockGetPluginCredentialSchema = vi.fn()
|
||||
const mockInvalidToolsByType = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-plugins-auth', () => ({
|
||||
useGetPluginCredentialInfo: (url: string) => ({
|
||||
data: url ? mockGetPluginCredentialInfo() : undefined,
|
||||
isLoading: false,
|
||||
}),
|
||||
useDeletePluginCredential: () => ({
|
||||
mutateAsync: mockDeletePluginCredential,
|
||||
}),
|
||||
useSetPluginDefaultCredential: () => ({
|
||||
mutateAsync: mockSetPluginDefaultCredential,
|
||||
}),
|
||||
useUpdatePluginCredential: () => ({
|
||||
mutateAsync: mockUpdatePluginCredential,
|
||||
}),
|
||||
useInvalidPluginCredentialInfo: () => mockInvalidPluginCredentialInfo,
|
||||
useGetPluginOAuthUrl: () => ({
|
||||
mutateAsync: mockGetPluginOAuthUrl,
|
||||
}),
|
||||
useGetPluginOAuthClientSchema: () => ({
|
||||
data: mockGetPluginOAuthClientSchema(),
|
||||
isLoading: false,
|
||||
}),
|
||||
useSetPluginOAuthCustomClient: () => ({
|
||||
mutateAsync: mockSetPluginOAuthCustomClient,
|
||||
}),
|
||||
useDeletePluginOAuthCustomClient: () => ({
|
||||
mutateAsync: mockDeletePluginOAuthCustomClient,
|
||||
}),
|
||||
useInvalidPluginOAuthClientSchema: () => mockInvalidPluginOAuthClientSchema,
|
||||
useAddPluginCredential: () => ({
|
||||
mutateAsync: mockAddPluginCredential,
|
||||
}),
|
||||
useGetPluginCredentialSchema: () => ({
|
||||
data: mockGetPluginCredentialSchema(),
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useInvalidToolsByType: () => mockInvalidToolsByType,
|
||||
}))
|
||||
|
||||
const mockIsCurrentWorkspaceManager = vi.fn()
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-oauth', () => ({
|
||||
openOAuthPopup: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-triggers', () => ({
|
||||
useTriggerPluginDynamicOptions: () => ({
|
||||
data: { options: [] },
|
||||
isLoading: false,
|
||||
}),
|
||||
useTriggerPluginDynamicOptionsInfo: () => ({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
useInvalidTriggerDynamicOptions: () => vi.fn(),
|
||||
}))
|
||||
|
||||
const createTestQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const _createWrapper = () => {
|
||||
const testQueryClient = createTestQueryClient()
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const _createPluginPayload = (overrides: Partial<PluginPayload> = {}): PluginPayload => ({
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createCredential = (overrides: Partial<Credential> = {}): Credential => ({
|
||||
id: 'test-credential-id',
|
||||
name: 'Test Credential',
|
||||
provider: 'test-provider',
|
||||
credential_type: CredentialTypeEnum.API_KEY,
|
||||
is_default: false,
|
||||
credentials: { api_key: 'test-key' },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const _createCredentialList = (count: number, overrides: Partial<Credential>[] = []): Credential[] => {
|
||||
return Array.from({ length: count }, (_, i) => createCredential({
|
||||
id: `credential-${i}`,
|
||||
name: `Credential ${i}`,
|
||||
is_default: i === 0,
|
||||
...overrides[i],
|
||||
}))
|
||||
}
|
||||
|
||||
describe('Index Exports', () => {
|
||||
describe('plugin-auth index exports', () => {
|
||||
it('should export all required components and hooks', async () => {
|
||||
const exports = await import('../index')
|
||||
|
||||
@ -144,104 +11,23 @@ describe('Index Exports', () => {
|
||||
expect(exports.Authorized).toBeDefined()
|
||||
expect(exports.AuthorizedInDataSourceNode).toBeDefined()
|
||||
expect(exports.AuthorizedInNode).toBeDefined()
|
||||
expect(exports.usePluginAuth).toBeDefined()
|
||||
expect(exports.PluginAuth).toBeDefined()
|
||||
expect(exports.PluginAuthInAgent).toBeDefined()
|
||||
expect(exports.PluginAuthInDataSourceNode).toBeDefined()
|
||||
}, 15000)
|
||||
|
||||
it('should export AuthCategory enum', async () => {
|
||||
const exports = await import('../index')
|
||||
|
||||
expect(exports.AuthCategory).toBeDefined()
|
||||
expect(exports.AuthCategory.tool).toBe('tool')
|
||||
expect(exports.AuthCategory.datasource).toBe('datasource')
|
||||
expect(exports.AuthCategory.model).toBe('model')
|
||||
expect(exports.AuthCategory.trigger).toBe('trigger')
|
||||
}, 15000)
|
||||
|
||||
it('should export CredentialTypeEnum', async () => {
|
||||
const exports = await import('../index')
|
||||
|
||||
expect(exports.CredentialTypeEnum).toBeDefined()
|
||||
expect(exports.CredentialTypeEnum.OAUTH2).toBe('oauth2')
|
||||
expect(exports.CredentialTypeEnum.API_KEY).toBe('api-key')
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
describe('Types', () => {
|
||||
describe('AuthCategory enum', () => {
|
||||
it('should have correct values', () => {
|
||||
expect(AuthCategory.tool).toBe('tool')
|
||||
expect(AuthCategory.datasource).toBe('datasource')
|
||||
expect(AuthCategory.model).toBe('model')
|
||||
expect(AuthCategory.trigger).toBe('trigger')
|
||||
})
|
||||
|
||||
it('should have exactly 4 categories', () => {
|
||||
const values = Object.values(AuthCategory)
|
||||
expect(values).toHaveLength(4)
|
||||
})
|
||||
expect(exports.usePluginAuth).toBeDefined()
|
||||
})
|
||||
|
||||
describe('CredentialTypeEnum', () => {
|
||||
it('should have correct values', () => {
|
||||
expect(CredentialTypeEnum.OAUTH2).toBe('oauth2')
|
||||
expect(CredentialTypeEnum.API_KEY).toBe('api-key')
|
||||
})
|
||||
|
||||
it('should have exactly 2 types', () => {
|
||||
const values = Object.values(CredentialTypeEnum)
|
||||
expect(values).toHaveLength(2)
|
||||
})
|
||||
it('should re-export AuthCategory enum with correct values', () => {
|
||||
expect(Object.values(AuthCategory)).toHaveLength(4)
|
||||
expect(AuthCategory.tool).toBe('tool')
|
||||
expect(AuthCategory.datasource).toBe('datasource')
|
||||
expect(AuthCategory.model).toBe('model')
|
||||
expect(AuthCategory.trigger).toBe('trigger')
|
||||
})
|
||||
|
||||
describe('Credential type', () => {
|
||||
it('should allow creating valid credentials', () => {
|
||||
const credential: Credential = {
|
||||
id: 'test-id',
|
||||
name: 'Test',
|
||||
provider: 'test-provider',
|
||||
is_default: true,
|
||||
}
|
||||
expect(credential.id).toBe('test-id')
|
||||
expect(credential.is_default).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow optional fields', () => {
|
||||
const credential: Credential = {
|
||||
id: 'test-id',
|
||||
name: 'Test',
|
||||
provider: 'test-provider',
|
||||
is_default: false,
|
||||
credential_type: CredentialTypeEnum.API_KEY,
|
||||
credentials: { key: 'value' },
|
||||
isWorkspaceDefault: true,
|
||||
from_enterprise: false,
|
||||
not_allowed_to_use: false,
|
||||
}
|
||||
expect(credential.credential_type).toBe(CredentialTypeEnum.API_KEY)
|
||||
expect(credential.isWorkspaceDefault).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PluginPayload type', () => {
|
||||
it('should allow creating valid plugin payload', () => {
|
||||
const payload: PluginPayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
}
|
||||
expect(payload.category).toBe(AuthCategory.tool)
|
||||
})
|
||||
|
||||
it('should allow optional fields', () => {
|
||||
const payload: PluginPayload = {
|
||||
category: AuthCategory.datasource,
|
||||
provider: 'test-provider',
|
||||
providerType: 'builtin',
|
||||
detail: undefined,
|
||||
}
|
||||
expect(payload.providerType).toBe('builtin')
|
||||
})
|
||||
it('should re-export CredentialTypeEnum with correct values', () => {
|
||||
expect(Object.values(CredentialTypeEnum)).toHaveLength(2)
|
||||
expect(CredentialTypeEnum.OAUTH2).toBe('oauth2')
|
||||
expect(CredentialTypeEnum.API_KEY).toBe('api-key')
|
||||
})
|
||||
})
|
||||
|
||||
@ -92,7 +92,7 @@ describe('PluginAuth', () => {
|
||||
expect(screen.queryByTestId('authorized')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies className when not authorized', () => {
|
||||
it('renders with className wrapper when not authorized', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: false,
|
||||
canOAuth: false,
|
||||
@ -104,10 +104,10 @@ describe('PluginAuth', () => {
|
||||
})
|
||||
|
||||
const { container } = render(<PluginAuth pluginPayload={defaultPayload} className="custom-class" />)
|
||||
expect((container.firstChild as HTMLElement).className).toContain('custom-class')
|
||||
expect(container.innerHTML).toContain('custom-class')
|
||||
})
|
||||
|
||||
it('does not apply className when authorized', () => {
|
||||
it('does not render className wrapper when authorized', () => {
|
||||
mockUsePluginAuth.mockReturnValue({
|
||||
isAuthorized: true,
|
||||
canOAuth: false,
|
||||
@ -119,7 +119,7 @@ describe('PluginAuth', () => {
|
||||
})
|
||||
|
||||
const { container } = render(<PluginAuth pluginPayload={defaultPayload} className="custom-class" />)
|
||||
expect((container.firstChild as HTMLElement).className).not.toContain('custom-class')
|
||||
expect(container.innerHTML).not.toContain('custom-class')
|
||||
})
|
||||
|
||||
it('passes pluginPayload.provider to usePluginAuth', () => {
|
||||
|
||||
@ -96,7 +96,7 @@ describe('Authorize', () => {
|
||||
it('should render nothing when canOAuth and canApiKey are both false/undefined', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { container } = render(
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={false}
|
||||
@ -105,10 +105,7 @@ describe('Authorize', () => {
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// No buttons should be rendered
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
// Container should only have wrapper element
|
||||
expect(container.querySelector('.flex')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render only OAuth button when canOAuth is true and canApiKey is false', () => {
|
||||
@ -225,7 +222,7 @@ describe('Authorize', () => {
|
||||
// ==================== Props Testing ====================
|
||||
describe('Props Testing', () => {
|
||||
describe('theme prop', () => {
|
||||
it('should render buttons with secondary theme variant when theme is secondary', () => {
|
||||
it('should render buttons when theme is secondary', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
@ -239,9 +236,7 @@ describe('Authorize', () => {
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button.className).toContain('btn-secondary')
|
||||
})
|
||||
expect(buttons).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@ -327,10 +322,10 @@ describe('Authorize', () => {
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should add opacity class when notAllowCustomCredential is true', () => {
|
||||
it('should disable all buttons when notAllowCustomCredential is true', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { container } = render(
|
||||
render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
@ -340,8 +335,8 @@ describe('Authorize', () => {
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const wrappers = container.querySelectorAll('.opacity-50')
|
||||
expect(wrappers.length).toBe(2) // Both OAuth and API Key wrappers
|
||||
const buttons = screen.getAllByRole('button')
|
||||
buttons.forEach(button => expect(button).toBeDisabled())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -459,7 +454,7 @@ describe('Authorize', () => {
|
||||
expect(screen.getAllByRole('button').length).toBe(2)
|
||||
})
|
||||
|
||||
it('should update button variant when theme changes', () => {
|
||||
it('should change button styling when theme changes', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender } = render(
|
||||
@ -471,9 +466,7 @@ describe('Authorize', () => {
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const buttonPrimary = screen.getByRole('button')
|
||||
// Primary theme with canOAuth=false should have primary variant
|
||||
expect(buttonPrimary.className).toContain('btn-primary')
|
||||
const primaryClassName = screen.getByRole('button').className
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
@ -483,7 +476,8 @@ describe('Authorize', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button').className).toContain('btn-secondary')
|
||||
const secondaryClassName = screen.getByRole('button').className
|
||||
expect(primaryClassName).not.toBe(secondaryClassName)
|
||||
})
|
||||
})
|
||||
|
||||
@ -574,38 +568,10 @@ describe('Authorize', () => {
|
||||
expect(typeof AuthorizeDefault).toBe('object')
|
||||
})
|
||||
|
||||
it('should not re-render wrapper when notAllowCustomCredential stays the same', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
const { rerender, container } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
notAllowCustomCredential={false}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
const initialOpacityElements = container.querySelectorAll('.opacity-50').length
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
notAllowCustomCredential={false}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.opacity-50').length).toBe(initialOpacityElements)
|
||||
})
|
||||
|
||||
it('should update wrapper when notAllowCustomCredential changes', () => {
|
||||
it('should reflect notAllowCustomCredential change via button disabled state', () => {
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
const { rerender, container } = render(
|
||||
const { rerender } = render(
|
||||
<Authorize
|
||||
pluginPayload={pluginPayload}
|
||||
canOAuth={true}
|
||||
@ -614,7 +580,7 @@ describe('Authorize', () => {
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.opacity-50').length).toBe(0)
|
||||
expect(screen.getByRole('button')).not.toBeDisabled()
|
||||
|
||||
rerender(
|
||||
<Authorize
|
||||
@ -624,7 +590,7 @@ describe('Authorize', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.opacity-50').length).toBe(1)
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Credential } from '../../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CredentialTypeEnum } from '../../types'
|
||||
import Item from '../item'
|
||||
@ -67,7 +67,7 @@ describe('Item Component', () => {
|
||||
it('should render selected icon when showSelectedIcon is true and credential is selected', () => {
|
||||
const credential = createCredential({ id: 'selected-id' })
|
||||
|
||||
render(
|
||||
const { container } = render(
|
||||
<Item
|
||||
credential={credential}
|
||||
showSelectedIcon={true}
|
||||
@ -75,53 +75,64 @@ describe('Item Component', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// RiCheckLine should be rendered
|
||||
expect(document.querySelector('.text-text-accent')).toBeInTheDocument()
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not render selected icon when credential is not selected', () => {
|
||||
const credential = createCredential({ id: 'not-selected-id' })
|
||||
|
||||
render(
|
||||
const { container: selectedContainer } = render(
|
||||
<Item
|
||||
credential={createCredential({ id: 'sel-id' })}
|
||||
showSelectedIcon={true}
|
||||
selectedCredentialId="sel-id"
|
||||
/>,
|
||||
)
|
||||
const selectedSvgCount = selectedContainer.querySelectorAll('svg').length
|
||||
|
||||
cleanup()
|
||||
|
||||
const { container: unselectedContainer } = render(
|
||||
<Item
|
||||
credential={credential}
|
||||
showSelectedIcon={true}
|
||||
selectedCredentialId="other-id"
|
||||
/>,
|
||||
)
|
||||
const unselectedSvgCount = unselectedContainer.querySelectorAll('svg').length
|
||||
|
||||
// Check icon should not be visible
|
||||
expect(document.querySelector('.text-text-accent')).not.toBeInTheDocument()
|
||||
expect(unselectedSvgCount).toBeLessThan(selectedSvgCount)
|
||||
})
|
||||
|
||||
it('should render with gray indicator when not_allowed_to_use is true', () => {
|
||||
it('should render with disabled appearance when not_allowed_to_use is true', () => {
|
||||
const credential = createCredential({ not_allowed_to_use: true })
|
||||
|
||||
const { container } = render(<Item credential={credential} />)
|
||||
|
||||
// The item should have tooltip wrapper with data-state attribute for unavailable credential
|
||||
const tooltipTrigger = container.querySelector('[data-state]')
|
||||
expect(tooltipTrigger).toBeInTheDocument()
|
||||
// The item should have disabled styles
|
||||
expect(container.querySelector('.cursor-not-allowed')).toBeInTheDocument()
|
||||
expect(container.querySelector('[data-state]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply disabled styles when disabled is true', () => {
|
||||
it('should not call onItemClick when disabled is true', () => {
|
||||
const onItemClick = vi.fn()
|
||||
const credential = createCredential()
|
||||
|
||||
const { container } = render(<Item credential={credential} disabled={true} />)
|
||||
const { container } = render(<Item credential={credential} onItemClick={onItemClick} disabled={true} />)
|
||||
|
||||
const itemDiv = container.querySelector('.cursor-not-allowed')
|
||||
expect(itemDiv).toBeInTheDocument()
|
||||
fireEvent.click(container.firstElementChild!)
|
||||
|
||||
expect(onItemClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply disabled styles when not_allowed_to_use is true', () => {
|
||||
it('should not call onItemClick when not_allowed_to_use is true', () => {
|
||||
const onItemClick = vi.fn()
|
||||
const credential = createCredential({ not_allowed_to_use: true })
|
||||
|
||||
const { container } = render(<Item credential={credential} />)
|
||||
const { container } = render(<Item credential={credential} onItemClick={onItemClick} />)
|
||||
|
||||
const itemDiv = container.querySelector('.cursor-not-allowed')
|
||||
expect(itemDiv).toBeInTheDocument()
|
||||
fireEvent.click(container.firstElementChild!)
|
||||
|
||||
expect(onItemClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -135,8 +146,7 @@ describe('Item Component', () => {
|
||||
<Item credential={credential} onItemClick={onItemClick} />,
|
||||
)
|
||||
|
||||
const itemDiv = container.querySelector('.group')
|
||||
fireEvent.click(itemDiv!)
|
||||
fireEvent.click(container.firstElementChild!)
|
||||
|
||||
expect(onItemClick).toHaveBeenCalledWith('click-test-id')
|
||||
})
|
||||
@ -149,49 +159,22 @@ describe('Item Component', () => {
|
||||
<Item credential={credential} onItemClick={onItemClick} />,
|
||||
)
|
||||
|
||||
const itemDiv = container.querySelector('.group')
|
||||
fireEvent.click(itemDiv!)
|
||||
fireEvent.click(container.firstElementChild!)
|
||||
|
||||
expect(onItemClick).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('should not call onItemClick when disabled', () => {
|
||||
const onItemClick = vi.fn()
|
||||
const credential = createCredential()
|
||||
|
||||
const { container } = render(
|
||||
<Item credential={credential} onItemClick={onItemClick} disabled={true} />,
|
||||
)
|
||||
|
||||
const itemDiv = container.querySelector('.group')
|
||||
fireEvent.click(itemDiv!)
|
||||
|
||||
expect(onItemClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onItemClick when not_allowed_to_use is true', () => {
|
||||
const onItemClick = vi.fn()
|
||||
const credential = createCredential({ not_allowed_to_use: true })
|
||||
|
||||
const { container } = render(
|
||||
<Item credential={credential} onItemClick={onItemClick} />,
|
||||
)
|
||||
|
||||
const itemDiv = container.querySelector('.group')
|
||||
fireEvent.click(itemDiv!)
|
||||
|
||||
expect(onItemClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Rename Mode Tests ====================
|
||||
describe('Rename Mode', () => {
|
||||
it('should enter rename mode when rename button is clicked', () => {
|
||||
const credential = createCredential()
|
||||
const renderWithRenameEnabled = (overrides: Record<string, unknown> = {}) => {
|
||||
const onRename = vi.fn()
|
||||
const credential = createCredential({ name: 'Original Name', ...overrides })
|
||||
|
||||
const { container } = render(
|
||||
const result = render(
|
||||
<Item
|
||||
credential={credential}
|
||||
onRename={onRename}
|
||||
disableRename={false}
|
||||
disableEdit={true}
|
||||
disableDelete={true}
|
||||
@ -199,224 +182,67 @@ describe('Item Component', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Since buttons are hidden initially, we need to find the ActionButton
|
||||
// In the actual implementation, they are rendered but hidden
|
||||
const actionButtons = container.querySelectorAll('button')
|
||||
const renameBtn = Array.from(actionButtons).find(btn =>
|
||||
btn.querySelector('.ri-edit-line') || btn.innerHTML.includes('RiEditLine'),
|
||||
)
|
||||
|
||||
if (renameBtn) {
|
||||
fireEvent.click(renameBtn)
|
||||
// Should show input for rename
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
const enterRenameMode = () => {
|
||||
const firstButton = result.container.querySelectorAll('button')[0] as HTMLElement
|
||||
fireEvent.click(firstButton)
|
||||
}
|
||||
|
||||
return { ...result, onRename, enterRenameMode }
|
||||
}
|
||||
|
||||
it('should enter rename mode when rename button is clicked', () => {
|
||||
const { enterRenameMode } = renderWithRenameEnabled()
|
||||
|
||||
enterRenameMode()
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show save and cancel buttons in rename mode', () => {
|
||||
const onRename = vi.fn()
|
||||
const credential = createCredential({ name: 'Original Name' })
|
||||
const { enterRenameMode } = renderWithRenameEnabled()
|
||||
|
||||
const { container } = render(
|
||||
<Item
|
||||
credential={credential}
|
||||
onRename={onRename}
|
||||
disableRename={false}
|
||||
disableEdit={true}
|
||||
disableDelete={true}
|
||||
disableSetDefault={true}
|
||||
/>,
|
||||
)
|
||||
enterRenameMode()
|
||||
|
||||
// Find and click rename button to enter rename mode
|
||||
const actionButtons = container.querySelectorAll('button')
|
||||
// Find the rename action button by looking for RiEditLine icon
|
||||
actionButtons.forEach((btn) => {
|
||||
if (btn.querySelector('svg')) {
|
||||
fireEvent.click(btn)
|
||||
}
|
||||
})
|
||||
|
||||
// If we're in rename mode, there should be save/cancel buttons
|
||||
const buttons = screen.queryAllByRole('button')
|
||||
if (buttons.length >= 2) {
|
||||
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
|
||||
}
|
||||
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onRename with new name when save is clicked', () => {
|
||||
const onRename = vi.fn()
|
||||
const credential = createCredential({ id: 'rename-test-id', name: 'Original' })
|
||||
const { enterRenameMode, onRename } = renderWithRenameEnabled({ id: 'rename-test-id' })
|
||||
|
||||
const { container } = render(
|
||||
<Item
|
||||
credential={credential}
|
||||
onRename={onRename}
|
||||
disableRename={false}
|
||||
disableEdit={true}
|
||||
disableDelete={true}
|
||||
disableSetDefault={true}
|
||||
/>,
|
||||
)
|
||||
enterRenameMode()
|
||||
|
||||
// Trigger rename mode by clicking the rename button
|
||||
const editIcon = container.querySelector('svg.ri-edit-line')
|
||||
if (editIcon) {
|
||||
fireEvent.click(editIcon.closest('button')!)
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'New Name' } })
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
// Now in rename mode, change input and save
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'New Name' } })
|
||||
|
||||
// Click save
|
||||
const saveButton = screen.getByText('common.operation.save')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
expect(onRename).toHaveBeenCalledWith({
|
||||
credential_id: 'rename-test-id',
|
||||
name: 'New Name',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should call onRename and exit rename mode when save button is clicked', () => {
|
||||
const onRename = vi.fn()
|
||||
const credential = createCredential({ id: 'rename-save-test', name: 'Original Name' })
|
||||
|
||||
const { container } = render(
|
||||
<Item
|
||||
credential={credential}
|
||||
onRename={onRename}
|
||||
disableRename={false}
|
||||
disableEdit={true}
|
||||
disableDelete={true}
|
||||
disableSetDefault={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find and click rename button to enter rename mode
|
||||
// The button contains RiEditLine svg
|
||||
const allButtons = Array.from(container.querySelectorAll('button'))
|
||||
let renameButton: Element | null = null
|
||||
for (const btn of allButtons) {
|
||||
if (btn.querySelector('svg')) {
|
||||
renameButton = btn
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (renameButton) {
|
||||
fireEvent.click(renameButton)
|
||||
|
||||
// Should be in rename mode now
|
||||
const input = screen.queryByRole('textbox')
|
||||
if (input) {
|
||||
expect(input).toHaveValue('Original Name')
|
||||
|
||||
// Change the value
|
||||
fireEvent.change(input, { target: { value: 'Updated Name' } })
|
||||
expect(input).toHaveValue('Updated Name')
|
||||
|
||||
// Click save button
|
||||
const saveButton = screen.getByText('common.operation.save')
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
// Verify onRename was called with correct parameters
|
||||
expect(onRename).toHaveBeenCalledTimes(1)
|
||||
expect(onRename).toHaveBeenCalledWith({
|
||||
credential_id: 'rename-save-test',
|
||||
name: 'Updated Name',
|
||||
})
|
||||
|
||||
// Should exit rename mode - input should be gone
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
}
|
||||
}
|
||||
expect(onRename).toHaveBeenCalledWith({
|
||||
credential_id: 'rename-test-id',
|
||||
name: 'New Name',
|
||||
})
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should exit rename mode when cancel is clicked', () => {
|
||||
const credential = createCredential({ name: 'Original' })
|
||||
const { enterRenameMode } = renderWithRenameEnabled()
|
||||
|
||||
const { container } = render(
|
||||
<Item
|
||||
credential={credential}
|
||||
disableRename={false}
|
||||
disableEdit={true}
|
||||
disableDelete={true}
|
||||
disableSetDefault={true}
|
||||
/>,
|
||||
)
|
||||
enterRenameMode()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
|
||||
// Enter rename mode
|
||||
const editIcon = container.querySelector('svg')?.closest('button')
|
||||
if (editIcon) {
|
||||
fireEvent.click(editIcon)
|
||||
fireEvent.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
// If in rename mode, cancel button should exist
|
||||
const cancelButton = screen.queryByText('common.operation.cancel')
|
||||
if (cancelButton) {
|
||||
fireEvent.click(cancelButton)
|
||||
// Should exit rename mode - input should be gone
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
}
|
||||
}
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update rename value when input changes', () => {
|
||||
const credential = createCredential({ name: 'Original' })
|
||||
it('should update input value when typing', () => {
|
||||
const { enterRenameMode } = renderWithRenameEnabled()
|
||||
|
||||
const { container } = render(
|
||||
<Item
|
||||
credential={credential}
|
||||
disableRename={false}
|
||||
disableEdit={true}
|
||||
disableDelete={true}
|
||||
disableSetDefault={true}
|
||||
/>,
|
||||
)
|
||||
enterRenameMode()
|
||||
|
||||
// We need to get into rename mode first
|
||||
// The rename button appears on hover in the actions area
|
||||
const allButtons = container.querySelectorAll('button')
|
||||
if (allButtons.length > 0) {
|
||||
fireEvent.click(allButtons[0])
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'Updated Value' } })
|
||||
|
||||
const input = screen.queryByRole('textbox')
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: 'Updated Value' } })
|
||||
expect(input).toHaveValue('Updated Value')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('should stop propagation when clicking input in rename mode', () => {
|
||||
const onItemClick = vi.fn()
|
||||
const credential = createCredential()
|
||||
|
||||
const { container } = render(
|
||||
<Item
|
||||
credential={credential}
|
||||
onItemClick={onItemClick}
|
||||
disableRename={false}
|
||||
disableEdit={true}
|
||||
disableDelete={true}
|
||||
disableSetDefault={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Enter rename mode and click on input
|
||||
const allButtons = container.querySelectorAll('button')
|
||||
if (allButtons.length > 0) {
|
||||
fireEvent.click(allButtons[0])
|
||||
|
||||
const input = screen.queryByRole('textbox')
|
||||
if (input) {
|
||||
fireEvent.click(input)
|
||||
// onItemClick should not be called when clicking the input
|
||||
expect(onItemClick).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
expect(input).toHaveValue('Updated Value')
|
||||
})
|
||||
})
|
||||
|
||||
@ -437,12 +263,9 @@ describe('Item Component', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find set default button
|
||||
const setDefaultButton = screen.queryByText('plugin.auth.setDefault')
|
||||
if (setDefaultButton) {
|
||||
fireEvent.click(setDefaultButton)
|
||||
expect(onSetDefault).toHaveBeenCalledWith('test-credential-id')
|
||||
}
|
||||
const setDefaultButton = screen.getByText('plugin.auth.setDefault')
|
||||
fireEvent.click(setDefaultButton)
|
||||
expect(onSetDefault).toHaveBeenCalledWith('test-credential-id')
|
||||
})
|
||||
|
||||
it('should not show set default button when credential is already default', () => {
|
||||
@ -517,16 +340,13 @@ describe('Item Component', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find the edit button (RiEqualizer2Line icon)
|
||||
const editButton = container.querySelector('svg')?.closest('button')
|
||||
if (editButton) {
|
||||
fireEvent.click(editButton)
|
||||
expect(onEdit).toHaveBeenCalledWith('edit-test-id', {
|
||||
api_key: 'secret',
|
||||
__name__: 'Edit Test',
|
||||
__credential_id__: 'edit-test-id',
|
||||
})
|
||||
}
|
||||
const editButton = container.querySelector('svg')?.closest('button') as HTMLElement
|
||||
fireEvent.click(editButton)
|
||||
expect(onEdit).toHaveBeenCalledWith('edit-test-id', {
|
||||
api_key: 'secret',
|
||||
__name__: 'Edit Test',
|
||||
__credential_id__: 'edit-test-id',
|
||||
})
|
||||
})
|
||||
|
||||
it('should not show edit button for OAuth credentials', () => {
|
||||
@ -584,12 +404,9 @@ describe('Item Component', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find delete button (RiDeleteBinLine icon)
|
||||
const deleteButton = container.querySelector('svg')?.closest('button')
|
||||
if (deleteButton) {
|
||||
fireEvent.click(deleteButton)
|
||||
expect(onDelete).toHaveBeenCalledWith('delete-test-id')
|
||||
}
|
||||
const deleteButton = container.querySelector('svg')?.closest('button') as HTMLElement
|
||||
fireEvent.click(deleteButton)
|
||||
expect(onDelete).toHaveBeenCalledWith('delete-test-id')
|
||||
})
|
||||
|
||||
it('should not show delete button when disableDelete is true', () => {
|
||||
@ -704,44 +521,15 @@ describe('Item Component', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find delete button and click
|
||||
const deleteButton = container.querySelector('svg')?.closest('button')
|
||||
if (deleteButton) {
|
||||
fireEvent.click(deleteButton)
|
||||
// onDelete should be called but not onItemClick (due to stopPropagation)
|
||||
expect(onDelete).toHaveBeenCalled()
|
||||
// Note: onItemClick might still be called due to event bubbling in test environment
|
||||
}
|
||||
})
|
||||
|
||||
it('should disable action buttons when disabled prop is true', () => {
|
||||
const onSetDefault = vi.fn()
|
||||
const credential = createCredential({ is_default: false })
|
||||
|
||||
render(
|
||||
<Item
|
||||
credential={credential}
|
||||
onSetDefault={onSetDefault}
|
||||
disabled={true}
|
||||
disableSetDefault={false}
|
||||
disableRename={true}
|
||||
disableEdit={true}
|
||||
disableDelete={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Set default button should be disabled
|
||||
const setDefaultButton = screen.queryByText('plugin.auth.setDefault')
|
||||
if (setDefaultButton) {
|
||||
const button = setDefaultButton.closest('button')
|
||||
expect(button).toBeDisabled()
|
||||
}
|
||||
const deleteButton = container.querySelector('svg')?.closest('button') as HTMLElement
|
||||
fireEvent.click(deleteButton)
|
||||
expect(onDelete).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== showAction Logic Tests ====================
|
||||
describe('Show Action Logic', () => {
|
||||
it('should not show action area when all actions are disabled', () => {
|
||||
it('should not render action buttons when all actions are disabled', () => {
|
||||
const credential = createCredential()
|
||||
|
||||
const { container } = render(
|
||||
@ -754,12 +542,10 @@ describe('Item Component', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should not have action area with hover:flex
|
||||
const actionArea = container.querySelector('.group-hover\\:flex')
|
||||
expect(actionArea).not.toBeInTheDocument()
|
||||
expect(container.querySelectorAll('button').length).toBe(0)
|
||||
})
|
||||
|
||||
it('should show action area when at least one action is enabled', () => {
|
||||
it('should render action buttons when at least one action is enabled', () => {
|
||||
const credential = createCredential()
|
||||
|
||||
const { container } = render(
|
||||
@ -772,38 +558,33 @@ describe('Item Component', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should have action area
|
||||
const actionArea = container.querySelector('.group-hover\\:flex')
|
||||
expect(actionArea).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('button').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle credential with empty name', () => {
|
||||
const credential = createCredential({ name: '' })
|
||||
|
||||
render(<Item credential={credential} />)
|
||||
|
||||
// Should render without crashing
|
||||
expect(document.querySelector('.group')).toBeInTheDocument()
|
||||
expect(() => {
|
||||
render(<Item credential={credential} />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle credential with undefined credentials object', () => {
|
||||
const credential = createCredential({ credentials: undefined })
|
||||
|
||||
render(
|
||||
<Item
|
||||
credential={credential}
|
||||
disableEdit={false}
|
||||
disableRename={true}
|
||||
disableDelete={true}
|
||||
disableSetDefault={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should render without crashing
|
||||
expect(document.querySelector('.group')).toBeInTheDocument()
|
||||
expect(() => {
|
||||
render(
|
||||
<Item
|
||||
credential={credential}
|
||||
disableEdit={false}
|
||||
disableRename={true}
|
||||
disableDelete={true}
|
||||
disableSetDefault={true}
|
||||
/>,
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle all optional callbacks being undefined', () => {
|
||||
@ -814,13 +595,13 @@ describe('Item Component', () => {
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should properly display long credential names with truncation', () => {
|
||||
it('should display long credential names with title attribute', () => {
|
||||
const longName = 'A'.repeat(100)
|
||||
const credential = createCredential({ name: longName })
|
||||
|
||||
const { container } = render(<Item credential={credential} />)
|
||||
|
||||
const nameElement = container.querySelector('.truncate')
|
||||
const nameElement = container.querySelector('[title]')
|
||||
expect(nameElement).toBeInTheDocument()
|
||||
expect(nameElement?.getAttribute('title')).toBe(longName)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user