mirror of
https://github.com/langgenius/dify.git
synced 2025-12-19 14:19:28 +00:00
chore(web): add some tests (#29772)
This commit is contained in:
@@ -0,0 +1,878 @@
|
||||
import React from 'react'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import AssistantTypePicker from './index'
|
||||
import type { AgentConfig } from '@/models/debug'
|
||||
import { AgentStrategy } from '@/types/app'
|
||||
|
||||
// Type definition for AgentSetting props
|
||||
type AgentSettingProps = {
|
||||
isChatModel: boolean
|
||||
payload: AgentConfig
|
||||
isFunctionCall: boolean
|
||||
onCancel: () => void
|
||||
onSave: (payload: AgentConfig) => void
|
||||
}
|
||||
|
||||
// Track mock calls for props validation
|
||||
let mockAgentSettingProps: AgentSettingProps | null = null
|
||||
|
||||
// Mock AgentSetting component (complex modal with external hooks)
|
||||
jest.mock('../agent/agent-setting', () => {
|
||||
return function MockAgentSetting(props: AgentSettingProps) {
|
||||
mockAgentSettingProps = props
|
||||
return (
|
||||
<div data-testid="agent-setting-modal">
|
||||
<button onClick={() => props.onSave({ max_iteration: 5 } as AgentConfig)}>Save</button>
|
||||
<button onClick={props.onCancel}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Test utilities
|
||||
const defaultAgentConfig: AgentConfig = {
|
||||
enabled: true,
|
||||
max_iteration: 3,
|
||||
strategy: AgentStrategy.functionCall,
|
||||
tools: [],
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
value: 'chat',
|
||||
disabled: false,
|
||||
onChange: jest.fn(),
|
||||
isFunctionCall: true,
|
||||
isChatModel: true,
|
||||
agentConfig: defaultAgentConfig,
|
||||
onAgentSettingChange: jest.fn(),
|
||||
}
|
||||
|
||||
const renderComponent = (props: Partial<React.ComponentProps<typeof AssistantTypePicker>> = {}) => {
|
||||
const mergedProps = { ...defaultProps, ...props }
|
||||
return render(<AssistantTypePicker {...mergedProps} />)
|
||||
}
|
||||
|
||||
// Helper to get option element by description (which is unique per option)
|
||||
const getOptionByDescription = (descriptionRegex: RegExp) => {
|
||||
const description = screen.getByText(descriptionRegex)
|
||||
return description.parentElement as HTMLElement
|
||||
}
|
||||
|
||||
describe('AssistantTypePicker', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockAgentSettingProps = null
|
||||
})
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render chat assistant by default when value is "chat"', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ value: 'chat' })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render agent assistant when value is "agent"', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ value: 'agent' })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/agentAssistant.name/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests (REQUIRED)
|
||||
describe('Props', () => {
|
||||
it('should use provided value prop', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ value: 'agent' })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/agentAssistant.name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle agentConfig prop', () => {
|
||||
// Arrange
|
||||
const customAgentConfig: AgentConfig = {
|
||||
enabled: true,
|
||||
max_iteration: 10,
|
||||
strategy: AgentStrategy.react,
|
||||
tools: [],
|
||||
}
|
||||
|
||||
// Act
|
||||
expect(() => {
|
||||
renderComponent({ agentConfig: customAgentConfig })
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined agentConfig prop', () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderComponent({ agentConfig: undefined })
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should open dropdown when clicking trigger', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
|
||||
await user.click(trigger!)
|
||||
|
||||
// Assert - Both options should be visible
|
||||
await waitFor(() => {
|
||||
const chatOptions = screen.getAllByText(/chatAssistant.name/i)
|
||||
const agentOptions = screen.getAllByText(/agentAssistant.name/i)
|
||||
expect(chatOptions.length).toBeGreaterThan(1)
|
||||
expect(agentOptions.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onChange when selecting chat assistant', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onChange = jest.fn()
|
||||
renderComponent({ value: 'agent', onChange })
|
||||
|
||||
// Act - Open dropdown
|
||||
const trigger = screen.getByText(/agentAssistant.name/i)
|
||||
await user.click(trigger)
|
||||
|
||||
// Wait for dropdown to open and find chat option
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click the chat option by its unique description
|
||||
const chatOption = getOptionByDescription(/chatAssistant.description/i)
|
||||
await user.click(chatOption)
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith('chat')
|
||||
})
|
||||
|
||||
it('should call onChange when selecting agent assistant', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onChange = jest.fn()
|
||||
renderComponent({ value: 'chat', onChange })
|
||||
|
||||
// Act - Open dropdown
|
||||
const trigger = screen.getByText(/chatAssistant.name/i)
|
||||
await user.click(trigger)
|
||||
|
||||
// Wait for dropdown to open and click agent option
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const agentOption = getOptionByDescription(/agentAssistant.description/i)
|
||||
await user.click(agentOption)
|
||||
|
||||
// Assert
|
||||
expect(onChange).toHaveBeenCalledWith('agent')
|
||||
})
|
||||
|
||||
it('should close dropdown when selecting chat assistant', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ value: 'agent' })
|
||||
|
||||
// Act - Open dropdown
|
||||
const trigger = screen.getByText(/agentAssistant.name/i)
|
||||
await user.click(trigger)
|
||||
|
||||
// Wait for dropdown and select chat
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const chatOption = getOptionByDescription(/chatAssistant.description/i)
|
||||
await user.click(chatOption)
|
||||
|
||||
// Assert - Dropdown should close (descriptions should not be visible)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/chatAssistant.description/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not close dropdown when selecting agent assistant', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ value: 'chat' })
|
||||
|
||||
// Act - Open dropdown
|
||||
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
|
||||
await user.click(trigger!)
|
||||
|
||||
// Wait for dropdown and select agent
|
||||
await waitFor(() => {
|
||||
const agentOptions = screen.getAllByText(/agentAssistant.name/i)
|
||||
expect(agentOptions.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
const agentOptions = screen.getAllByText(/agentAssistant.name/i)
|
||||
await user.click(agentOptions[0].closest('div')!)
|
||||
|
||||
// Assert - Dropdown should remain open (agent settings should be visible)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onChange when clicking same value', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onChange = jest.fn()
|
||||
renderComponent({ value: 'chat', onChange })
|
||||
|
||||
// Act - Open dropdown
|
||||
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
|
||||
await user.click(trigger!)
|
||||
|
||||
// Wait for dropdown and click same option
|
||||
await waitFor(() => {
|
||||
const chatOptions = screen.getAllByText(/chatAssistant.name/i)
|
||||
expect(chatOptions.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
const chatOptions = screen.getAllByText(/chatAssistant.name/i)
|
||||
await user.click(chatOptions[1].closest('div')!)
|
||||
|
||||
// Assert
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Disabled state
|
||||
describe('Disabled State', () => {
|
||||
it('should not respond to clicks when disabled', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onChange = jest.fn()
|
||||
renderComponent({ disabled: true, onChange })
|
||||
|
||||
// Act - Open dropdown (dropdown can still open when disabled)
|
||||
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
|
||||
await user.click(trigger!)
|
||||
|
||||
// Wait for dropdown to open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Act - Try to click an option
|
||||
const agentOption = getOptionByDescription(/agentAssistant.description/i)
|
||||
await user.click(agentOption)
|
||||
|
||||
// Assert - onChange should not be called (options are disabled)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not show agent config UI when disabled', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ value: 'agent', disabled: true })
|
||||
|
||||
// Act - Open dropdown
|
||||
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
|
||||
await user.click(trigger!)
|
||||
|
||||
// Assert - Agent settings option should not be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/agent.setting.name/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show agent config UI when not disabled', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ value: 'agent', disabled: false })
|
||||
|
||||
// Act - Open dropdown
|
||||
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
|
||||
await user.click(trigger!)
|
||||
|
||||
// Assert - Agent settings option should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Agent Settings Modal
|
||||
describe('Agent Settings Modal', () => {
|
||||
it('should open agent settings modal when clicking agent config UI', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ value: 'agent', disabled: false })
|
||||
|
||||
// Act - Open dropdown
|
||||
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
|
||||
await user.click(trigger!)
|
||||
|
||||
// Click agent settings
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div')
|
||||
await user.click(agentSettingsTrigger!)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not open agent settings when value is not agent', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ value: 'chat', disabled: false })
|
||||
|
||||
// Act - Open dropdown
|
||||
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
|
||||
await user.click(trigger!)
|
||||
|
||||
// Wait for dropdown to open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Assert - Agent settings modal should not appear (value is 'chat')
|
||||
expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onAgentSettingChange when saving agent settings', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onAgentSettingChange = jest.fn()
|
||||
renderComponent({ value: 'agent', disabled: false, onAgentSettingChange })
|
||||
|
||||
// Act - Open dropdown and agent settings
|
||||
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
|
||||
await user.click(trigger!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div')
|
||||
await user.click(agentSettingsTrigger!)
|
||||
|
||||
// Wait for modal and click save
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const saveButton = screen.getByText('Save')
|
||||
await user.click(saveButton)
|
||||
|
||||
// Assert
|
||||
expect(onAgentSettingChange).toHaveBeenCalledWith({ max_iteration: 5 })
|
||||
})
|
||||
|
||||
it('should close modal when saving agent settings', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ value: 'agent', disabled: false })
|
||||
|
||||
// Act - Open dropdown, agent settings, and save
|
||||
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
|
||||
await user.click(trigger!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div')
|
||||
await user.click(agentSettingsTrigger!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const saveButton = screen.getByText('Save')
|
||||
await user.click(saveButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close modal when canceling agent settings', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onAgentSettingChange = jest.fn()
|
||||
renderComponent({ value: 'agent', disabled: false, onAgentSettingChange })
|
||||
|
||||
// Act - Open dropdown, agent settings, and cancel
|
||||
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
|
||||
await user.click(trigger!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div')
|
||||
await user.click(agentSettingsTrigger!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const cancelButton = screen.getByText('Cancel')
|
||||
await user.click(cancelButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(onAgentSettingChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close dropdown when opening agent settings', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ value: 'agent', disabled: false })
|
||||
|
||||
// Act - Open dropdown and agent settings
|
||||
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
|
||||
await user.click(trigger!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div')
|
||||
await user.click(agentSettingsTrigger!)
|
||||
|
||||
// Assert - Modal should be open and dropdown should close
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The dropdown should be closed (agent settings description should not be visible)
|
||||
await waitFor(() => {
|
||||
const descriptions = screen.queryAllByText(/agent.setting.description/i)
|
||||
expect(descriptions.length).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle rapid toggle clicks', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
|
||||
await user.click(trigger!)
|
||||
await user.click(trigger!)
|
||||
await user.click(trigger!)
|
||||
|
||||
// Assert - Should not crash
|
||||
expect(trigger).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple rapid selection changes', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const onChange = jest.fn()
|
||||
renderComponent({ value: 'chat', onChange })
|
||||
|
||||
// Act - Open and select agent
|
||||
const trigger = screen.getByText(/chatAssistant.name/i)
|
||||
await user.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click agent option - this stays open because value is 'agent'
|
||||
const agentOption = getOptionByDescription(/agentAssistant.description/i)
|
||||
await user.click(agentOption)
|
||||
|
||||
// Assert - onChange should have been called once to switch to agent
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(onChange).toHaveBeenCalledWith('agent')
|
||||
})
|
||||
|
||||
it('should handle missing callback functions gracefully', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act & Assert - Should not crash
|
||||
expect(() => {
|
||||
renderComponent({
|
||||
onChange: undefined!,
|
||||
onAgentSettingChange: undefined!,
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
|
||||
await user.click(trigger!)
|
||||
})
|
||||
|
||||
it('should handle empty agentConfig', async () => {
|
||||
// Arrange & Act
|
||||
expect(() => {
|
||||
renderComponent({ agentConfig: {} as AgentConfig })
|
||||
}).not.toThrow()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('should render with different prop combinations', () => {
|
||||
const combinations = [
|
||||
{ value: 'chat' as const, disabled: true, isFunctionCall: true, isChatModel: true },
|
||||
{ value: 'agent' as const, disabled: false, isFunctionCall: false, isChatModel: false },
|
||||
{ value: 'agent' as const, disabled: true, isFunctionCall: true, isChatModel: false },
|
||||
{ value: 'chat' as const, disabled: false, isFunctionCall: false, isChatModel: true },
|
||||
]
|
||||
|
||||
it.each(combinations)(
|
||||
'value=$value, disabled=$disabled, isFunctionCall=$isFunctionCall, isChatModel=$isChatModel',
|
||||
(combo) => {
|
||||
// Arrange & Act
|
||||
renderComponent(combo)
|
||||
|
||||
// Assert
|
||||
const expectedText = combo.value === 'agent' ? 'agentAssistant.name' : 'chatAssistant.name'
|
||||
expect(screen.getByText(new RegExp(expectedText, 'i'))).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Accessibility
|
||||
describe('Accessibility', () => {
|
||||
it('should render interactive dropdown items', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act - Open dropdown
|
||||
const trigger = screen.getByText(/chatAssistant.name/i)
|
||||
await user.click(trigger)
|
||||
|
||||
// Assert - Both options should be visible and clickable
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Verify we can interact with option elements using helper function
|
||||
const chatOption = getOptionByDescription(/chatAssistant.description/i)
|
||||
const agentOption = getOptionByDescription(/agentAssistant.description/i)
|
||||
expect(chatOption).toBeInTheDocument()
|
||||
expect(agentOption).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// SelectItem Component
|
||||
describe('SelectItem Component', () => {
|
||||
it('should show checked state for selected option', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ value: 'chat' })
|
||||
|
||||
// Act - Open dropdown
|
||||
const trigger = screen.getByText(/chatAssistant.name/i)
|
||||
await user.click(trigger)
|
||||
|
||||
// Assert - Both options should be visible with radio components
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The SelectItem components render with different visual states
|
||||
// based on isChecked prop - we verify both options are rendered
|
||||
const chatOption = getOptionByDescription(/chatAssistant.description/i)
|
||||
const agentOption = getOptionByDescription(/agentAssistant.description/i)
|
||||
expect(chatOption).toBeInTheDocument()
|
||||
expect(agentOption).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description text', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act - Open dropdown
|
||||
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
|
||||
await user.click(trigger!)
|
||||
|
||||
// Assert - Descriptions should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show Radio component for each option', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act - Open dropdown
|
||||
const trigger = screen.getByText(/chatAssistant.name/i)
|
||||
await user.click(trigger)
|
||||
|
||||
// Assert - Radio components should be present (both options visible)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Props Validation for AgentSetting
|
||||
describe('AgentSetting Props', () => {
|
||||
it('should pass isFunctionCall and isChatModel props to AgentSetting', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent({
|
||||
value: 'agent',
|
||||
isFunctionCall: true,
|
||||
isChatModel: false,
|
||||
})
|
||||
|
||||
// Act - Open dropdown and trigger AgentSetting
|
||||
const trigger = screen.getByText(/agentAssistant.name/i)
|
||||
await user.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
|
||||
await user.click(agentSettingsTrigger)
|
||||
|
||||
// Assert - Verify AgentSetting receives correct props
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(mockAgentSettingProps).not.toBeNull()
|
||||
expect(mockAgentSettingProps!.isFunctionCall).toBe(true)
|
||||
expect(mockAgentSettingProps!.isChatModel).toBe(false)
|
||||
})
|
||||
|
||||
it('should pass agentConfig payload to AgentSetting', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const customConfig: AgentConfig = {
|
||||
enabled: true,
|
||||
max_iteration: 10,
|
||||
strategy: AgentStrategy.react,
|
||||
tools: [],
|
||||
}
|
||||
|
||||
renderComponent({
|
||||
value: 'agent',
|
||||
agentConfig: customConfig,
|
||||
})
|
||||
|
||||
// Act - Open AgentSetting
|
||||
const trigger = screen.getByText(/agentAssistant.name/i)
|
||||
await user.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
|
||||
await user.click(agentSettingsTrigger)
|
||||
|
||||
// Assert - Verify payload was passed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(mockAgentSettingProps).not.toBeNull()
|
||||
expect(mockAgentSettingProps!.payload).toEqual(customConfig)
|
||||
})
|
||||
})
|
||||
|
||||
// Keyboard Navigation
|
||||
describe('Keyboard Navigation', () => {
|
||||
it('should support closing dropdown with Escape key', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act - Open dropdown
|
||||
const trigger = screen.getByText(/chatAssistant.name/i)
|
||||
await user.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Press Escape
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
// Assert - Dropdown should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/chatAssistant.description/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow keyboard focus on trigger element', () => {
|
||||
// Arrange
|
||||
renderComponent()
|
||||
|
||||
// Act - Get trigger and verify it can receive focus
|
||||
const trigger = screen.getByText(/chatAssistant.name/i)
|
||||
|
||||
// Assert - Element should be focusable
|
||||
expect(trigger).toBeInTheDocument()
|
||||
expect(trigger.parentElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow keyboard focus on dropdown options', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act - Open dropdown
|
||||
const trigger = screen.getByText(/chatAssistant.name/i)
|
||||
await user.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Get options
|
||||
const chatOption = getOptionByDescription(/chatAssistant.description/i)
|
||||
const agentOption = getOptionByDescription(/agentAssistant.description/i)
|
||||
|
||||
// Assert - Options should be focusable
|
||||
expect(chatOption).toBeInTheDocument()
|
||||
expect(agentOption).toBeInTheDocument()
|
||||
|
||||
// Verify options can receive focus
|
||||
act(() => {
|
||||
chatOption.focus()
|
||||
})
|
||||
expect(document.activeElement).toBe(chatOption)
|
||||
})
|
||||
|
||||
it('should maintain keyboard accessibility for all interactive elements', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ value: 'agent' })
|
||||
|
||||
// Act - Open dropdown
|
||||
const trigger = screen.getByText(/agentAssistant.name/i)
|
||||
await user.click(trigger)
|
||||
|
||||
// Assert - Agent settings button should be focusable
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const agentSettings = screen.getByText(/agent.setting.name/i)
|
||||
expect(agentSettings).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ARIA Attributes
|
||||
describe('ARIA Attributes', () => {
|
||||
it('should have proper ARIA state for dropdown', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderComponent()
|
||||
|
||||
// Act - Check initial state
|
||||
const portalContainer = container.querySelector('[data-state]')
|
||||
expect(portalContainer).toHaveAttribute('data-state', 'closed')
|
||||
|
||||
// Open dropdown
|
||||
const trigger = screen.getByText(/chatAssistant.name/i)
|
||||
await user.click(trigger)
|
||||
|
||||
// Assert - State should change to open
|
||||
await waitFor(() => {
|
||||
const openPortal = container.querySelector('[data-state="open"]')
|
||||
expect(openPortal).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should have proper data-state attribute', () => {
|
||||
// Arrange & Act
|
||||
const { container } = renderComponent()
|
||||
|
||||
// Assert - Portal should have data-state for accessibility
|
||||
const portalContainer = container.querySelector('[data-state]')
|
||||
expect(portalContainer).toBeInTheDocument()
|
||||
expect(portalContainer).toHaveAttribute('data-state')
|
||||
|
||||
// Should start in closed state
|
||||
expect(portalContainer).toHaveAttribute('data-state', 'closed')
|
||||
})
|
||||
|
||||
it('should maintain accessible structure for screen readers', () => {
|
||||
// Arrange & Act
|
||||
renderComponent({ value: 'chat' })
|
||||
|
||||
// Assert - Text content should be accessible
|
||||
expect(screen.getByText(/chatAssistant.name/i)).toBeInTheDocument()
|
||||
|
||||
// Icons should have proper structure
|
||||
const { container } = renderComponent()
|
||||
const icons = container.querySelectorAll('svg')
|
||||
expect(icons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should provide context through text labels', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
// Act - Open dropdown
|
||||
const trigger = screen.getByText(/chatAssistant.name/i)
|
||||
await user.click(trigger)
|
||||
|
||||
// Assert - All options should have descriptive text
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/chatAssistant.description/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/agentAssistant.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Title text should be visible
|
||||
expect(screen.getByText(/assistantType.name/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
625
web/app/components/billing/upgrade-btn/index.spec.tsx
Normal file
625
web/app/components/billing/upgrade-btn/index.spec.tsx
Normal file
@@ -0,0 +1,625 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import UpgradeBtn from './index'
|
||||
|
||||
// ✅ Import real project components (DO NOT mock these)
|
||||
// PremiumBadge, Button, SparklesSoft are all base components
|
||||
|
||||
// ✅ Mock i18n with actual translations instead of returning keys
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'billing.upgradeBtn.encourage': 'Upgrade to Pro',
|
||||
'billing.upgradeBtn.encourageShort': 'Upgrade',
|
||||
'billing.upgradeBtn.plain': 'Upgrade Plan',
|
||||
'custom.label.key': 'Custom Label',
|
||||
'custom.key': 'Custom Text',
|
||||
'custom.short.key': 'Short Custom',
|
||||
'custom.all': 'All Custom Props',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// ✅ Mock external dependencies only
|
||||
const mockSetShowPricingModal = jest.fn()
|
||||
jest.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock gtag for tracking tests
|
||||
let mockGtag: jest.Mock | undefined
|
||||
|
||||
describe('UpgradeBtn', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockGtag = jest.fn()
|
||||
;(window as any).gtag = mockGtag
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as any).gtag
|
||||
})
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing with default props', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
|
||||
// Assert - should render with default text
|
||||
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render premium badge by default', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
|
||||
// Assert - PremiumBadge renders with text content
|
||||
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plain button when isPlain is true', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain />)
|
||||
|
||||
// Assert - Button should be rendered with plain text
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgrade plan/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render short text when isShort is true', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn isShort />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/^upgrade$/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom label when labelKey is provided', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn labelKey="custom.label.key" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/custom label/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom label in plain button when labelKey is provided with isPlain', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain labelKey="custom.label.key" />)
|
||||
|
||||
// Assert
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(screen.getByText(/custom label/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests (REQUIRED)
|
||||
describe('Props', () => {
|
||||
it('should apply custom className to premium badge', () => {
|
||||
// Arrange
|
||||
const customClass = 'custom-upgrade-btn'
|
||||
|
||||
// Act
|
||||
const { container } = render(<UpgradeBtn className={customClass} />)
|
||||
|
||||
// Assert - Check the root element has the custom class
|
||||
const rootElement = container.firstChild as HTMLElement
|
||||
expect(rootElement).toHaveClass(customClass)
|
||||
})
|
||||
|
||||
it('should apply custom className to plain button', () => {
|
||||
// Arrange
|
||||
const customClass = 'custom-button-class'
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain className={customClass} />)
|
||||
|
||||
// Assert
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass(customClass)
|
||||
})
|
||||
|
||||
it('should apply custom style to premium badge', () => {
|
||||
// Arrange
|
||||
const customStyle = { backgroundColor: 'red', padding: '10px' }
|
||||
|
||||
// Act
|
||||
const { container } = render(<UpgradeBtn style={customStyle} />)
|
||||
|
||||
// Assert
|
||||
const rootElement = container.firstChild as HTMLElement
|
||||
expect(rootElement).toHaveStyle(customStyle)
|
||||
})
|
||||
|
||||
it('should apply custom style to plain button', () => {
|
||||
// Arrange
|
||||
const customStyle = { backgroundColor: 'blue', margin: '5px' }
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain style={customStyle} />)
|
||||
|
||||
// Assert
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveStyle(customStyle)
|
||||
})
|
||||
|
||||
it('should render with size "s"', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn size="s" />)
|
||||
|
||||
// Assert - Component renders successfully with size prop
|
||||
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with size "m" by default', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
|
||||
// Assert - Component renders successfully
|
||||
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with size "custom"', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn size="custom" />)
|
||||
|
||||
// Assert - Component renders successfully with custom size
|
||||
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call custom onClick when provided and premium badge is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = jest.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn onClick={handleClick} />)
|
||||
const badge = screen.getByText(/upgrade to pro/i).closest('div')
|
||||
await user.click(badge!)
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call custom onClick when provided and plain button is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = jest.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain onClick={handleClick} />)
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open pricing modal when no custom onClick is provided and premium badge is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
const badge = screen.getByText(/upgrade to pro/i).closest('div')
|
||||
await user.click(badge!)
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should open pricing modal when no custom onClick is provided and plain button is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain />)
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should track gtag event when loc is provided and badge is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const loc = 'header-navigation'
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn loc={loc} />)
|
||||
const badge = screen.getByText(/upgrade to pro/i).closest('div')
|
||||
await user.click(badge!)
|
||||
|
||||
// Assert
|
||||
expect(mockGtag).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
|
||||
loc,
|
||||
})
|
||||
})
|
||||
|
||||
it('should track gtag event when loc is provided and plain button is clicked', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const loc = 'footer-section'
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain loc={loc} />)
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// Assert
|
||||
expect(mockGtag).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
|
||||
loc,
|
||||
})
|
||||
})
|
||||
|
||||
it('should not track gtag event when loc is not provided', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
const badge = screen.getByText(/upgrade to pro/i).closest('div')
|
||||
await user.click(badge!)
|
||||
|
||||
// Assert
|
||||
expect(mockGtag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not track gtag event when gtag is not available', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
delete (window as any).gtag
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn loc="test-location" />)
|
||||
const badge = screen.getByText(/upgrade to pro/i).closest('div')
|
||||
await user.click(badge!)
|
||||
|
||||
// Assert - should not throw error
|
||||
expect(mockGtag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call both custom onClick and track gtag when both are provided', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = jest.fn()
|
||||
const loc = 'settings-page'
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn onClick={handleClick} loc={loc} />)
|
||||
const badge = screen.getByText(/upgrade to pro/i).closest('div')
|
||||
await user.click(badge!)
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
|
||||
loc,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Edge Cases (REQUIRED)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined className', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn className={undefined} />)
|
||||
|
||||
// Assert - should render without error
|
||||
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined style', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn style={undefined} />)
|
||||
|
||||
// Assert - should render without error
|
||||
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined onClick', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn onClick={undefined} />)
|
||||
const badge = screen.getByText(/upgrade to pro/i).closest('div')
|
||||
await user.click(badge!)
|
||||
|
||||
// Assert - should fall back to setShowPricingModal
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle undefined loc', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn loc={undefined} />)
|
||||
const badge = screen.getByText(/upgrade to pro/i).closest('div')
|
||||
await user.click(badge!)
|
||||
|
||||
// Assert - should not attempt to track gtag
|
||||
expect(mockGtag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle undefined labelKey', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn labelKey={undefined} />)
|
||||
|
||||
// Assert - should use default label
|
||||
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string className', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn className="" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string loc', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn loc="" />)
|
||||
const badge = screen.getByText(/upgrade to pro/i).closest('div')
|
||||
await user.click(badge!)
|
||||
|
||||
// Assert - empty loc should not trigger gtag
|
||||
expect(mockGtag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle empty string labelKey', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn labelKey="" />)
|
||||
|
||||
// Assert - empty labelKey is falsy, so it falls back to default label
|
||||
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Prop Combinations
|
||||
describe('Prop Combinations', () => {
|
||||
it('should handle isPlain with isShort', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain isShort />)
|
||||
|
||||
// Assert - isShort should not affect plain button text
|
||||
expect(screen.getByText(/upgrade plan/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle isPlain with custom labelKey', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain labelKey="custom.key" />)
|
||||
|
||||
// Assert - labelKey should override plain text
|
||||
expect(screen.getByText(/custom text/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/upgrade plan/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle isShort with custom labelKey', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn isShort labelKey="custom.short.key" />)
|
||||
|
||||
// Assert - labelKey should override isShort behavior
|
||||
expect(screen.getByText(/short custom/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/^upgrade$/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle all custom props together', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = jest.fn()
|
||||
const customStyle = { margin: '10px' }
|
||||
const customClass = 'all-custom'
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
<UpgradeBtn
|
||||
className={customClass}
|
||||
style={customStyle}
|
||||
size="s"
|
||||
isShort
|
||||
onClick={handleClick}
|
||||
loc="test-loc"
|
||||
labelKey="custom.all"
|
||||
/>,
|
||||
)
|
||||
const badge = screen.getByText(/all custom props/i).closest('div')
|
||||
await user.click(badge!)
|
||||
|
||||
// Assert
|
||||
const rootElement = container.firstChild as HTMLElement
|
||||
expect(rootElement).toHaveClass(customClass)
|
||||
expect(rootElement).toHaveStyle(customStyle)
|
||||
expect(screen.getByText(/all custom props/i)).toBeInTheDocument()
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
|
||||
loc: 'test-loc',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Accessibility Tests
|
||||
describe('Accessibility', () => {
|
||||
it('should be keyboard accessible with plain button', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = jest.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain onClick={handleClick} />)
|
||||
const button = screen.getByRole('button')
|
||||
|
||||
// Tab to button
|
||||
await user.tab()
|
||||
expect(button).toHaveFocus()
|
||||
|
||||
// Press Enter
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should be keyboard accessible with Space key', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = jest.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain onClick={handleClick} />)
|
||||
|
||||
// Tab to button and press Space
|
||||
await user.tab()
|
||||
await user.keyboard(' ')
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should be clickable for premium badge variant', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = jest.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn onClick={handleClick} />)
|
||||
const badge = screen.getByText(/upgrade to pro/i).closest('div')
|
||||
|
||||
// Click badge
|
||||
await user.click(badge!)
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should have proper button role when isPlain is true', () => {
|
||||
// Act
|
||||
render(<UpgradeBtn isPlain />)
|
||||
|
||||
// Assert - Plain button should have button role
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Performance Tests
|
||||
describe('Performance', () => {
|
||||
it('should not rerender when props do not change', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<UpgradeBtn loc="test" />)
|
||||
const firstRender = screen.getByText(/upgrade to pro/i)
|
||||
|
||||
// Act - Rerender with same props
|
||||
rerender(<UpgradeBtn loc="test" />)
|
||||
|
||||
// Assert - Component should still be in document
|
||||
expect(firstRender).toBeInTheDocument()
|
||||
expect(screen.getByText(/upgrade to pro/i)).toBe(firstRender)
|
||||
})
|
||||
|
||||
it('should rerender when props change', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<UpgradeBtn labelKey="custom.key" />)
|
||||
expect(screen.getByText(/custom text/i)).toBeInTheDocument()
|
||||
|
||||
// Act - Rerender with different labelKey
|
||||
rerender(<UpgradeBtn labelKey="custom.label.key" />)
|
||||
|
||||
// Assert - Should show new label
|
||||
expect(screen.getByText(/custom label/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/custom text/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle rapid rerenders efficiently', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<UpgradeBtn />)
|
||||
|
||||
// Act - Multiple rapid rerenders
|
||||
for (let i = 0; i < 10; i++)
|
||||
rerender(<UpgradeBtn />)
|
||||
|
||||
// Assert - Component should still render correctly
|
||||
expect(screen.getByText(/upgrade to pro/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be memoized with React.memo', () => {
|
||||
// Arrange
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => <div>{children}</div>
|
||||
|
||||
const { rerender } = render(
|
||||
<TestWrapper>
|
||||
<UpgradeBtn />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
const firstElement = screen.getByText(/upgrade to pro/i)
|
||||
|
||||
// Act - Rerender parent with same props
|
||||
rerender(
|
||||
<TestWrapper>
|
||||
<UpgradeBtn />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
// Assert - Element reference should be stable due to memo
|
||||
expect(screen.getByText(/upgrade to pro/i)).toBe(firstElement)
|
||||
})
|
||||
})
|
||||
|
||||
// Integration Tests
|
||||
describe('Integration', () => {
|
||||
it('should work with modal context for pricing modal', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn />)
|
||||
const badge = screen.getByText(/upgrade to pro/i).closest('div')
|
||||
await user.click(badge!)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should integrate onClick with analytics tracking', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const handleClick = jest.fn()
|
||||
|
||||
// Act
|
||||
render(<UpgradeBtn onClick={handleClick} loc="integration-test" />)
|
||||
const badge = screen.getByText(/upgrade to pro/i).closest('div')
|
||||
await user.click(badge!)
|
||||
|
||||
// Assert - Both onClick and gtag should be called
|
||||
await waitFor(() => {
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', {
|
||||
loc: 'integration-test',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
738
web/app/components/explore/installed-app/index.spec.tsx
Normal file
738
web/app/components/explore/installed-app/index.spec.tsx
Normal file
@@ -0,0 +1,738 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
|
||||
// Mock external dependencies BEFORE imports
|
||||
jest.mock('use-context-selector', () => ({
|
||||
useContext: jest.fn(),
|
||||
createContext: jest.fn(() => ({})),
|
||||
}))
|
||||
jest.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: jest.fn(),
|
||||
}))
|
||||
jest.mock('@/service/access-control', () => ({
|
||||
useGetUserCanAccessApp: jest.fn(),
|
||||
}))
|
||||
jest.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledAppAccessModeByAppId: jest.fn(),
|
||||
useGetInstalledAppParams: jest.fn(),
|
||||
useGetInstalledAppMeta: jest.fn(),
|
||||
}))
|
||||
|
||||
import { useContext } from 'use-context-selector'
|
||||
import InstalledApp from './index'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
|
||||
import type { InstalledApp as InstalledAppType } from '@/models/explore'
|
||||
|
||||
/**
|
||||
* Mock child components for unit testing
|
||||
*
|
||||
* RATIONALE FOR MOCKING:
|
||||
* - TextGenerationApp: 648 lines, complex batch processing, task management, file uploads
|
||||
* - ChatWithHistory: 576-line custom hook, complex conversation/history management, 30+ context values
|
||||
*
|
||||
* These components are too complex to test as real components. Using real components would:
|
||||
* 1. Require mocking dozens of their dependencies (services, contexts, hooks)
|
||||
* 2. Make tests fragile and coupled to child component implementation details
|
||||
* 3. Violate the principle of testing one component in isolation
|
||||
*
|
||||
* For a container component like InstalledApp, its responsibility is to:
|
||||
* - Correctly route to the appropriate child component based on app mode
|
||||
* - Pass the correct props to child components
|
||||
* - Handle loading/error states before rendering children
|
||||
*
|
||||
* The internal logic of ChatWithHistory and TextGenerationApp should be tested
|
||||
* in their own dedicated test files.
|
||||
*/
|
||||
jest.mock('@/app/components/share/text-generation', () => ({
|
||||
__esModule: true,
|
||||
default: ({ isInstalledApp, installedAppInfo, isWorkflow }: {
|
||||
isInstalledApp?: boolean
|
||||
installedAppInfo?: InstalledAppType
|
||||
isWorkflow?: boolean
|
||||
}) => (
|
||||
<div data-testid="text-generation-app">
|
||||
Text Generation App
|
||||
{isWorkflow && ' (Workflow)'}
|
||||
{isInstalledApp && ` - ${installedAppInfo?.id}`}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/base/chat/chat-with-history', () => ({
|
||||
__esModule: true,
|
||||
default: ({ installedAppInfo, className }: {
|
||||
installedAppInfo?: InstalledAppType
|
||||
className?: string
|
||||
}) => (
|
||||
<div data-testid="chat-with-history" className={className}>
|
||||
Chat With History - {installedAppInfo?.id}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('InstalledApp', () => {
|
||||
const mockUpdateAppInfo = jest.fn()
|
||||
const mockUpdateWebAppAccessMode = jest.fn()
|
||||
const mockUpdateAppParams = jest.fn()
|
||||
const mockUpdateWebAppMeta = jest.fn()
|
||||
const mockUpdateUserCanAccessApp = jest.fn()
|
||||
|
||||
const mockInstalledApp = {
|
||||
id: 'installed-app-123',
|
||||
app: {
|
||||
id: 'app-123',
|
||||
name: 'Test App',
|
||||
mode: AppModeEnum.CHAT,
|
||||
icon_type: 'emoji' as const,
|
||||
icon: '🚀',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_url: '',
|
||||
description: 'Test description',
|
||||
use_icon_as_answer_icon: false,
|
||||
},
|
||||
uninstallable: true,
|
||||
is_pinned: false,
|
||||
}
|
||||
|
||||
const mockAppParams = {
|
||||
user_input_form: [],
|
||||
file_upload: { image: { enabled: false, number_limits: 0, transfer_methods: [] } },
|
||||
system_parameters: {},
|
||||
}
|
||||
|
||||
const mockAppMeta = {
|
||||
tool_icons: {},
|
||||
}
|
||||
|
||||
const mockWebAppAccessMode = {
|
||||
accessMode: AccessMode.PUBLIC,
|
||||
}
|
||||
|
||||
const mockUserCanAccessApp = {
|
||||
result: true,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
// Mock useContext
|
||||
;(useContext as jest.Mock).mockReturnValue({
|
||||
installedApps: [mockInstalledApp],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
// Mock useWebAppStore
|
||||
;(useWebAppStore as unknown as jest.Mock).mockImplementation((
|
||||
selector: (state: {
|
||||
updateAppInfo: jest.Mock
|
||||
updateWebAppAccessMode: jest.Mock
|
||||
updateAppParams: jest.Mock
|
||||
updateWebAppMeta: jest.Mock
|
||||
updateUserCanAccessApp: jest.Mock
|
||||
}) => unknown,
|
||||
) => {
|
||||
const state = {
|
||||
updateAppInfo: mockUpdateAppInfo,
|
||||
updateWebAppAccessMode: mockUpdateWebAppAccessMode,
|
||||
updateAppParams: mockUpdateAppParams,
|
||||
updateWebAppMeta: mockUpdateWebAppMeta,
|
||||
updateUserCanAccessApp: mockUpdateUserCanAccessApp,
|
||||
}
|
||||
return selector(state)
|
||||
})
|
||||
|
||||
// Mock service hooks with default success states
|
||||
;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: mockWebAppAccessMode,
|
||||
error: null,
|
||||
})
|
||||
|
||||
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: mockAppParams,
|
||||
error: null,
|
||||
})
|
||||
|
||||
;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: mockAppMeta,
|
||||
error: null,
|
||||
})
|
||||
|
||||
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
|
||||
data: mockUserCanAccessApp,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading state when fetching app params', () => {
|
||||
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
|
||||
isFetching: true,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { container } = render(<InstalledApp id="installed-app-123" />)
|
||||
const svg = container.querySelector('svg.spin-animation')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading state when fetching app meta', () => {
|
||||
;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({
|
||||
isFetching: true,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { container } = render(<InstalledApp id="installed-app-123" />)
|
||||
const svg = container.querySelector('svg.spin-animation')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading state when fetching web app access mode', () => {
|
||||
;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({
|
||||
isFetching: true,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { container } = render(<InstalledApp id="installed-app-123" />)
|
||||
const svg = container.querySelector('svg.spin-animation')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading state when fetching installed apps', () => {
|
||||
;(useContext as jest.Mock).mockReturnValue({
|
||||
installedApps: [mockInstalledApp],
|
||||
isFetchingInstalledApps: true,
|
||||
})
|
||||
|
||||
const { container } = render(<InstalledApp id="installed-app-123" />)
|
||||
const svg = container.querySelector('svg.spin-animation')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app not found (404) when installedApp does not exist', () => {
|
||||
;(useContext as jest.Mock).mockReturnValue({
|
||||
installedApps: [],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="nonexistent-app" />)
|
||||
expect(screen.getByText(/404/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error States', () => {
|
||||
it('should render error when app params fails to load', () => {
|
||||
const error = new Error('Failed to load app params')
|
||||
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Failed to load app params/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render error when app meta fails to load', () => {
|
||||
const error = new Error('Failed to load app meta')
|
||||
;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Failed to load app meta/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render error when web app access mode fails to load', () => {
|
||||
const error = new Error('Failed to load access mode')
|
||||
;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Failed to load access mode/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render error when user access check fails', () => {
|
||||
const error = new Error('Failed to check user access')
|
||||
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
|
||||
data: null,
|
||||
error,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/Failed to check user access/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render no permission (403) when user cannot access app', () => {
|
||||
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
|
||||
data: { result: false },
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/403/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/no permission/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App Mode Rendering', () => {
|
||||
it('should render ChatWithHistory for CHAT mode', () => {
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ChatWithHistory for ADVANCED_CHAT mode', () => {
|
||||
const advancedChatApp = {
|
||||
...mockInstalledApp,
|
||||
app: {
|
||||
...mockInstalledApp.app,
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
},
|
||||
}
|
||||
;(useContext as jest.Mock).mockReturnValue({
|
||||
installedApps: [advancedChatApp],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ChatWithHistory for AGENT_CHAT mode', () => {
|
||||
const agentChatApp = {
|
||||
...mockInstalledApp,
|
||||
app: {
|
||||
...mockInstalledApp.app,
|
||||
mode: AppModeEnum.AGENT_CHAT,
|
||||
},
|
||||
}
|
||||
;(useContext as jest.Mock).mockReturnValue({
|
||||
installedApps: [agentChatApp],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render TextGenerationApp for COMPLETION mode', () => {
|
||||
const completionApp = {
|
||||
...mockInstalledApp,
|
||||
app: {
|
||||
...mockInstalledApp.app,
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
},
|
||||
}
|
||||
;(useContext as jest.Mock).mockReturnValue({
|
||||
installedApps: [completionApp],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Text Generation App/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Workflow/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render TextGenerationApp with workflow flag for WORKFLOW mode', () => {
|
||||
const workflowApp = {
|
||||
...mockInstalledApp,
|
||||
app: {
|
||||
...mockInstalledApp.app,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
}
|
||||
;(useContext as jest.Mock).mockReturnValue({
|
||||
installedApps: [workflowApp],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Workflow/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should use id prop to find installed app', () => {
|
||||
const app1 = { ...mockInstalledApp, id: 'app-1' }
|
||||
const app2 = { ...mockInstalledApp, id: 'app-2' }
|
||||
;(useContext as jest.Mock).mockReturnValue({
|
||||
installedApps: [app1, app2],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="app-2" />)
|
||||
expect(screen.getByText(/app-2/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle id that does not match any installed app', () => {
|
||||
render(<InstalledApp id="nonexistent-id" />)
|
||||
expect(screen.getByText(/404/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Effects', () => {
|
||||
it('should update app info when installedApp is available', async () => {
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAppInfo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
app_id: 'installed-app-123',
|
||||
site: expect.objectContaining({
|
||||
title: 'Test App',
|
||||
icon_type: 'emoji',
|
||||
icon: '🚀',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_url: '',
|
||||
prompt_public: false,
|
||||
copyright: '',
|
||||
show_workflow_steps: true,
|
||||
use_icon_as_answer_icon: false,
|
||||
}),
|
||||
plan: 'basic',
|
||||
custom_config: null,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update app info to null when installedApp is not found', async () => {
|
||||
;(useContext as jest.Mock).mockReturnValue({
|
||||
installedApps: [],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="nonexistent-app" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAppInfo).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update app params when data is available', async () => {
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update app meta when data is available', async () => {
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateWebAppMeta).toHaveBeenCalledWith(mockAppMeta)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update web app access mode when data is available', async () => {
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateWebAppAccessMode).toHaveBeenCalledWith(AccessMode.PUBLIC)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update user can access app when data is available', async () => {
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update user can access app to false when result is false', async () => {
|
||||
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
|
||||
data: { result: false },
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update user can access app to false when data is null', async () => {
|
||||
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not update app params when data is null', async () => {
|
||||
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAppInfo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(mockUpdateAppParams).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not update app meta when data is null', async () => {
|
||||
;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAppInfo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(mockUpdateWebAppMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not update access mode when data is null', async () => {
|
||||
;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAppInfo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(mockUpdateWebAppAccessMode).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty installedApps array', () => {
|
||||
;(useContext as jest.Mock).mockReturnValue({
|
||||
installedApps: [],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/404/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple installed apps and find the correct one', () => {
|
||||
const otherApp = {
|
||||
...mockInstalledApp,
|
||||
id: 'other-app-id',
|
||||
app: {
|
||||
...mockInstalledApp.app,
|
||||
name: 'Other App',
|
||||
},
|
||||
}
|
||||
;(useContext as jest.Mock).mockReturnValue({
|
||||
installedApps: [otherApp, mockInstalledApp],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
// Should find and render the correct app
|
||||
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
|
||||
expect(screen.getByText(/installed-app-123/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply correct CSS classes to container', () => {
|
||||
const { container } = render(<InstalledApp id="installed-app-123" />)
|
||||
const mainDiv = container.firstChild as HTMLElement
|
||||
expect(mainDiv).toHaveClass('h-full', 'bg-background-default', 'py-2', 'pl-0', 'pr-2', 'sm:p-2')
|
||||
})
|
||||
|
||||
it('should apply correct CSS classes to ChatWithHistory', () => {
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
const chatComponent = screen.getByTestId('chat-with-history')
|
||||
expect(chatComponent).toHaveClass('overflow-hidden', 'rounded-2xl', 'shadow-md')
|
||||
})
|
||||
|
||||
it('should handle rapid id prop changes', async () => {
|
||||
const app1 = { ...mockInstalledApp, id: 'app-1' }
|
||||
const app2 = { ...mockInstalledApp, id: 'app-2' }
|
||||
;(useContext as jest.Mock).mockReturnValue({
|
||||
installedApps: [app1, app2],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
const { rerender } = render(<InstalledApp id="app-1" />)
|
||||
expect(screen.getByText(/app-1/)).toBeInTheDocument()
|
||||
|
||||
rerender(<InstalledApp id="app-2" />)
|
||||
expect(screen.getByText(/app-2/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call service hooks with correct appId', () => {
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
expect(useGetInstalledAppAccessModeByAppId).toHaveBeenCalledWith('installed-app-123')
|
||||
expect(useGetInstalledAppParams).toHaveBeenCalledWith('installed-app-123')
|
||||
expect(useGetInstalledAppMeta).toHaveBeenCalledWith('installed-app-123')
|
||||
expect(useGetUserCanAccessApp).toHaveBeenCalledWith({
|
||||
appId: 'app-123',
|
||||
isInstalledApp: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call service hooks with null when installedApp is not found', () => {
|
||||
;(useContext as jest.Mock).mockReturnValue({
|
||||
installedApps: [],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="nonexistent-app" />)
|
||||
|
||||
expect(useGetInstalledAppAccessModeByAppId).toHaveBeenCalledWith(null)
|
||||
expect(useGetInstalledAppParams).toHaveBeenCalledWith(null)
|
||||
expect(useGetInstalledAppMeta).toHaveBeenCalledWith(null)
|
||||
expect(useGetUserCanAccessApp).toHaveBeenCalledWith({
|
||||
appId: undefined,
|
||||
isInstalledApp: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
// React.memo wraps the component with a special $$typeof symbol
|
||||
const componentType = (InstalledApp as React.MemoExoticComponent<typeof InstalledApp>).$$typeof
|
||||
expect(componentType).toBeDefined()
|
||||
})
|
||||
|
||||
it('should re-render when props change', () => {
|
||||
const { rerender } = render(<InstalledApp id="installed-app-123" />)
|
||||
expect(screen.getByText(/installed-app-123/)).toBeInTheDocument()
|
||||
|
||||
// Change to a different app
|
||||
const differentApp = {
|
||||
...mockInstalledApp,
|
||||
id: 'different-app-456',
|
||||
app: {
|
||||
...mockInstalledApp.app,
|
||||
name: 'Different App',
|
||||
},
|
||||
}
|
||||
;(useContext as jest.Mock).mockReturnValue({
|
||||
installedApps: [differentApp],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
rerender(<InstalledApp id="different-app-456" />)
|
||||
expect(screen.getByText(/different-app-456/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should maintain component stability across re-renders with same props', () => {
|
||||
const { rerender } = render(<InstalledApp id="installed-app-123" />)
|
||||
const initialCallCount = mockUpdateAppInfo.mock.calls.length
|
||||
|
||||
// Rerender with same props - useEffect may still run due to dependencies
|
||||
rerender(<InstalledApp id="installed-app-123" />)
|
||||
|
||||
// Component should render successfully
|
||||
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
|
||||
|
||||
// Mock calls might increase due to useEffect, but component should be stable
|
||||
expect(mockUpdateAppInfo.mock.calls.length).toBeGreaterThanOrEqual(initialCallCount)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Render Priority', () => {
|
||||
it('should show error before loading state', () => {
|
||||
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
|
||||
isFetching: true,
|
||||
data: null,
|
||||
error: new Error('Some error'),
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
// Error should take precedence over loading
|
||||
expect(screen.getByText(/Some error/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error before permission check', () => {
|
||||
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: null,
|
||||
error: new Error('Params error'),
|
||||
})
|
||||
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
|
||||
data: { result: false },
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
// Error should take precedence over permission
|
||||
expect(screen.getByText(/Params error/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/403/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show permission error before 404', () => {
|
||||
;(useContext as jest.Mock).mockReturnValue({
|
||||
installedApps: [],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
|
||||
data: { result: false },
|
||||
error: null,
|
||||
})
|
||||
|
||||
render(<InstalledApp id="nonexistent-app" />)
|
||||
// Permission should take precedence over 404
|
||||
expect(screen.getByText(/403/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading before 404', () => {
|
||||
;(useContext as jest.Mock).mockReturnValue({
|
||||
installedApps: [],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
|
||||
isFetching: true,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { container } = render(<InstalledApp id="nonexistent-app" />)
|
||||
// Loading should take precedence over 404
|
||||
const svg = container.querySelector('svg.spin-animation')
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user