= {}): ModelParameterRule => ({
+ name: 'temperature',
+ label: { en_US: 'Temperature', zh_Hans: '温度' },
+ type: 'float',
+ default: 0.7,
+ min: 0,
+ max: 2,
+ precision: 2,
+ required: false,
+ ...overrides,
+})
+
+/**
+ * Factory function to create default props
+ */
+const createDefaultProps = (overrides: Partial<{
+ isAdvancedMode: boolean
+ provider: string
+ modelId: string
+ completionParams: FormValue
+ onCompletionParamsChange: (newParams: FormValue) => void
+}> = {}) => ({
+ isAdvancedMode: false,
+ provider: 'langgenius/openai/openai',
+ modelId: 'gpt-4',
+ completionParams: {},
+ onCompletionParamsChange: vi.fn(),
+ ...overrides,
+})
+
+/**
+ * Setup mock for useModelParameterRules
+ */
+const setupModelParameterRulesMock = (config: {
+ data?: ModelParameterRule[]
+ isPending?: boolean
+} = {}) => {
+ mockUseModelParameterRules.mockReturnValue({
+ data: config.data ? { data: config.data } : undefined,
+ isPending: config.isPending ?? false,
+ })
+}
+
+// ==================== Tests ====================
+
+describe('LLMParamsPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ setupModelParameterRulesMock({ data: [], isPending: false })
+ })
+
+ // ==================== Rendering Tests ====================
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ expect(container).toBeInTheDocument()
+ })
+
+ it('should render loading state when isPending is true', () => {
+ // Arrange
+ setupModelParameterRulesMock({ isPending: true })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert - Loading component uses aria-label instead of visible text
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
+
+ it('should render parameters header', () => {
+ // Arrange
+ setupModelParameterRulesMock({ data: [], isPending: false })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('common.modelProvider.parameters')).toBeInTheDocument()
+ })
+
+ it('should render PresetsParameter for openai provider', () => {
+ // Arrange
+ setupModelParameterRulesMock({ data: [], isPending: false })
+ const props = createDefaultProps({ provider: 'langgenius/openai/openai' })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('presets-parameter')).toBeInTheDocument()
+ })
+
+ it('should render PresetsParameter for azure_openai provider', () => {
+ // Arrange
+ setupModelParameterRulesMock({ data: [], isPending: false })
+ const props = createDefaultProps({ provider: 'langgenius/azure_openai/azure_openai' })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('presets-parameter')).toBeInTheDocument()
+ })
+
+ it('should not render PresetsParameter for non-preset providers', () => {
+ // Arrange
+ setupModelParameterRulesMock({ data: [], isPending: false })
+ const props = createDefaultProps({ provider: 'anthropic/claude' })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByTestId('presets-parameter')).not.toBeInTheDocument()
+ })
+
+ it('should render parameter items when rules are available', () => {
+ // Arrange
+ const rules = [
+ createParameterRule({ name: 'temperature' }),
+ createParameterRule({ name: 'top_p', label: { en_US: 'Top P', zh_Hans: 'Top P' } }),
+ ]
+ setupModelParameterRulesMock({ data: rules, isPending: false })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
+ expect(screen.getByTestId('parameter-item-top_p')).toBeInTheDocument()
+ })
+
+ it('should not render parameter items when rules are empty', () => {
+ // Arrange
+ setupModelParameterRulesMock({ data: [], isPending: false })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByTestId('parameter-item-temperature')).not.toBeInTheDocument()
+ })
+
+ it('should include stop parameter rule in advanced mode', () => {
+ // Arrange
+ const rules = [createParameterRule({ name: 'temperature' })]
+ setupModelParameterRulesMock({ data: rules, isPending: false })
+ const props = createDefaultProps({ isAdvancedMode: true })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
+ expect(screen.getByTestId('parameter-item-stop')).toBeInTheDocument()
+ })
+
+ it('should not include stop parameter rule in non-advanced mode', () => {
+ // Arrange
+ const rules = [createParameterRule({ name: 'temperature' })]
+ setupModelParameterRulesMock({ data: rules, isPending: false })
+ const props = createDefaultProps({ isAdvancedMode: false })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
+ expect(screen.queryByTestId('parameter-item-stop')).not.toBeInTheDocument()
+ })
+
+ it('should pass isInWorkflow=true to ParameterItem', () => {
+ // Arrange
+ const rules = [createParameterRule({ name: 'temperature' })]
+ setupModelParameterRulesMock({ data: rules, isPending: false })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('parameter-item-temperature')).toHaveAttribute('data-is-in-workflow', 'true')
+ })
+ })
+
+ // ==================== Props Testing ====================
+ describe('Props', () => {
+ it('should call useModelParameterRules with provider and modelId', () => {
+ // Arrange
+ const props = createDefaultProps({
+ provider: 'test-provider',
+ modelId: 'test-model',
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(mockUseModelParameterRules).toHaveBeenCalledWith('test-provider', 'test-model')
+ })
+
+ it('should pass completion params value to ParameterItem', () => {
+ // Arrange
+ const rules = [createParameterRule({ name: 'temperature' })]
+ setupModelParameterRulesMock({ data: rules, isPending: false })
+ const props = createDefaultProps({
+ completionParams: { temperature: 0.8 },
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('parameter-item-temperature')).toHaveAttribute('data-value', '0.8')
+ })
+
+ it('should handle undefined completion params value', () => {
+ // Arrange
+ const rules = [createParameterRule({ name: 'temperature' })]
+ setupModelParameterRulesMock({ data: rules, isPending: false })
+ const props = createDefaultProps({
+ completionParams: {},
+ })
+
+ // Act
+ render()
+
+ // Assert - when value is undefined, JSON.stringify returns undefined string
+ expect(screen.getByTestId('parameter-item-temperature')).not.toHaveAttribute('data-value')
+ })
+ })
+
+ // ==================== Event Handlers ====================
+ describe('Event Handlers', () => {
+ describe('handleSelectPresetParameter', () => {
+ it('should apply Creative preset config', () => {
+ // Arrange
+ const onCompletionParamsChange = vi.fn()
+ setupModelParameterRulesMock({ data: [], isPending: false })
+ const props = createDefaultProps({
+ provider: 'langgenius/openai/openai',
+ onCompletionParamsChange,
+ completionParams: { existing: 'value' },
+ })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByTestId('preset-creative'))
+
+ // Assert
+ expect(onCompletionParamsChange).toHaveBeenCalledWith({
+ existing: 'value',
+ temperature: 0.8,
+ top_p: 0.9,
+ presence_penalty: 0.1,
+ frequency_penalty: 0.1,
+ })
+ })
+
+ it('should apply Balanced preset config', () => {
+ // Arrange
+ const onCompletionParamsChange = vi.fn()
+ setupModelParameterRulesMock({ data: [], isPending: false })
+ const props = createDefaultProps({
+ provider: 'langgenius/openai/openai',
+ onCompletionParamsChange,
+ completionParams: {},
+ })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByTestId('preset-balanced'))
+
+ // Assert
+ expect(onCompletionParamsChange).toHaveBeenCalledWith({
+ temperature: 0.5,
+ top_p: 0.85,
+ presence_penalty: 0.2,
+ frequency_penalty: 0.3,
+ })
+ })
+
+ it('should apply Precise preset config', () => {
+ // Arrange
+ const onCompletionParamsChange = vi.fn()
+ setupModelParameterRulesMock({ data: [], isPending: false })
+ const props = createDefaultProps({
+ provider: 'langgenius/openai/openai',
+ onCompletionParamsChange,
+ completionParams: {},
+ })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByTestId('preset-precise'))
+
+ // Assert
+ expect(onCompletionParamsChange).toHaveBeenCalledWith({
+ temperature: 0.2,
+ top_p: 0.75,
+ presence_penalty: 0.5,
+ frequency_penalty: 0.5,
+ })
+ })
+
+ it('should apply empty config for Custom preset (spreads undefined)', () => {
+ // Arrange
+ const onCompletionParamsChange = vi.fn()
+ setupModelParameterRulesMock({ data: [], isPending: false })
+ const props = createDefaultProps({
+ provider: 'langgenius/openai/openai',
+ onCompletionParamsChange,
+ completionParams: { existing: 'value' },
+ })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByTestId('preset-custom'))
+
+ // Assert - Custom preset has no config, so only existing params are kept
+ expect(onCompletionParamsChange).toHaveBeenCalledWith({ existing: 'value' })
+ })
+ })
+
+ describe('handleParamChange', () => {
+ it('should call onCompletionParamsChange with updated param', () => {
+ // Arrange
+ const onCompletionParamsChange = vi.fn()
+ const rules = [createParameterRule({ name: 'temperature' })]
+ setupModelParameterRulesMock({ data: rules, isPending: false })
+ const props = createDefaultProps({
+ onCompletionParamsChange,
+ completionParams: { existing: 'value' },
+ })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByTestId('change-temperature'))
+
+ // Assert
+ expect(onCompletionParamsChange).toHaveBeenCalledWith({
+ existing: 'value',
+ temperature: 0.5,
+ })
+ })
+
+ it('should override existing param value', () => {
+ // Arrange
+ const onCompletionParamsChange = vi.fn()
+ const rules = [createParameterRule({ name: 'temperature' })]
+ setupModelParameterRulesMock({ data: rules, isPending: false })
+ const props = createDefaultProps({
+ onCompletionParamsChange,
+ completionParams: { temperature: 0.9 },
+ })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByTestId('change-temperature'))
+
+ // Assert
+ expect(onCompletionParamsChange).toHaveBeenCalledWith({
+ temperature: 0.5,
+ })
+ })
+ })
+
+ describe('handleSwitch', () => {
+ it('should add param when switch is turned on', () => {
+ // Arrange
+ const onCompletionParamsChange = vi.fn()
+ const rules = [createParameterRule({ name: 'temperature', default: 0.7 })]
+ setupModelParameterRulesMock({ data: rules, isPending: false })
+ const props = createDefaultProps({
+ onCompletionParamsChange,
+ completionParams: { existing: 'value' },
+ })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByTestId('switch-on-temperature'))
+
+ // Assert
+ expect(onCompletionParamsChange).toHaveBeenCalledWith({
+ existing: 'value',
+ temperature: 0.7,
+ })
+ })
+
+ it('should remove param when switch is turned off', () => {
+ // Arrange
+ const onCompletionParamsChange = vi.fn()
+ const rules = [createParameterRule({ name: 'temperature' })]
+ setupModelParameterRulesMock({ data: rules, isPending: false })
+ const props = createDefaultProps({
+ onCompletionParamsChange,
+ completionParams: { temperature: 0.8, other: 'value' },
+ })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByTestId('switch-off-temperature'))
+
+ // Assert
+ expect(onCompletionParamsChange).toHaveBeenCalledWith({
+ other: 'value',
+ })
+ })
+ })
+ })
+
+ // ==================== Memoization ====================
+ describe('Memoization - parameterRules', () => {
+ it('should return empty array when data is undefined', () => {
+ // Arrange
+ mockUseModelParameterRules.mockReturnValue({
+ data: undefined,
+ isPending: false,
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert - no parameter items should be rendered
+ expect(screen.queryByTestId(/parameter-item-/)).not.toBeInTheDocument()
+ })
+
+ it('should return empty array when data.data is undefined', () => {
+ // Arrange
+ mockUseModelParameterRules.mockReturnValue({
+ data: { data: undefined },
+ isPending: false,
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByTestId(/parameter-item-/)).not.toBeInTheDocument()
+ })
+
+ it('should use data.data when available', () => {
+ // Arrange
+ const rules = [
+ createParameterRule({ name: 'temperature' }),
+ createParameterRule({ name: 'top_p' }),
+ ]
+ setupModelParameterRulesMock({ data: rules, isPending: false })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
+ expect(screen.getByTestId('parameter-item-top_p')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Edge Cases ====================
+ describe('Edge Cases', () => {
+ it('should handle empty completionParams', () => {
+ // Arrange
+ const rules = [createParameterRule({ name: 'temperature' })]
+ setupModelParameterRulesMock({ data: rules, isPending: false })
+ const props = createDefaultProps({ completionParams: {} })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
+ })
+
+ it('should handle multiple parameter rules', () => {
+ // Arrange
+ const rules = [
+ createParameterRule({ name: 'temperature' }),
+ createParameterRule({ name: 'top_p' }),
+ createParameterRule({ name: 'max_tokens', type: 'int' }),
+ createParameterRule({ name: 'presence_penalty' }),
+ ]
+ setupModelParameterRulesMock({ data: rules, isPending: false })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
+ expect(screen.getByTestId('parameter-item-top_p')).toBeInTheDocument()
+ expect(screen.getByTestId('parameter-item-max_tokens')).toBeInTheDocument()
+ expect(screen.getByTestId('parameter-item-presence_penalty')).toBeInTheDocument()
+ })
+
+ it('should use unique keys for parameter items based on modelId and name', () => {
+ // Arrange
+ const rules = [
+ createParameterRule({ name: 'temperature' }),
+ createParameterRule({ name: 'top_p' }),
+ ]
+ setupModelParameterRulesMock({ data: rules, isPending: false })
+ const props = createDefaultProps({ modelId: 'gpt-4' })
+
+ // Act
+ const { container } = render()
+
+ // Assert - verify both items are rendered (keys are internal but rendering proves uniqueness)
+ const items = container.querySelectorAll('[data-testid^="parameter-item-"]')
+ expect(items).toHaveLength(2)
+ })
+ })
+
+ // ==================== Re-render Behavior ====================
+ describe('Re-render Behavior', () => {
+ it('should update parameter items when rules change', () => {
+ // Arrange
+ const initialRules = [createParameterRule({ name: 'temperature' })]
+ setupModelParameterRulesMock({ data: initialRules, isPending: false })
+ const props = createDefaultProps()
+
+ // Act
+ const { rerender } = render()
+ expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
+ expect(screen.queryByTestId('parameter-item-top_p')).not.toBeInTheDocument()
+
+ // Update mock
+ const newRules = [
+ createParameterRule({ name: 'temperature' }),
+ createParameterRule({ name: 'top_p' }),
+ ]
+ setupModelParameterRulesMock({ data: newRules, isPending: false })
+ rerender()
+
+ // Assert
+ expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
+ expect(screen.getByTestId('parameter-item-top_p')).toBeInTheDocument()
+ })
+
+ it('should show loading when transitioning from loaded to loading', () => {
+ // Arrange
+ const rules = [createParameterRule({ name: 'temperature' })]
+ setupModelParameterRulesMock({ data: rules, isPending: false })
+ const props = createDefaultProps()
+
+ // Act
+ const { rerender } = render()
+ expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
+
+ // Update to loading
+ setupModelParameterRulesMock({ isPending: true })
+ rerender()
+
+ // Assert - Loading component uses role="status" with aria-label
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
+
+ it('should update when isAdvancedMode changes', () => {
+ // Arrange
+ const rules = [createParameterRule({ name: 'temperature' })]
+ setupModelParameterRulesMock({ data: rules, isPending: false })
+ const props = createDefaultProps({ isAdvancedMode: false })
+
+ // Act
+ const { rerender } = render()
+ expect(screen.queryByTestId('parameter-item-stop')).not.toBeInTheDocument()
+
+ rerender()
+
+ // Assert
+ expect(screen.getByTestId('parameter-item-stop')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Component Type ====================
+ describe('Component Type', () => {
+ it('should be a functional component', () => {
+ // Assert
+ expect(typeof LLMParamsPanel).toBe('function')
+ })
+
+ it('should accept all required props', () => {
+ // Arrange
+ setupModelParameterRulesMock({ data: [], isPending: false })
+ const props = createDefaultProps()
+
+ // Act & Assert
+ expect(() => render()).not.toThrow()
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.spec.tsx
new file mode 100644
index 0000000000..304bd563f7
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.spec.tsx
@@ -0,0 +1,623 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// Import component after mocks
+import TTSParamsPanel from './tts-params-panel'
+
+// ==================== Mock Setup ====================
+// All vi.mock() calls are hoisted, so inline all mock data
+
+// Mock languages data with inline definition
+vi.mock('@/i18n-config/language', () => ({
+ languages: [
+ { value: 'en-US', name: 'English (United States)', supported: true },
+ { value: 'zh-Hans', name: '简体中文', supported: true },
+ { value: 'ja-JP', name: '日本語', supported: true },
+ { value: 'unsupported-lang', name: 'Unsupported Language', supported: false },
+ ],
+}))
+
+// Mock PortalSelect component
+vi.mock('@/app/components/base/select', () => ({
+ PortalSelect: ({
+ value,
+ items,
+ onSelect,
+ triggerClassName,
+ popupClassName,
+ popupInnerClassName,
+ }: {
+ value: string
+ items: Array<{ value: string, name: string }>
+ onSelect: (item: { value: string }) => void
+ triggerClassName?: string
+ popupClassName?: string
+ popupInnerClassName?: string
+ }) => (
+
+
{value}
+
+ {items.map(item => (
+
+ ))}
+
+
+ ),
+}))
+
+// ==================== Test Utilities ====================
+
+/**
+ * Factory function to create a voice item
+ */
+const createVoiceItem = (overrides: Partial<{ mode: string, name: string }> = {}) => ({
+ mode: 'alloy',
+ name: 'Alloy',
+ ...overrides,
+})
+
+/**
+ * Factory function to create a currentModel with voices
+ */
+const createCurrentModel = (voices: Array<{ mode: string, name: string }> = []) => ({
+ model_properties: {
+ voices,
+ },
+})
+
+/**
+ * Factory function to create default props
+ */
+const createDefaultProps = (overrides: Partial<{
+ currentModel: { model_properties: { voices: Array<{ mode: string, name: string }> } } | null
+ language: string
+ voice: string
+ onChange: (language: string, voice: string) => void
+}> = {}) => ({
+ currentModel: createCurrentModel([
+ createVoiceItem({ mode: 'alloy', name: 'Alloy' }),
+ createVoiceItem({ mode: 'echo', name: 'Echo' }),
+ createVoiceItem({ mode: 'fable', name: 'Fable' }),
+ ]),
+ language: 'en-US',
+ voice: 'alloy',
+ onChange: vi.fn(),
+ ...overrides,
+})
+
+// ==================== Tests ====================
+
+describe('TTSParamsPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ // ==================== Rendering Tests ====================
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ expect(container).toBeInTheDocument()
+ })
+
+ it('should render language label', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('appDebug.voice.voiceSettings.language')).toBeInTheDocument()
+ })
+
+ it('should render voice label', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByText('appDebug.voice.voiceSettings.voice')).toBeInTheDocument()
+ })
+
+ it('should render two PortalSelect components', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ const selects = screen.getAllByTestId('portal-select')
+ expect(selects).toHaveLength(2)
+ })
+
+ it('should render language select with correct value', () => {
+ // Arrange
+ const props = createDefaultProps({ language: 'zh-Hans' })
+
+ // Act
+ render()
+
+ // Assert
+ const selects = screen.getAllByTestId('portal-select')
+ expect(selects[0]).toHaveAttribute('data-value', 'zh-Hans')
+ })
+
+ it('should render voice select with correct value', () => {
+ // Arrange
+ const props = createDefaultProps({ voice: 'echo' })
+
+ // Act
+ render()
+
+ // Assert
+ const selects = screen.getAllByTestId('portal-select')
+ expect(selects[1]).toHaveAttribute('data-value', 'echo')
+ })
+
+ it('should only show supported languages in language select', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('select-item-en-US')).toBeInTheDocument()
+ expect(screen.getByTestId('select-item-zh-Hans')).toBeInTheDocument()
+ expect(screen.getByTestId('select-item-ja-JP')).toBeInTheDocument()
+ expect(screen.queryByTestId('select-item-unsupported-lang')).not.toBeInTheDocument()
+ })
+
+ it('should render voice items from currentModel', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('select-item-alloy')).toBeInTheDocument()
+ expect(screen.getByTestId('select-item-echo')).toBeInTheDocument()
+ expect(screen.getByTestId('select-item-fable')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Props Testing ====================
+ describe('Props', () => {
+ it('should apply trigger className to PortalSelect', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ const selects = screen.getAllByTestId('portal-select')
+ expect(selects[0]).toHaveAttribute('data-trigger-class', 'h-8')
+ expect(selects[1]).toHaveAttribute('data-trigger-class', 'h-8')
+ })
+
+ it('should apply popup className to PortalSelect', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ const selects = screen.getAllByTestId('portal-select')
+ expect(selects[0]).toHaveAttribute('data-popup-class', 'z-[1000]')
+ expect(selects[1]).toHaveAttribute('data-popup-class', 'z-[1000]')
+ })
+
+ it('should apply popup inner className to PortalSelect', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ const selects = screen.getAllByTestId('portal-select')
+ expect(selects[0]).toHaveAttribute('data-popup-inner-class', 'w-[354px]')
+ expect(selects[1]).toHaveAttribute('data-popup-inner-class', 'w-[354px]')
+ })
+ })
+
+ // ==================== Event Handlers ====================
+ describe('Event Handlers', () => {
+ describe('setLanguage', () => {
+ it('should call onChange with new language and current voice', () => {
+ // Arrange
+ const onChange = vi.fn()
+ const props = createDefaultProps({
+ onChange,
+ language: 'en-US',
+ voice: 'alloy',
+ })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByTestId('select-item-zh-Hans'))
+
+ // Assert
+ expect(onChange).toHaveBeenCalledWith('zh-Hans', 'alloy')
+ })
+
+ it('should call onChange with different languages', () => {
+ // Arrange
+ const onChange = vi.fn()
+ const props = createDefaultProps({
+ onChange,
+ language: 'en-US',
+ voice: 'echo',
+ })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByTestId('select-item-ja-JP'))
+
+ // Assert
+ expect(onChange).toHaveBeenCalledWith('ja-JP', 'echo')
+ })
+
+ it('should preserve voice when changing language', () => {
+ // Arrange
+ const onChange = vi.fn()
+ const props = createDefaultProps({
+ onChange,
+ language: 'en-US',
+ voice: 'fable',
+ })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByTestId('select-item-zh-Hans'))
+
+ // Assert
+ expect(onChange).toHaveBeenCalledWith('zh-Hans', 'fable')
+ })
+ })
+
+ describe('setVoice', () => {
+ it('should call onChange with current language and new voice', () => {
+ // Arrange
+ const onChange = vi.fn()
+ const props = createDefaultProps({
+ onChange,
+ language: 'en-US',
+ voice: 'alloy',
+ })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByTestId('select-item-echo'))
+
+ // Assert
+ expect(onChange).toHaveBeenCalledWith('en-US', 'echo')
+ })
+
+ it('should call onChange with different voices', () => {
+ // Arrange
+ const onChange = vi.fn()
+ const props = createDefaultProps({
+ onChange,
+ language: 'zh-Hans',
+ voice: 'alloy',
+ })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByTestId('select-item-fable'))
+
+ // Assert
+ expect(onChange).toHaveBeenCalledWith('zh-Hans', 'fable')
+ })
+
+ it('should preserve language when changing voice', () => {
+ // Arrange
+ const onChange = vi.fn()
+ const props = createDefaultProps({
+ onChange,
+ language: 'ja-JP',
+ voice: 'alloy',
+ })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByTestId('select-item-echo'))
+
+ // Assert
+ expect(onChange).toHaveBeenCalledWith('ja-JP', 'echo')
+ })
+ })
+ })
+
+ // ==================== Memoization ====================
+ describe('Memoization - voiceList', () => {
+ it('should return empty array when currentModel is null', () => {
+ // Arrange
+ const props = createDefaultProps({ currentModel: null })
+
+ // Act
+ render()
+
+ // Assert - no voice items should be rendered
+ expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('select-item-echo')).not.toBeInTheDocument()
+ })
+
+ it('should return empty array when currentModel is undefined', () => {
+ // Arrange
+ const props = {
+ currentModel: undefined,
+ language: 'en-US',
+ voice: 'alloy',
+ onChange: vi.fn(),
+ }
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument()
+ })
+
+ it('should map voices with mode as value', () => {
+ // Arrange
+ const props = createDefaultProps({
+ currentModel: createCurrentModel([
+ { mode: 'voice-1', name: 'Voice One' },
+ { mode: 'voice-2', name: 'Voice Two' },
+ ]),
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('select-item-voice-1')).toBeInTheDocument()
+ expect(screen.getByTestId('select-item-voice-2')).toBeInTheDocument()
+ })
+
+ it('should handle currentModel with empty voices array', () => {
+ // Arrange
+ const props = createDefaultProps({
+ currentModel: createCurrentModel([]),
+ })
+
+ // Act
+ render()
+
+ // Assert - no voice items (except language items)
+ const voiceSelects = screen.getAllByTestId('portal-select')
+ // Second select is voice select, should have no voice items in items-container
+ const voiceItemsContainer = voiceSelects[1].querySelector('[data-testid="items-container"]')
+ expect(voiceItemsContainer?.children).toHaveLength(0)
+ })
+
+ it('should handle currentModel with single voice', () => {
+ // Arrange
+ const props = createDefaultProps({
+ currentModel: createCurrentModel([
+ { mode: 'single-voice', name: 'Single Voice' },
+ ]),
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('select-item-single-voice')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Edge Cases ====================
+ describe('Edge Cases', () => {
+ it('should handle empty language value', () => {
+ // Arrange
+ const props = createDefaultProps({ language: '' })
+
+ // Act
+ render()
+
+ // Assert
+ const selects = screen.getAllByTestId('portal-select')
+ expect(selects[0]).toHaveAttribute('data-value', '')
+ })
+
+ it('should handle empty voice value', () => {
+ // Arrange
+ const props = createDefaultProps({ voice: '' })
+
+ // Act
+ render()
+
+ // Assert
+ const selects = screen.getAllByTestId('portal-select')
+ expect(selects[1]).toHaveAttribute('data-value', '')
+ })
+
+ it('should handle many voices', () => {
+ // Arrange
+ const manyVoices = Array.from({ length: 20 }, (_, i) => ({
+ mode: `voice-${i}`,
+ name: `Voice ${i}`,
+ }))
+ const props = createDefaultProps({
+ currentModel: createCurrentModel(manyVoices),
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('select-item-voice-0')).toBeInTheDocument()
+ expect(screen.getByTestId('select-item-voice-19')).toBeInTheDocument()
+ })
+
+ it('should handle voice with special characters in mode', () => {
+ // Arrange
+ const props = createDefaultProps({
+ currentModel: createCurrentModel([
+ { mode: 'voice-with_special.chars', name: 'Special Voice' },
+ ]),
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('select-item-voice-with_special.chars')).toBeInTheDocument()
+ })
+
+ it('should handle onChange not being called multiple times', () => {
+ // Arrange
+ const onChange = vi.fn()
+ const props = createDefaultProps({ onChange })
+
+ // Act
+ render()
+ fireEvent.click(screen.getByTestId('select-item-echo'))
+
+ // Assert
+ expect(onChange).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ // ==================== Re-render Behavior ====================
+ describe('Re-render Behavior', () => {
+ it('should update when language prop changes', () => {
+ // Arrange
+ const props = createDefaultProps({ language: 'en-US' })
+
+ // Act
+ const { rerender } = render()
+ const selects = screen.getAllByTestId('portal-select')
+ expect(selects[0]).toHaveAttribute('data-value', 'en-US')
+
+ rerender()
+
+ // Assert
+ const updatedSelects = screen.getAllByTestId('portal-select')
+ expect(updatedSelects[0]).toHaveAttribute('data-value', 'zh-Hans')
+ })
+
+ it('should update when voice prop changes', () => {
+ // Arrange
+ const props = createDefaultProps({ voice: 'alloy' })
+
+ // Act
+ const { rerender } = render()
+ const selects = screen.getAllByTestId('portal-select')
+ expect(selects[1]).toHaveAttribute('data-value', 'alloy')
+
+ rerender()
+
+ // Assert
+ const updatedSelects = screen.getAllByTestId('portal-select')
+ expect(updatedSelects[1]).toHaveAttribute('data-value', 'echo')
+ })
+
+ it('should update voice list when currentModel changes', () => {
+ // Arrange
+ const initialModel = createCurrentModel([
+ { mode: 'alloy', name: 'Alloy' },
+ ])
+ const props = createDefaultProps({ currentModel: initialModel })
+
+ // Act
+ const { rerender } = render()
+ expect(screen.getByTestId('select-item-alloy')).toBeInTheDocument()
+ expect(screen.queryByTestId('select-item-nova')).not.toBeInTheDocument()
+
+ const newModel = createCurrentModel([
+ { mode: 'alloy', name: 'Alloy' },
+ { mode: 'nova', name: 'Nova' },
+ ])
+ rerender()
+
+ // Assert
+ expect(screen.getByTestId('select-item-alloy')).toBeInTheDocument()
+ expect(screen.getByTestId('select-item-nova')).toBeInTheDocument()
+ })
+
+ it('should handle currentModel becoming null', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ const { rerender } = render()
+ expect(screen.getByTestId('select-item-alloy')).toBeInTheDocument()
+
+ rerender()
+
+ // Assert
+ expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument()
+ })
+ })
+
+ // ==================== Component Type ====================
+ describe('Component Type', () => {
+ it('should be a functional component', () => {
+ // Assert
+ expect(typeof TTSParamsPanel).toBe('function')
+ })
+
+ it('should accept all required props', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act & Assert
+ expect(() => render()).not.toThrow()
+ })
+ })
+
+ // ==================== Accessibility ====================
+ describe('Accessibility', () => {
+ it('should have proper label structure for language select', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ const languageLabel = screen.getByText('appDebug.voice.voiceSettings.language')
+ expect(languageLabel).toHaveClass('system-sm-semibold')
+ })
+
+ it('should have proper label structure for voice select', () => {
+ // Arrange
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ const voiceLabel = screen.getByText('appDebug.voice.voiceSettings.voice')
+ expect(voiceLabel).toHaveClass('system-sm-semibold')
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.spec.tsx
new file mode 100644
index 0000000000..658c40c13c
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.spec.tsx
@@ -0,0 +1,1028 @@
+import type { Node } from 'reactflow'
+import type { ToolValue } from '@/app/components/workflow/block-selector/types'
+import type { NodeOutPutVar, ToolWithProvider } from '@/app/components/workflow/types'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// ==================== Imports (after mocks) ====================
+
+import MultipleToolSelector from './index'
+
+// ==================== Mock Setup ====================
+
+// Mock useAllMCPTools hook
+const mockMCPToolsData = vi.fn<() => ToolWithProvider[] | undefined>(() => undefined)
+vi.mock('@/service/use-tools', () => ({
+ useAllMCPTools: () => ({
+ data: mockMCPToolsData(),
+ }),
+}))
+
+// Track edit tool index for unique test IDs
+let editToolIndex = 0
+
+vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({
+ default: ({
+ value,
+ onSelect,
+ onSelectMultiple,
+ onDelete,
+ controlledState,
+ onControlledStateChange,
+ panelShowState,
+ onPanelShowStateChange,
+ isEdit,
+ supportEnableSwitch,
+ }: {
+ value?: ToolValue
+ onSelect: (tool: ToolValue) => void
+ onSelectMultiple?: (tools: ToolValue[]) => void
+ onDelete?: () => void
+ controlledState?: boolean
+ onControlledStateChange?: (state: boolean) => void
+ panelShowState?: boolean
+ onPanelShowStateChange?: (state: boolean) => void
+ isEdit?: boolean
+ supportEnableSwitch?: boolean
+ }) => {
+ if (isEdit) {
+ const currentIndex = editToolIndex++
+ return (
+
+ {value && (
+ <>
+ {value.tool_label}
+
+
+ {onSelectMultiple && (
+
+ )}
+ >
+ )}
+
+ )
+ }
+ else {
+ return (
+
+
+ {onSelectMultiple && (
+
+ )}
+
+ )
+ }
+ },
+}))
+
+// ==================== Test Utilities ====================
+
+const createQueryClient = () => new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+})
+
+const createToolValue = (overrides: Partial = {}): ToolValue => ({
+ provider_name: 'test-provider',
+ provider_show_name: 'Test Provider',
+ tool_name: 'test-tool',
+ tool_label: 'Test Tool',
+ tool_description: 'Test tool description',
+ settings: {},
+ parameters: {},
+ enabled: true,
+ extra: { description: 'Test description' },
+ ...overrides,
+})
+
+const createMCPTool = (overrides: Partial = {}): ToolWithProvider => ({
+ id: 'mcp-provider-1',
+ name: 'mcp-provider',
+ author: 'test-author',
+ type: 'mcp',
+ icon: 'test-icon.png',
+ label: { en_US: 'MCP Provider' } as any,
+ description: { en_US: 'MCP Provider description' } as any,
+ is_team_authorization: true,
+ allow_delete: false,
+ labels: [],
+ tools: [{
+ name: 'mcp-tool-1',
+ label: { en_US: 'MCP Tool 1' } as any,
+ description: { en_US: 'MCP Tool 1 description' } as any,
+ parameters: [],
+ output_schema: {},
+ }],
+ ...overrides,
+} as ToolWithProvider)
+
+const createNodeOutputVar = (overrides: Partial = {}): NodeOutPutVar => ({
+ nodeId: 'node-1',
+ title: 'Test Node',
+ vars: [],
+ ...overrides,
+})
+
+const createNode = (overrides: Partial = {}): Node => ({
+ id: 'node-1',
+ position: { x: 0, y: 0 },
+ data: { title: 'Test Node' },
+ ...overrides,
+})
+
+type RenderOptions = {
+ disabled?: boolean
+ value?: ToolValue[]
+ label?: string
+ required?: boolean
+ tooltip?: React.ReactNode
+ supportCollapse?: boolean
+ scope?: string
+ onChange?: (value: ToolValue[]) => void
+ nodeOutputVars?: NodeOutPutVar[]
+ availableNodes?: Node[]
+ nodeId?: string
+ canChooseMCPTool?: boolean
+}
+
+const renderComponent = (options: RenderOptions = {}) => {
+ const defaultProps = {
+ disabled: false,
+ value: [],
+ label: 'Tools',
+ required: false,
+ tooltip: undefined,
+ supportCollapse: false,
+ scope: undefined,
+ onChange: vi.fn(),
+ nodeOutputVars: [createNodeOutputVar()],
+ availableNodes: [createNode()],
+ nodeId: 'test-node-id',
+ canChooseMCPTool: false,
+ }
+
+ const props = { ...defaultProps, ...options }
+ const queryClient = createQueryClient()
+
+ return {
+ ...render(
+
+
+ ,
+ ),
+ props,
+ }
+}
+
+// ==================== Tests ====================
+
+describe('MultipleToolSelector', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockMCPToolsData.mockReturnValue(undefined)
+ editToolIndex = 0
+ })
+
+ // ==================== Rendering Tests ====================
+ describe('Rendering', () => {
+ it('should render with label', () => {
+ // Arrange & Act
+ renderComponent({ label: 'My Tools' })
+
+ // Assert
+ expect(screen.getByText('My Tools')).toBeInTheDocument()
+ })
+
+ it('should render required indicator when required is true', () => {
+ // Arrange & Act
+ renderComponent({ required: true })
+
+ // Assert
+ expect(screen.getByText('*')).toBeInTheDocument()
+ })
+
+ it('should not render required indicator when required is false', () => {
+ // Arrange & Act
+ renderComponent({ required: false })
+
+ // Assert
+ expect(screen.queryByText('*')).not.toBeInTheDocument()
+ })
+
+ it('should render empty state when no tools are selected', () => {
+ // Arrange & Act
+ renderComponent({ value: [] })
+
+ // Assert
+ expect(screen.getByText('plugin.detailPanel.toolSelector.empty')).toBeInTheDocument()
+ })
+
+ it('should render selected tools when value is provided', () => {
+ // Arrange
+ const tools = [
+ createToolValue({ tool_name: 'tool-1', tool_label: 'Tool 1' }),
+ createToolValue({ tool_name: 'tool-2', tool_label: 'Tool 2' }),
+ ]
+
+ // Act
+ renderComponent({ value: tools })
+
+ // Assert
+ const editSelectors = screen.getAllByTestId('tool-selector-edit')
+ expect(editSelectors).toHaveLength(2)
+ })
+
+ it('should render add button when not disabled', () => {
+ // Arrange & Act
+ const { container } = renderComponent({ disabled: false })
+
+ // Assert
+ const addButton = container.querySelector('[class*="mx-1"]')
+ expect(addButton).toBeInTheDocument()
+ })
+
+ it('should not render add button when disabled', () => {
+ // Arrange & Act
+ renderComponent({ disabled: true })
+
+ // Assert
+ const addSelectors = screen.queryAllByTestId('tool-selector-add')
+ // The add button should still be present but outside the disabled check
+ expect(addSelectors).toHaveLength(1)
+ })
+
+ it('should render tooltip when provided', () => {
+ // Arrange & Act
+ const { container } = renderComponent({ tooltip: 'This is a tooltip' })
+
+ // Assert - Tooltip icon should be present
+ const tooltipIcon = container.querySelector('svg')
+ expect(tooltipIcon).toBeInTheDocument()
+ })
+
+ it('should render enabled count when tools are selected', () => {
+ // Arrange
+ const tools = [
+ createToolValue({ tool_name: 'tool-1', enabled: true }),
+ createToolValue({ tool_name: 'tool-2', enabled: false }),
+ ]
+
+ // Act
+ renderComponent({ value: tools })
+
+ // Assert
+ expect(screen.getByText('1/2')).toBeInTheDocument()
+ expect(screen.getByText('appDebug.agent.tools.enabled')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Collapse Functionality Tests ====================
+ describe('Collapse Functionality', () => {
+ it('should render collapse arrow when supportCollapse is true', () => {
+ // Arrange & Act
+ const { container } = renderComponent({ supportCollapse: true })
+
+ // Assert
+ const collapseArrow = container.querySelector('svg[class*="cursor-pointer"]')
+ expect(collapseArrow).toBeInTheDocument()
+ })
+
+ it('should not render collapse arrow when supportCollapse is false', () => {
+ // Arrange & Act
+ const { container } = renderComponent({ supportCollapse: false })
+
+ // Assert
+ const collapseArrows = container.querySelectorAll('svg[class*="rotate"]')
+ expect(collapseArrows).toHaveLength(0)
+ })
+
+ it('should toggle collapse state when clicking header with supportCollapse enabled', () => {
+ // Arrange
+ const tools = [createToolValue()]
+ const { container } = renderComponent({ supportCollapse: true, value: tools })
+ const headerArea = container.querySelector('[class*="cursor-pointer"]')
+
+ // Act - Initially visible
+ expect(screen.getByTestId('tool-selector-edit')).toBeInTheDocument()
+
+ // Click to collapse
+ fireEvent.click(headerArea!)
+
+ // Assert - Should be collapsed
+ expect(screen.queryByTestId('tool-selector-edit')).not.toBeInTheDocument()
+ })
+
+ it('should not toggle collapse when supportCollapse is false', () => {
+ // Arrange
+ const tools = [createToolValue()]
+ renderComponent({ supportCollapse: false, value: tools })
+
+ // Act
+ fireEvent.click(screen.getByText('Tools'))
+
+ // Assert - Should still be visible
+ expect(screen.getByTestId('tool-selector-edit')).toBeInTheDocument()
+ })
+
+ it('should expand when add button is clicked while collapsed', async () => {
+ // Arrange
+ const tools = [createToolValue()]
+ const { container } = renderComponent({ supportCollapse: true, value: tools })
+ const headerArea = container.querySelector('[class*="cursor-pointer"]')
+
+ // Collapse first
+ fireEvent.click(headerArea!)
+ expect(screen.queryByTestId('tool-selector-edit')).not.toBeInTheDocument()
+
+ // Act - Click add button
+ const addButton = container.querySelector('button')
+ fireEvent.click(addButton!)
+
+ // Assert - Should be expanded
+ await waitFor(() => {
+ expect(screen.getByTestId('tool-selector-edit')).toBeInTheDocument()
+ })
+ })
+ })
+
+ // ==================== State Management Tests ====================
+ describe('State Management', () => {
+ it('should track enabled count correctly', () => {
+ // Arrange
+ const tools = [
+ createToolValue({ tool_name: 'tool-1', enabled: true }),
+ createToolValue({ tool_name: 'tool-2', enabled: true }),
+ createToolValue({ tool_name: 'tool-3', enabled: false }),
+ ]
+
+ // Act
+ renderComponent({ value: tools })
+
+ // Assert
+ expect(screen.getByText('2/3')).toBeInTheDocument()
+ })
+
+ it('should track enabled count with MCP tools when canChooseMCPTool is true', () => {
+ // Arrange
+ const mcpTools = [createMCPTool({ id: 'mcp-provider' })]
+ mockMCPToolsData.mockReturnValue(mcpTools)
+
+ const tools = [
+ createToolValue({ tool_name: 'tool-1', provider_name: 'regular-provider', enabled: true }),
+ createToolValue({ tool_name: 'mcp-tool', provider_name: 'mcp-provider', enabled: true }),
+ ]
+
+ // Act
+ renderComponent({ value: tools, canChooseMCPTool: true })
+
+ // Assert
+ expect(screen.getByText('2/2')).toBeInTheDocument()
+ })
+
+ it('should not count MCP tools when canChooseMCPTool is false', () => {
+ // Arrange
+ const mcpTools = [createMCPTool({ id: 'mcp-provider' })]
+ mockMCPToolsData.mockReturnValue(mcpTools)
+
+ const tools = [
+ createToolValue({ tool_name: 'tool-1', provider_name: 'regular-provider', enabled: true }),
+ createToolValue({ tool_name: 'mcp-tool', provider_name: 'mcp-provider', enabled: true }),
+ ]
+
+ // Act
+ renderComponent({ value: tools, canChooseMCPTool: false })
+
+ // Assert
+ expect(screen.getByText('1/2')).toBeInTheDocument()
+ })
+
+ it('should manage open state for add tool panel', () => {
+ // Arrange
+ const { container } = renderComponent()
+
+ // Initially closed
+ const addSelector = screen.getByTestId('tool-selector-add')
+ expect(addSelector).toHaveAttribute('data-controlled-state', 'false')
+
+ // Act - Click add button (ActionButton)
+ const actionButton = container.querySelector('[class*="mx-1"]')
+ fireEvent.click(actionButton!)
+
+ // Assert - Open state should change to true
+ expect(screen.getByTestId('tool-selector-add')).toHaveAttribute('data-controlled-state', 'true')
+ })
+ })
+
+ // ==================== User Interactions Tests ====================
+ describe('User Interactions', () => {
+ it('should call onChange when adding a new tool via add button', () => {
+ // Arrange
+ const onChange = vi.fn()
+ renderComponent({ onChange })
+
+ // Act - Click add tool button in add selector
+ fireEvent.click(screen.getByTestId('add-tool-btn'))
+
+ // Assert
+ expect(onChange).toHaveBeenCalledWith([
+ expect.objectContaining({ provider_name: 'new-provider', tool_name: 'new-tool' }),
+ ])
+ })
+
+ it('should call onChange when adding multiple tools', () => {
+ // Arrange
+ const onChange = vi.fn()
+ renderComponent({ onChange })
+
+ // Act - Click add multiple tools button
+ fireEvent.click(screen.getByTestId('add-multiple-tools-btn'))
+
+ // Assert
+ expect(onChange).toHaveBeenCalledWith([
+ expect.objectContaining({ provider_name: 'batch-p', tool_name: 'batch-t1' }),
+ expect.objectContaining({ provider_name: 'batch-p', tool_name: 'batch-t2' }),
+ ])
+ })
+
+ it('should deduplicate when adding duplicate tool', () => {
+ // Arrange
+ const existingTool = createToolValue({ tool_name: 'new-tool', provider_name: 'new-provider' })
+ const onChange = vi.fn()
+ renderComponent({ value: [existingTool], onChange })
+
+ // Act - Try to add the same tool
+ fireEvent.click(screen.getByTestId('add-tool-btn'))
+
+ // Assert - Should still have only 1 tool (deduplicated)
+ expect(onChange).toHaveBeenCalledWith([existingTool])
+ })
+
+ it('should call onChange when deleting a tool', () => {
+ // Arrange
+ const tools = [
+ createToolValue({ tool_name: 'tool-0', provider_name: 'p0' }),
+ createToolValue({ tool_name: 'tool-1', provider_name: 'p1' }),
+ ]
+ const onChange = vi.fn()
+ renderComponent({ value: tools, onChange })
+
+ // Act - Delete first tool (index 0)
+ fireEvent.click(screen.getByTestId('delete-btn-0'))
+
+ // Assert - Should have only second tool
+ expect(onChange).toHaveBeenCalledWith([
+ expect.objectContaining({ tool_name: 'tool-1', provider_name: 'p1' }),
+ ])
+ })
+
+ it('should call onChange when configuring a tool', () => {
+ // Arrange
+ const tools = [createToolValue({ tool_name: 'tool-1', enabled: true })]
+ const onChange = vi.fn()
+ renderComponent({ value: tools, onChange })
+
+ // Act - Click configure button to toggle enabled
+ fireEvent.click(screen.getByTestId('configure-btn-0'))
+
+ // Assert - Should update the tool at index 0
+ expect(onChange).toHaveBeenCalledWith([
+ expect.objectContaining({ tool_name: 'tool-1', enabled: false }),
+ ])
+ })
+
+ it('should call onChange with correct index when configuring second tool', () => {
+ // Arrange
+ const tools = [
+ createToolValue({ tool_name: 'tool-0', enabled: true }),
+ createToolValue({ tool_name: 'tool-1', enabled: true }),
+ ]
+ const onChange = vi.fn()
+ renderComponent({ value: tools, onChange })
+
+ // Act - Configure second tool (index 1)
+ fireEvent.click(screen.getByTestId('configure-btn-1'))
+
+ // Assert - Should update only the second tool
+ expect(onChange).toHaveBeenCalledWith([
+ expect.objectContaining({ tool_name: 'tool-0', enabled: true }),
+ expect.objectContaining({ tool_name: 'tool-1', enabled: false }),
+ ])
+ })
+
+ it('should call onChange with correct array when deleting middle tool', () => {
+ // Arrange
+ const tools = [
+ createToolValue({ tool_name: 'tool-0', provider_name: 'p0' }),
+ createToolValue({ tool_name: 'tool-1', provider_name: 'p1' }),
+ createToolValue({ tool_name: 'tool-2', provider_name: 'p2' }),
+ ]
+ const onChange = vi.fn()
+ renderComponent({ value: tools, onChange })
+
+ // Act - Delete middle tool (index 1)
+ fireEvent.click(screen.getByTestId('delete-btn-1'))
+
+ // Assert - Should have first and third tools
+ expect(onChange).toHaveBeenCalledWith([
+ expect.objectContaining({ tool_name: 'tool-0' }),
+ expect.objectContaining({ tool_name: 'tool-2' }),
+ ])
+ })
+
+ it('should handle add multiple from edit selector', () => {
+ // Arrange
+ const tools = [createToolValue({ tool_name: 'existing' })]
+ const onChange = vi.fn()
+ renderComponent({ value: tools, onChange })
+
+ // Act - Click add multiple from edit selector
+ fireEvent.click(screen.getByTestId('add-multiple-btn-0'))
+
+ // Assert - Should add batch tools with deduplication
+ expect(onChange).toHaveBeenCalled()
+ })
+ })
+
+ // ==================== Event Handlers Tests ====================
+ describe('Event Handlers', () => {
+ it('should handle add button click', () => {
+ // Arrange
+ const { container } = renderComponent()
+ const addButton = container.querySelector('button')
+
+ // Act
+ fireEvent.click(addButton!)
+
+ // Assert - Add tool panel should open
+ expect(screen.getByTestId('tool-selector-add')).toBeInTheDocument()
+ })
+
+ it('should handle collapse click with supportCollapse', () => {
+ // Arrange
+ const tools = [createToolValue()]
+ const { container } = renderComponent({ supportCollapse: true, value: tools })
+ const labelArea = container.querySelector('[class*="cursor-pointer"]')
+
+ // Act
+ fireEvent.click(labelArea!)
+
+ // Assert - Tools should be hidden
+ expect(screen.queryByTestId('tool-selector-edit')).not.toBeInTheDocument()
+
+ // Click again to expand
+ fireEvent.click(labelArea!)
+
+ // Assert - Tools should be visible again
+ expect(screen.getByTestId('tool-selector-edit')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Edge Cases Tests ====================
+ describe('Edge Cases', () => {
+ it('should handle empty value array', () => {
+ // Arrange & Act
+ renderComponent({ value: [] })
+
+ // Assert
+ expect(screen.getByText('plugin.detailPanel.toolSelector.empty')).toBeInTheDocument()
+ expect(screen.queryAllByTestId('tool-selector-edit')).toHaveLength(0)
+ })
+
+ it('should handle undefined value', () => {
+ // Arrange & Act - value defaults to [] in component
+ renderComponent({ value: undefined as any })
+
+ // Assert
+ expect(screen.getByText('plugin.detailPanel.toolSelector.empty')).toBeInTheDocument()
+ })
+
+ it('should handle null mcpTools data', () => {
+ // Arrange
+ mockMCPToolsData.mockReturnValue(undefined)
+ const tools = [createToolValue({ enabled: true })]
+
+ // Act
+ renderComponent({ value: tools })
+
+ // Assert - Should still render
+ expect(screen.getByText('1/1')).toBeInTheDocument()
+ })
+
+ it('should handle tools with missing enabled property', () => {
+ // Arrange
+ const tools = [
+ { ...createToolValue(), enabled: undefined } as ToolValue,
+ ]
+
+ // Act
+ renderComponent({ value: tools })
+
+ // Assert - Should count as not enabled (falsy)
+ expect(screen.getByText('0/1')).toBeInTheDocument()
+ })
+
+ it('should handle empty label', () => {
+ // Arrange & Act
+ renderComponent({ label: '' })
+
+ // Assert - Should not crash
+ expect(screen.getByTestId('tool-selector-add')).toBeInTheDocument()
+ })
+
+ it('should handle nodeOutputVars as empty array', () => {
+ // Arrange & Act
+ renderComponent({ nodeOutputVars: [] })
+
+ // Assert
+ expect(screen.getByTestId('tool-selector-add')).toBeInTheDocument()
+ })
+
+ it('should handle availableNodes as empty array', () => {
+ // Arrange & Act
+ renderComponent({ availableNodes: [] })
+
+ // Assert
+ expect(screen.getByTestId('tool-selector-add')).toBeInTheDocument()
+ })
+
+ it('should handle undefined nodeId', () => {
+ // Arrange & Act
+ renderComponent({ nodeId: undefined })
+
+ // Assert
+ expect(screen.getByTestId('tool-selector-add')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Props Variations Tests ====================
+ describe('Props Variations', () => {
+ it('should pass disabled prop to child selectors', () => {
+ // Arrange & Act
+ const { container } = renderComponent({ disabled: true })
+
+ // Assert - ActionButton (add button with mx-1 class) should not be rendered
+ const actionButton = container.querySelector('[class*="mx-1"]')
+ expect(actionButton).not.toBeInTheDocument()
+ })
+
+ it('should pass scope prop to ToolSelector', () => {
+ // Arrange & Act
+ renderComponent({ scope: 'test-scope' })
+
+ // Assert
+ expect(screen.getByTestId('tool-selector-add')).toBeInTheDocument()
+ })
+
+ it('should pass canChooseMCPTool prop correctly', () => {
+ // Arrange & Act
+ renderComponent({ canChooseMCPTool: true })
+
+ // Assert
+ expect(screen.getByTestId('tool-selector-add')).toBeInTheDocument()
+ })
+
+ it('should render with supportEnableSwitch for edit selectors', () => {
+ // Arrange
+ const tools = [createToolValue()]
+
+ // Act
+ renderComponent({ value: tools })
+
+ // Assert
+ const editSelector = screen.getByTestId('tool-selector-edit')
+ expect(editSelector).toHaveAttribute('data-support-enable-switch', 'true')
+ })
+
+ it('should handle multiple tools correctly', () => {
+ // Arrange
+ const tools = Array.from({ length: 5 }, (_, i) =>
+ createToolValue({ tool_name: `tool-${i}`, tool_label: `Tool ${i}` }))
+
+ // Act
+ renderComponent({ value: tools })
+
+ // Assert
+ const editSelectors = screen.getAllByTestId('tool-selector-edit')
+ expect(editSelectors).toHaveLength(5)
+ })
+ })
+
+ // ==================== MCP Tools Integration Tests ====================
+ describe('MCP Tools Integration', () => {
+ it('should correctly identify MCP tools', () => {
+ // Arrange
+ const mcpTools = [
+ createMCPTool({ id: 'mcp-provider-1' }),
+ createMCPTool({ id: 'mcp-provider-2' }),
+ ]
+ mockMCPToolsData.mockReturnValue(mcpTools)
+
+ const tools = [
+ createToolValue({ provider_name: 'mcp-provider-1', enabled: true }),
+ createToolValue({ provider_name: 'regular-provider', enabled: true }),
+ ]
+
+ // Act
+ renderComponent({ value: tools, canChooseMCPTool: true })
+
+ // Assert
+ expect(screen.getByText('2/2')).toBeInTheDocument()
+ })
+
+ it('should exclude MCP tools from enabled count when canChooseMCPTool is false', () => {
+ // Arrange
+ const mcpTools = [createMCPTool({ id: 'mcp-provider' })]
+ mockMCPToolsData.mockReturnValue(mcpTools)
+
+ const tools = [
+ createToolValue({ provider_name: 'mcp-provider', enabled: true }),
+ createToolValue({ provider_name: 'regular', enabled: true }),
+ ]
+
+ // Act
+ renderComponent({ value: tools, canChooseMCPTool: false })
+
+ // Assert - Only regular tool should be counted
+ expect(screen.getByText('1/2')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Deduplication Logic Tests ====================
+ describe('Deduplication Logic', () => {
+ it('should deduplicate by provider_name and tool_name combination', () => {
+ // Arrange
+ const onChange = vi.fn()
+ const existingTools = [
+ createToolValue({ provider_name: 'new-provider', tool_name: 'new-tool' }),
+ ]
+ renderComponent({ value: existingTools, onChange })
+
+ // Act - Try to add same provider_name + tool_name via add button
+ fireEvent.click(screen.getByTestId('add-tool-btn'))
+
+ // Assert - Should not add duplicate, only existing tool remains
+ expect(onChange).toHaveBeenCalledWith(existingTools)
+ })
+
+ it('should allow same tool_name with different provider_name', () => {
+ // Arrange
+ const onChange = vi.fn()
+ const existingTools = [
+ createToolValue({ provider_name: 'other-provider', tool_name: 'new-tool' }),
+ ]
+ renderComponent({ value: existingTools, onChange })
+
+ // Act - Add tool with different provider
+ fireEvent.click(screen.getByTestId('add-tool-btn'))
+
+ // Assert - Should add as it's different provider
+ expect(onChange).toHaveBeenCalledWith([
+ existingTools[0],
+ expect.objectContaining({ provider_name: 'new-provider', tool_name: 'new-tool' }),
+ ])
+ })
+
+ it('should deduplicate multiple tools in batch add', () => {
+ // Arrange
+ const onChange = vi.fn()
+ const existingTools = [
+ createToolValue({ provider_name: 'batch-p', tool_name: 'batch-t1' }),
+ ]
+ renderComponent({ value: existingTools, onChange })
+
+ // Act - Add multiple tools (batch-t1 is duplicate)
+ fireEvent.click(screen.getByTestId('add-multiple-tools-btn'))
+
+ // Assert - Should have 2 unique tools (batch-t1 deduplicated)
+ expect(onChange).toHaveBeenCalledWith([
+ expect.objectContaining({ provider_name: 'batch-p', tool_name: 'batch-t1' }),
+ expect.objectContaining({ provider_name: 'batch-p', tool_name: 'batch-t2' }),
+ ])
+ })
+ })
+
+ // ==================== Delete Functionality Tests ====================
+ describe('Delete Functionality', () => {
+ it('should remove tool at specific index when delete is clicked', () => {
+ // Arrange
+ const tools = [
+ createToolValue({ tool_name: 'tool-0', provider_name: 'p0' }),
+ createToolValue({ tool_name: 'tool-1', provider_name: 'p1' }),
+ createToolValue({ tool_name: 'tool-2', provider_name: 'p2' }),
+ ]
+ const onChange = vi.fn()
+ renderComponent({ value: tools, onChange })
+
+ // Act - Delete first tool
+ fireEvent.click(screen.getByTestId('delete-btn-0'))
+
+ // Assert
+ expect(onChange).toHaveBeenCalledWith([
+ expect.objectContaining({ tool_name: 'tool-1' }),
+ expect.objectContaining({ tool_name: 'tool-2' }),
+ ])
+ })
+
+ it('should remove last tool when delete is clicked', () => {
+ // Arrange
+ const tools = [
+ createToolValue({ tool_name: 'tool-0', provider_name: 'p0' }),
+ createToolValue({ tool_name: 'tool-1', provider_name: 'p1' }),
+ ]
+ const onChange = vi.fn()
+ renderComponent({ value: tools, onChange })
+
+ // Act - Delete last tool (index 1)
+ fireEvent.click(screen.getByTestId('delete-btn-1'))
+
+ // Assert
+ expect(onChange).toHaveBeenCalledWith([
+ expect.objectContaining({ tool_name: 'tool-0' }),
+ ])
+ })
+
+ it('should result in empty array when deleting last remaining tool', () => {
+ // Arrange
+ const tools = [createToolValue({ tool_name: 'only-tool' })]
+ const onChange = vi.fn()
+ renderComponent({ value: tools, onChange })
+
+ // Act - Delete the only tool
+ fireEvent.click(screen.getByTestId('delete-btn-0'))
+
+ // Assert
+ expect(onChange).toHaveBeenCalledWith([])
+ })
+ })
+
+ // ==================== Configure Functionality Tests ====================
+ describe('Configure Functionality', () => {
+ it('should update tool at specific index when configured', () => {
+ // Arrange
+ const tools = [
+ createToolValue({ tool_name: 'tool-1', enabled: true }),
+ ]
+ const onChange = vi.fn()
+ renderComponent({ value: tools, onChange })
+
+ // Act - Configure tool (toggles enabled)
+ fireEvent.click(screen.getByTestId('configure-btn-0'))
+
+ // Assert
+ expect(onChange).toHaveBeenCalledWith([
+ expect.objectContaining({ tool_name: 'tool-1', enabled: false }),
+ ])
+ })
+
+ it('should preserve other tools when configuring one tool', () => {
+ // Arrange
+ const tools = [
+ createToolValue({ tool_name: 'tool-0', enabled: true }),
+ createToolValue({ tool_name: 'tool-1', enabled: false }),
+ createToolValue({ tool_name: 'tool-2', enabled: true }),
+ ]
+ const onChange = vi.fn()
+ renderComponent({ value: tools, onChange })
+
+ // Act - Configure middle tool (index 1)
+ fireEvent.click(screen.getByTestId('configure-btn-1'))
+
+ // Assert - All tools preserved, only middle one changed
+ expect(onChange).toHaveBeenCalledWith([
+ expect.objectContaining({ tool_name: 'tool-0', enabled: true }),
+ expect.objectContaining({ tool_name: 'tool-1', enabled: true }), // toggled
+ expect.objectContaining({ tool_name: 'tool-2', enabled: true }),
+ ])
+ })
+
+ it('should update first tool correctly', () => {
+ // Arrange
+ const tools = [
+ createToolValue({ tool_name: 'first', enabled: false }),
+ createToolValue({ tool_name: 'second', enabled: true }),
+ ]
+ const onChange = vi.fn()
+ renderComponent({ value: tools, onChange })
+
+ // Act - Configure first tool
+ fireEvent.click(screen.getByTestId('configure-btn-0'))
+
+ // Assert
+ expect(onChange).toHaveBeenCalledWith([
+ expect.objectContaining({ tool_name: 'first', enabled: true }), // toggled
+ expect.objectContaining({ tool_name: 'second', enabled: true }),
+ ])
+ })
+ })
+
+ // ==================== Panel State Tests ====================
+ describe('Panel State Management', () => {
+ it('should initialize with panel show state true on add', () => {
+ // Arrange
+ const { container } = renderComponent()
+
+ // Act - Click add button
+ const addButton = container.querySelector('button')
+ fireEvent.click(addButton!)
+
+ // Assert
+ const addSelector = screen.getByTestId('tool-selector-add')
+ expect(addSelector).toHaveAttribute('data-panel-show-state', 'true')
+ })
+ })
+
+ // ==================== Accessibility Tests ====================
+ describe('Accessibility', () => {
+ it('should have clickable add button', () => {
+ // Arrange
+ const { container } = renderComponent()
+
+ // Assert
+ const addButton = container.querySelector('button')
+ expect(addButton).toBeInTheDocument()
+ })
+
+ it('should show divider when tools are selected', () => {
+ // Arrange
+ const tools = [createToolValue()]
+
+ // Act
+ const { container } = renderComponent({ value: tools })
+
+ // Assert
+ const divider = container.querySelector('[class*="h-3"]')
+ expect(divider).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Tooltip Tests ====================
+ describe('Tooltip Rendering', () => {
+ it('should render question icon when tooltip is provided', () => {
+ // Arrange & Act
+ const { container } = renderComponent({ tooltip: 'Help text' })
+
+ // Assert
+ const questionIcon = container.querySelector('svg')
+ expect(questionIcon).toBeInTheDocument()
+ })
+
+ it('should not render question icon when tooltip is not provided', () => {
+ // Arrange & Act
+ const { container } = renderComponent({ tooltip: undefined })
+
+ // Assert - Should only have add icon, not question icon in label area
+ const labelDiv = container.querySelector('.system-sm-semibold-uppercase')
+ const icons = labelDiv?.querySelectorAll('svg') || []
+ // Question icon should not be in the label area
+ expect(icons.length).toBeLessThanOrEqual(1)
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx
new file mode 100644
index 0000000000..8bf154e26e
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx
@@ -0,0 +1,1884 @@
+import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import * as React from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+// Import after mocks
+import { SupportedCreationMethods } from '@/app/components/plugins/types'
+import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
+import { CommonCreateModal } from './common-modal'
+
+// ============================================================================
+// Type Definitions
+// ============================================================================
+
+type PluginDetail = {
+ plugin_id: string
+ provider: string
+ name: string
+ declaration?: {
+ trigger?: {
+ subscription_schema?: Array<{ name: string, type: string, required?: boolean, description?: string }>
+ subscription_constructor?: {
+ credentials_schema?: Array<{ name: string, type: string, required?: boolean, help?: string }>
+ parameters?: Array<{ name: string, type: string, required?: boolean, description?: string }>
+ }
+ }
+ }
+}
+
+type TriggerLogEntity = {
+ id: string
+ message: string
+ timestamp: string
+ level: 'info' | 'warn' | 'error'
+}
+
+// ============================================================================
+// Mock Factory Functions
+// ============================================================================
+
+function createMockPluginDetail(overrides: Partial = {}): PluginDetail {
+ return {
+ plugin_id: 'test-plugin-id',
+ provider: 'test-provider',
+ name: 'Test Plugin',
+ declaration: {
+ trigger: {
+ subscription_schema: [],
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [],
+ },
+ },
+ },
+ ...overrides,
+ }
+}
+
+function createMockSubscriptionBuilder(overrides: Partial = {}): TriggerSubscriptionBuilder {
+ return {
+ id: 'builder-123',
+ name: 'Test Builder',
+ provider: 'test-provider',
+ credential_type: TriggerCredentialTypeEnum.ApiKey,
+ credentials: {},
+ endpoint: 'https://example.com/callback',
+ parameters: {},
+ properties: {},
+ workflows_in_use: 0,
+ ...overrides,
+ }
+}
+
+function createMockLogData(logs: TriggerLogEntity[] = []): { logs: TriggerLogEntity[] } {
+ return { logs }
+}
+
+// ============================================================================
+// Mock Setup
+// ============================================================================
+
+const mockTranslate = vi.fn((key: string) => key)
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: mockTranslate,
+ }),
+}))
+
+// Mock plugin store
+const mockPluginDetail = createMockPluginDetail()
+const mockUsePluginStore = vi.fn(() => mockPluginDetail)
+vi.mock('../../store', () => ({
+ usePluginStore: () => mockUsePluginStore(),
+}))
+
+// Mock subscription list hook
+const mockRefetch = vi.fn()
+vi.mock('../use-subscription-list', () => ({
+ useSubscriptionList: () => ({
+ refetch: mockRefetch,
+ }),
+}))
+
+// Mock service hooks
+const mockVerifyCredentials = vi.fn()
+const mockCreateBuilder = vi.fn()
+const mockBuildSubscription = vi.fn()
+const mockUpdateBuilder = vi.fn()
+
+// Configurable pending states
+let mockIsVerifyingCredentials = false
+let mockIsBuilding = false
+const setMockPendingStates = (verifying: boolean, building: boolean) => {
+ mockIsVerifyingCredentials = verifying
+ mockIsBuilding = building
+}
+
+vi.mock('@/service/use-triggers', () => ({
+ useVerifyAndUpdateTriggerSubscriptionBuilder: () => ({
+ mutate: mockVerifyCredentials,
+ get isPending() { return mockIsVerifyingCredentials },
+ }),
+ useCreateTriggerSubscriptionBuilder: () => ({
+ mutateAsync: mockCreateBuilder,
+ isPending: false,
+ }),
+ useBuildTriggerSubscription: () => ({
+ mutate: mockBuildSubscription,
+ get isPending() { return mockIsBuilding },
+ }),
+ useUpdateTriggerSubscriptionBuilder: () => ({
+ mutate: mockUpdateBuilder,
+ isPending: false,
+ }),
+ useTriggerSubscriptionBuilderLogs: () => ({
+ data: createMockLogData(),
+ }),
+}))
+
+// Mock error parser
+const mockParsePluginErrorMessage = vi.fn().mockResolvedValue(null)
+vi.mock('@/utils/error-parser', () => ({
+ parsePluginErrorMessage: (...args: unknown[]) => mockParsePluginErrorMessage(...args),
+}))
+
+// Mock URL validation
+vi.mock('@/utils/urlValidation', () => ({
+ isPrivateOrLocalAddress: vi.fn().mockReturnValue(false),
+}))
+
+// Mock toast
+const mockToastNotify = vi.fn()
+vi.mock('@/app/components/base/toast', () => ({
+ default: {
+ notify: (params: unknown) => mockToastNotify(params),
+ },
+}))
+
+// Mock Modal component
+vi.mock('@/app/components/base/modal/modal', () => ({
+ default: ({
+ children,
+ onClose,
+ onConfirm,
+ title,
+ confirmButtonText,
+ bottomSlot,
+ size,
+ disabled,
+ }: {
+ children: React.ReactNode
+ onClose: () => void
+ onConfirm: () => void
+ title: string
+ confirmButtonText: string
+ bottomSlot?: React.ReactNode
+ size?: string
+ disabled?: boolean
+ }) => (
+
+
{title}
+
{children}
+
{bottomSlot}
+
+
+
+ ),
+}))
+
+// Configurable form mock values
+type MockFormValuesConfig = {
+ values: Record
+ isCheckValidated: boolean
+}
+let mockFormValuesConfig: MockFormValuesConfig = {
+ values: { api_key: 'test-api-key', subscription_name: 'Test Subscription' },
+ isCheckValidated: true,
+}
+let mockGetFormReturnsNull = false
+
+// Separate validation configs for different forms
+let mockSubscriptionFormValidated = true
+let mockAutoParamsFormValidated = true
+let mockManualPropsFormValidated = true
+
+const setMockFormValuesConfig = (config: MockFormValuesConfig) => {
+ mockFormValuesConfig = config
+}
+const setMockGetFormReturnsNull = (value: boolean) => {
+ mockGetFormReturnsNull = value
+}
+const setMockFormValidation = (subscription: boolean, autoParams: boolean, manualProps: boolean) => {
+ mockSubscriptionFormValidated = subscription
+ mockAutoParamsFormValidated = autoParams
+ mockManualPropsFormValidated = manualProps
+}
+
+// Mock BaseForm component with ref support
+vi.mock('@/app/components/base/form/components/base', async () => {
+ const React = await import('react')
+
+ type MockFormRef = {
+ getFormValues: (options: Record) => { values: Record, isCheckValidated: boolean }
+ setFields: (fields: Array<{ name: string, errors?: string[], warnings?: string[] }>) => void
+ getForm: () => { setFieldValue: (name: string, value: unknown) => void } | null
+ }
+ type MockBaseFormProps = { formSchemas: Array<{ name: string }>, onChange?: () => void }
+
+ function MockBaseFormInner({ formSchemas, onChange }: MockBaseFormProps, ref: React.ForwardedRef) {
+ // Determine which form this is based on schema
+ const isSubscriptionForm = formSchemas.some((s: { name: string }) => s.name === 'subscription_name')
+ const isAutoParamsForm = formSchemas.some((s: { name: string }) =>
+ ['repo_name', 'branch', 'repo', 'text_field', 'dynamic_field', 'bool_field', 'text_input_field', 'unknown_field', 'count'].includes(s.name),
+ )
+ const isManualPropsForm = formSchemas.some((s: { name: string }) => s.name === 'webhook_url')
+
+ React.useImperativeHandle(ref, () => ({
+ getFormValues: () => {
+ let isValidated = mockFormValuesConfig.isCheckValidated
+ if (isSubscriptionForm)
+ isValidated = mockSubscriptionFormValidated
+ else if (isAutoParamsForm)
+ isValidated = mockAutoParamsFormValidated
+ else if (isManualPropsForm)
+ isValidated = mockManualPropsFormValidated
+
+ return {
+ ...mockFormValuesConfig,
+ isCheckValidated: isValidated,
+ }
+ },
+ setFields: () => {},
+ getForm: () => mockGetFormReturnsNull
+ ? null
+ : { setFieldValue: () => {} },
+ }))
+ return (
+
+ {formSchemas.map((schema: { name: string }) => (
+
+ ))}
+
+ )
+ }
+
+ return {
+ BaseForm: React.forwardRef(MockBaseFormInner),
+ }
+})
+
+// Mock EncryptedBottom component
+vi.mock('@/app/components/base/encrypted-bottom', () => ({
+ EncryptedBottom: () => Encrypted
,
+}))
+
+// Mock LogViewer component
+vi.mock('../log-viewer', () => ({
+ default: ({ logs }: { logs: TriggerLogEntity[] }) => (
+
+ {logs.map(log => (
+
{log.message}
+ ))}
+
+ ),
+}))
+
+// Mock debounce
+vi.mock('es-toolkit/compat', () => ({
+ debounce: (fn: (...args: unknown[]) => unknown) => {
+ const debouncedFn = (...args: unknown[]) => fn(...args)
+ debouncedFn.cancel = vi.fn()
+ return debouncedFn
+ },
+}))
+
+// ============================================================================
+// Test Suites
+// ============================================================================
+
+describe('CommonCreateModal', () => {
+ const defaultProps = {
+ onClose: vi.fn(),
+ createType: SupportedCreationMethods.APIKEY,
+ builder: undefined as TriggerSubscriptionBuilder | undefined,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUsePluginStore.mockReturnValue(mockPluginDetail)
+ mockCreateBuilder.mockResolvedValue({
+ subscription_builder: createMockSubscriptionBuilder(),
+ })
+ // Reset configurable mocks
+ setMockPendingStates(false, false)
+ setMockFormValuesConfig({
+ values: { api_key: 'test-api-key', subscription_name: 'Test Subscription' },
+ isCheckValidated: true,
+ })
+ setMockGetFormReturnsNull(false)
+ setMockFormValidation(true, true, true) // All forms validated by default
+ mockParsePluginErrorMessage.mockResolvedValue(null)
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render modal with correct title for API Key method', () => {
+ render()
+
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('pluginTrigger.modal.apiKey.title')
+ })
+
+ it('should render modal with correct title for Manual method', () => {
+ render()
+
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('pluginTrigger.modal.manual.title')
+ })
+
+ it('should render modal with correct title for OAuth method', () => {
+ render()
+
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('pluginTrigger.modal.oauth.title')
+ })
+
+ it('should show multi-steps for API Key method', () => {
+ const detailWithCredentials = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [
+ { name: 'api_key', type: 'secret', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithCredentials)
+
+ render()
+
+ expect(screen.getByText('pluginTrigger.modal.steps.verify')).toBeInTheDocument()
+ expect(screen.getByText('pluginTrigger.modal.steps.configuration')).toBeInTheDocument()
+ })
+
+ it('should render LogViewer for Manual method', () => {
+ render()
+
+ expect(screen.getByTestId('log-viewer')).toBeInTheDocument()
+ })
+ })
+
+ describe('Builder Initialization', () => {
+ it('should create builder on mount when no builder provided', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(mockCreateBuilder).toHaveBeenCalledWith({
+ provider: 'test-provider',
+ credential_type: 'api-key',
+ })
+ })
+ })
+
+ it('should not create builder when builder is provided', async () => {
+ const existingBuilder = createMockSubscriptionBuilder()
+ render()
+
+ await waitFor(() => {
+ expect(mockCreateBuilder).not.toHaveBeenCalled()
+ })
+ })
+
+ it('should show error toast when builder creation fails', async () => {
+ mockCreateBuilder.mockRejectedValueOnce(new Error('Creation failed'))
+
+ render()
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'pluginTrigger.modal.errors.createFailed',
+ })
+ })
+ })
+ })
+
+ describe('API Key Flow', () => {
+ it('should start at Verify step for API Key method', () => {
+ const detailWithCredentials = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [
+ { name: 'api_key', type: 'secret', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithCredentials)
+
+ render()
+
+ expect(screen.getByTestId('form-field-api_key')).toBeInTheDocument()
+ })
+
+ it('should show verify button text initially', () => {
+ render()
+
+ expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.common.verify')
+ })
+ })
+
+ describe('Modal Actions', () => {
+ it('should call onClose when close button is clicked', () => {
+ const mockOnClose = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-close'))
+
+ expect(mockOnClose).toHaveBeenCalled()
+ })
+
+ it('should call onConfirm handler when confirm button is clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'Please fill in all required credentials',
+ })
+ })
+ })
+
+ describe('Manual Method', () => {
+ it('should start at Configuration step for Manual method', () => {
+ render()
+
+ expect(screen.getByText('pluginTrigger.modal.manual.logs.title')).toBeInTheDocument()
+ })
+
+ it('should render manual properties form when schema exists', () => {
+ const detailWithManualSchema = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_schema: [
+ { name: 'webhook_url', type: 'text', required: true },
+ ],
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithManualSchema)
+
+ render()
+
+ expect(screen.getByTestId('form-field-webhook_url')).toBeInTheDocument()
+ })
+
+ it('should show create button text for Manual method', () => {
+ render()
+
+ expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.common.create')
+ })
+ })
+
+ describe('Form Interactions', () => {
+ it('should render credentials form fields', () => {
+ const detailWithCredentials = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [
+ { name: 'client_id', type: 'text', required: true },
+ { name: 'client_secret', type: 'secret', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithCredentials)
+
+ render()
+
+ expect(screen.getByTestId('form-field-client_id')).toBeInTheDocument()
+ expect(screen.getByTestId('form-field-client_secret')).toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle missing provider gracefully', async () => {
+ const detailWithoutProvider = { ...mockPluginDetail, provider: '' }
+ mockUsePluginStore.mockReturnValue(detailWithoutProvider)
+
+ render()
+
+ await waitFor(() => {
+ expect(mockCreateBuilder).not.toHaveBeenCalled()
+ })
+ })
+
+ it('should handle empty credentials schema', () => {
+ const detailWithEmptySchema = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithEmptySchema)
+
+ render()
+
+ expect(screen.queryByTestId('form-field-api_key')).not.toBeInTheDocument()
+ })
+
+ it('should handle undefined trigger in declaration', () => {
+ const detailWithEmptyDeclaration = createMockPluginDetail({
+ declaration: {
+ trigger: undefined,
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithEmptyDeclaration)
+
+ render()
+
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+ })
+
+ describe('CREDENTIAL_TYPE_MAP', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUsePluginStore.mockReturnValue(mockPluginDetail)
+ mockCreateBuilder.mockResolvedValue({
+ subscription_builder: createMockSubscriptionBuilder(),
+ })
+ })
+
+ it('should use correct credential type for APIKEY', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(mockCreateBuilder).toHaveBeenCalledWith(
+ expect.objectContaining({
+ credential_type: 'api-key',
+ }),
+ )
+ })
+ })
+
+ it('should use correct credential type for OAUTH', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(mockCreateBuilder).toHaveBeenCalledWith(
+ expect.objectContaining({
+ credential_type: 'oauth2',
+ }),
+ )
+ })
+ })
+
+ it('should use correct credential type for MANUAL', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(mockCreateBuilder).toHaveBeenCalledWith(
+ expect.objectContaining({
+ credential_type: 'unauthorized',
+ }),
+ )
+ })
+ })
+ })
+
+ describe('MODAL_TITLE_KEY_MAP', () => {
+ it('should use correct title key for APIKEY', () => {
+ render()
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('pluginTrigger.modal.apiKey.title')
+ })
+
+ it('should use correct title key for OAUTH', () => {
+ render()
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('pluginTrigger.modal.oauth.title')
+ })
+
+ it('should use correct title key for MANUAL', () => {
+ render()
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('pluginTrigger.modal.manual.title')
+ })
+ })
+
+ describe('Verify Flow', () => {
+ it('should call verifyCredentials and move to Configuration step on success', async () => {
+ const detailWithCredentials = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [
+ { name: 'api_key', type: 'secret', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithCredentials)
+ mockVerifyCredentials.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(mockCreateBuilder).toHaveBeenCalled()
+ })
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ await waitFor(() => {
+ expect(mockVerifyCredentials).toHaveBeenCalled()
+ })
+ })
+
+ it('should show error on verify failure', async () => {
+ const detailWithCredentials = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [
+ { name: 'api_key', type: 'secret', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithCredentials)
+ mockVerifyCredentials.mockImplementation((params, { onError }) => {
+ onError(new Error('Verification failed'))
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(mockCreateBuilder).toHaveBeenCalled()
+ })
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ await waitFor(() => {
+ expect(mockVerifyCredentials).toHaveBeenCalled()
+ })
+ })
+ })
+
+ describe('Create Flow', () => {
+ it('should show error when subscriptionBuilder is not found in Configuration step', async () => {
+ // Start in Configuration step (Manual method)
+ render()
+
+ // Before builder is created, click confirm
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'Subscription builder not found',
+ })
+ })
+ })
+
+ it('should call buildSubscription on successful create', async () => {
+ const builder = createMockSubscriptionBuilder()
+ mockBuildSubscription.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ // Verify form is rendered and confirm button is clickable
+ expect(screen.getByTestId('modal-confirm')).toBeInTheDocument()
+ })
+
+ it('should show error toast when buildSubscription fails', async () => {
+ const builder = createMockSubscriptionBuilder()
+ mockBuildSubscription.mockImplementation((params, { onError }) => {
+ onError(new Error('Build failed'))
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ // Verify the modal is still rendered after error
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+
+ it('should call refetch and onClose on successful create', async () => {
+ const mockOnClose = vi.fn()
+ const builder = createMockSubscriptionBuilder()
+ mockBuildSubscription.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ render()
+
+ // Verify component renders with builder
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+ })
+
+ describe('Manual Properties Change', () => {
+ it('should call updateBuilder when manual properties change', async () => {
+ const detailWithManualSchema = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_schema: [
+ { name: 'webhook_url', type: 'text', required: true },
+ ],
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithManualSchema)
+
+ render()
+
+ await waitFor(() => {
+ expect(mockCreateBuilder).toHaveBeenCalled()
+ })
+
+ const input = screen.getByTestId('form-field-webhook_url')
+ fireEvent.change(input, { target: { value: 'https://example.com/webhook' } })
+
+ // updateBuilder should be called after debounce
+ await waitFor(() => {
+ expect(mockUpdateBuilder).toHaveBeenCalled()
+ })
+ })
+
+ it('should not call updateBuilder when subscriptionBuilder is missing', async () => {
+ const detailWithManualSchema = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_schema: [
+ { name: 'webhook_url', type: 'text', required: true },
+ ],
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithManualSchema)
+ mockCreateBuilder.mockResolvedValue({ subscription_builder: undefined })
+
+ render()
+
+ const input = screen.getByTestId('form-field-webhook_url')
+ fireEvent.change(input, { target: { value: 'https://example.com/webhook' } })
+
+ // updateBuilder should not be called
+ expect(mockUpdateBuilder).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('UpdateBuilder Error Handling', () => {
+ it('should show error toast when updateBuilder fails', async () => {
+ const detailWithManualSchema = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_schema: [
+ { name: 'webhook_url', type: 'text', required: true },
+ ],
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithManualSchema)
+ mockUpdateBuilder.mockImplementation((params, { onError }) => {
+ onError(new Error('Update failed'))
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(mockCreateBuilder).toHaveBeenCalled()
+ })
+
+ const input = screen.getByTestId('form-field-webhook_url')
+ fireEvent.change(input, { target: { value: 'https://example.com/webhook' } })
+
+ await waitFor(() => {
+ expect(mockUpdateBuilder).toHaveBeenCalled()
+ })
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'error',
+ }),
+ )
+ })
+ })
+ })
+
+ describe('Private Address Warning', () => {
+ it('should show warning when callback URL is private address', async () => {
+ const { isPrivateOrLocalAddress } = await import('@/utils/urlValidation')
+ vi.mocked(isPrivateOrLocalAddress).mockReturnValue(true)
+
+ const builder = createMockSubscriptionBuilder({
+ endpoint: 'http://localhost:3000/callback',
+ })
+
+ render()
+
+ // Verify component renders with the private address endpoint
+ expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument()
+ })
+
+ it('should clear warning when callback URL is not private address', async () => {
+ const { isPrivateOrLocalAddress } = await import('@/utils/urlValidation')
+ vi.mocked(isPrivateOrLocalAddress).mockReturnValue(false)
+
+ const builder = createMockSubscriptionBuilder({
+ endpoint: 'https://example.com/callback',
+ })
+
+ render()
+
+ // Verify component renders with public address endpoint
+ expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument()
+ })
+ })
+
+ describe('Auto Parameters Schema', () => {
+ it('should render auto parameters form for OAuth method', () => {
+ const detailWithAutoParams = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [
+ { name: 'repo_name', type: 'string', required: true },
+ { name: 'branch', type: 'text', required: false },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithAutoParams)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.getByTestId('form-field-repo_name')).toBeInTheDocument()
+ expect(screen.getByTestId('form-field-branch')).toBeInTheDocument()
+ })
+
+ it('should not render auto parameters form for Manual method', () => {
+ const detailWithAutoParams = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [
+ { name: 'repo_name', type: 'string', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithAutoParams)
+
+ render()
+
+ // For manual method, auto parameters should not be rendered
+ expect(screen.queryByTestId('form-field-repo_name')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Form Type Normalization', () => {
+ it('should normalize various form types in auto parameters', () => {
+ const detailWithVariousTypes = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [
+ { name: 'text_field', type: 'string' },
+ { name: 'secret_field', type: 'password' },
+ { name: 'number_field', type: 'number' },
+ { name: 'bool_field', type: 'boolean' },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithVariousTypes)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.getByTestId('form-field-text_field')).toBeInTheDocument()
+ expect(screen.getByTestId('form-field-secret_field')).toBeInTheDocument()
+ expect(screen.getByTestId('form-field-number_field')).toBeInTheDocument()
+ expect(screen.getByTestId('form-field-bool_field')).toBeInTheDocument()
+ })
+
+ it('should handle integer type as number', () => {
+ const detailWithInteger = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [
+ { name: 'count', type: 'integer' },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithInteger)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.getByTestId('form-field-count')).toBeInTheDocument()
+ })
+ })
+
+ describe('API Key Credentials Change', () => {
+ it('should clear errors when credentials change', () => {
+ const detailWithCredentials = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [
+ { name: 'api_key', type: 'secret', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithCredentials)
+
+ render()
+
+ const input = screen.getByTestId('form-field-api_key')
+ fireEvent.change(input, { target: { value: 'new-api-key' } })
+
+ // Verify the input field exists and accepts changes
+ expect(input).toBeInTheDocument()
+ })
+ })
+
+ describe('Subscription Form in Configuration Step', () => {
+ it('should render subscription name and callback URL fields', () => {
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.getByTestId('form-field-subscription_name')).toBeInTheDocument()
+ expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument()
+ })
+ })
+
+ describe('Pending States', () => {
+ it('should show verifying text when isVerifyingCredentials is true', () => {
+ setMockPendingStates(true, false)
+
+ render()
+
+ expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.common.verifying')
+ })
+
+ it('should show creating text when isBuilding is true', () => {
+ setMockPendingStates(false, true)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.common.creating')
+ })
+
+ it('should disable confirm button when verifying', () => {
+ setMockPendingStates(true, false)
+
+ render()
+
+ expect(screen.getByTestId('modal-confirm')).toBeDisabled()
+ })
+
+ it('should disable confirm button when building', () => {
+ setMockPendingStates(false, true)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.getByTestId('modal-confirm')).toBeDisabled()
+ })
+ })
+
+ describe('Modal Size', () => {
+ it('should use md size for Manual method', () => {
+ render()
+
+ expect(screen.getByTestId('modal')).toHaveAttribute('data-size', 'md')
+ })
+
+ it('should use sm size for API Key method', () => {
+ render()
+
+ expect(screen.getByTestId('modal')).toHaveAttribute('data-size', 'sm')
+ })
+
+ it('should use sm size for OAuth method', () => {
+ render()
+
+ expect(screen.getByTestId('modal')).toHaveAttribute('data-size', 'sm')
+ })
+ })
+
+ describe('BottomSlot', () => {
+ it('should show EncryptedBottom in Verify step', () => {
+ render()
+
+ expect(screen.getByTestId('encrypted-bottom')).toBeInTheDocument()
+ })
+
+ it('should not show EncryptedBottom in Configuration step', () => {
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.queryByTestId('encrypted-bottom')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Form Validation Failure', () => {
+ it('should return early when subscription form validation fails', async () => {
+ // Subscription form fails validation
+ setMockFormValidation(false, true, true)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ // buildSubscription should not be called when validation fails
+ expect(mockBuildSubscription).not.toHaveBeenCalled()
+ })
+
+ it('should return early when auto parameters validation fails', async () => {
+ // Subscription form passes, but auto params form fails
+ setMockFormValidation(true, false, true)
+
+ const detailWithAutoParams = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [
+ { name: 'repo_name', type: 'string', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithAutoParams)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ // buildSubscription should not be called when validation fails
+ expect(mockBuildSubscription).not.toHaveBeenCalled()
+ })
+
+ it('should return early when manual properties validation fails', async () => {
+ // Subscription form passes, but manual properties form fails
+ setMockFormValidation(true, true, false)
+
+ const detailWithManualSchema = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_schema: [
+ { name: 'webhook_url', type: 'text', required: true },
+ ],
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithManualSchema)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ // buildSubscription should not be called when validation fails
+ expect(mockBuildSubscription).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Error Message Parsing', () => {
+ it('should use parsed error message when available for verify error', async () => {
+ mockParsePluginErrorMessage.mockResolvedValue('Custom parsed error')
+
+ const detailWithCredentials = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [
+ { name: 'api_key', type: 'secret', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithCredentials)
+ mockVerifyCredentials.mockImplementation((params, { onError }) => {
+ onError(new Error('Raw error'))
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(mockCreateBuilder).toHaveBeenCalled()
+ })
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ await waitFor(() => {
+ expect(mockParsePluginErrorMessage).toHaveBeenCalled()
+ })
+ })
+
+ it('should use parsed error message when available for build error', async () => {
+ mockParsePluginErrorMessage.mockResolvedValue('Custom build error')
+
+ const builder = createMockSubscriptionBuilder()
+ mockBuildSubscription.mockImplementation((params, { onError }) => {
+ onError(new Error('Raw build error'))
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ await waitFor(() => {
+ expect(mockParsePluginErrorMessage).toHaveBeenCalled()
+ })
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'Custom build error',
+ })
+ })
+ })
+
+ it('should use fallback error message when parsePluginErrorMessage returns null', async () => {
+ mockParsePluginErrorMessage.mockResolvedValue(null)
+
+ const builder = createMockSubscriptionBuilder()
+ mockBuildSubscription.mockImplementation((params, { onError }) => {
+ onError(new Error('Raw error'))
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'pluginTrigger.subscription.createFailed',
+ })
+ })
+ })
+
+ it('should use parsed error message for update builder error', async () => {
+ mockParsePluginErrorMessage.mockResolvedValue('Custom update error')
+
+ const detailWithManualSchema = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_schema: [
+ { name: 'webhook_url', type: 'text', required: true },
+ ],
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithManualSchema)
+ mockUpdateBuilder.mockImplementation((params, { onError }) => {
+ onError(new Error('Update failed'))
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(mockCreateBuilder).toHaveBeenCalled()
+ })
+
+ const input = screen.getByTestId('form-field-webhook_url')
+ fireEvent.change(input, { target: { value: 'https://example.com/webhook' } })
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'Custom update error',
+ })
+ })
+ })
+ })
+
+ describe('Form getForm null handling', () => {
+ it('should handle getForm returning null', async () => {
+ setMockGetFormReturnsNull(true)
+
+ const builder = createMockSubscriptionBuilder({
+ endpoint: 'https://example.com/callback',
+ })
+
+ render()
+
+ // Component should render without errors even when getForm returns null
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+ })
+
+ describe('normalizeFormType with existing FormTypeEnum', () => {
+ it('should return the same type when already a valid FormTypeEnum', () => {
+ const detailWithFormTypeEnum = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [
+ { name: 'text_input_field', type: 'text-input' },
+ { name: 'secret_input_field', type: 'secret-input' },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithFormTypeEnum)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.getByTestId('form-field-text_input_field')).toBeInTheDocument()
+ expect(screen.getByTestId('form-field-secret_input_field')).toBeInTheDocument()
+ })
+
+ it('should handle unknown type by defaulting to textInput', () => {
+ const detailWithUnknownType = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [
+ { name: 'unknown_field', type: 'unknown-type' },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithUnknownType)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.getByTestId('form-field-unknown_field')).toBeInTheDocument()
+ })
+ })
+
+ describe('Verify Success Flow', () => {
+ it('should show success toast and move to Configuration step on verify success', async () => {
+ const detailWithCredentials = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [
+ { name: 'api_key', type: 'secret', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithCredentials)
+ mockVerifyCredentials.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(mockCreateBuilder).toHaveBeenCalled()
+ })
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'pluginTrigger.modal.apiKey.verify.success',
+ })
+ })
+ })
+ })
+
+ describe('Build Success Flow', () => {
+ it('should call refetch and onClose on successful build', async () => {
+ const mockOnClose = vi.fn()
+ const builder = createMockSubscriptionBuilder()
+ mockBuildSubscription.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'pluginTrigger.subscription.createSuccess',
+ })
+ })
+
+ await waitFor(() => {
+ expect(mockOnClose).toHaveBeenCalled()
+ })
+
+ await waitFor(() => {
+ expect(mockRefetch).toHaveBeenCalled()
+ })
+ })
+ })
+
+ describe('DynamicSelect Parameters', () => {
+ it('should handle dynamic-select type parameters', () => {
+ const detailWithDynamicSelect = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [
+ { name: 'dynamic_field', type: 'dynamic-select', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithDynamicSelect)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.getByTestId('form-field-dynamic_field')).toBeInTheDocument()
+ })
+ })
+
+ describe('Boolean Type Parameters', () => {
+ it('should handle boolean type parameters with special styling', () => {
+ const detailWithBoolean = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [
+ { name: 'bool_field', type: 'boolean', required: false },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithBoolean)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.getByTestId('form-field-bool_field')).toBeInTheDocument()
+ })
+ })
+
+ describe('Empty Form Values', () => {
+ it('should show error when credentials form returns empty values', () => {
+ setMockFormValuesConfig({
+ values: {},
+ isCheckValidated: false,
+ })
+
+ const detailWithCredentials = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [
+ { name: 'api_key', type: 'secret', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithCredentials)
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'Please fill in all required credentials',
+ })
+ })
+ })
+
+ describe('Auto Parameters with Empty Schema', () => {
+ it('should not render auto parameters when schema is empty', () => {
+ const detailWithEmptyParams = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithEmptyParams)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ // Should only have subscription form fields
+ expect(screen.getByTestId('form-field-subscription_name')).toBeInTheDocument()
+ expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument()
+ })
+ })
+
+ describe('Manual Properties with Empty Schema', () => {
+ it('should not render manual properties form when schema is empty', () => {
+ const detailWithEmptySchema = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_schema: [],
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithEmptySchema)
+
+ render()
+
+ // Should have subscription form but not manual properties
+ expect(screen.getByTestId('form-field-subscription_name')).toBeInTheDocument()
+ expect(screen.queryByTestId('form-field-webhook_url')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Credentials Schema with Help Text', () => {
+ it('should transform help to tooltip in credentials schema', () => {
+ const detailWithHelp = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [
+ { name: 'api_key', type: 'secret', required: true, help: 'Enter your API key' },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithHelp)
+
+ render()
+
+ expect(screen.getByTestId('form-field-api_key')).toBeInTheDocument()
+ })
+ })
+
+ describe('Auto Parameters with Description', () => {
+ it('should transform description to tooltip in auto parameters', () => {
+ const detailWithDescription = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [
+ { name: 'repo_name', type: 'string', required: true, description: 'Repository name' },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithDescription)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.getByTestId('form-field-repo_name')).toBeInTheDocument()
+ })
+ })
+
+ describe('Manual Properties with Description', () => {
+ it('should transform description to tooltip in manual properties', () => {
+ const detailWithDescription = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_schema: [
+ { name: 'webhook_url', type: 'text', required: true, description: 'Webhook URL' },
+ ],
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithDescription)
+
+ render()
+
+ expect(screen.getByTestId('form-field-webhook_url')).toBeInTheDocument()
+ })
+ })
+
+ describe('MultiSteps Component', () => {
+ it('should not render MultiSteps for OAuth method', () => {
+ render()
+
+ expect(screen.queryByText('pluginTrigger.modal.steps.verify')).not.toBeInTheDocument()
+ })
+
+ it('should not render MultiSteps for Manual method', () => {
+ render()
+
+ expect(screen.queryByText('pluginTrigger.modal.steps.verify')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('API Key Build with Parameters', () => {
+ it('should include parameters in build request for API Key method', async () => {
+ const detailWithParams = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [
+ { name: 'api_key', type: 'secret', required: true },
+ ],
+ parameters: [
+ { name: 'repo', type: 'string', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithParams)
+
+ // First verify credentials
+ mockVerifyCredentials.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+ mockBuildSubscription.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ // Click verify
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ await waitFor(() => {
+ expect(mockVerifyCredentials).toHaveBeenCalled()
+ })
+
+ // Now in configuration step, click create
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ await waitFor(() => {
+ expect(mockBuildSubscription).toHaveBeenCalled()
+ })
+ })
+ })
+
+ describe('OAuth Build Flow', () => {
+ it('should handle OAuth build flow correctly', async () => {
+ const detailWithOAuth = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithOAuth)
+ mockBuildSubscription.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ await waitFor(() => {
+ expect(mockBuildSubscription).toHaveBeenCalled()
+ })
+ })
+ })
+
+ describe('StatusStep Component Branches', () => {
+ it('should render active indicator dot when step is active', () => {
+ const detailWithCredentials = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [
+ { name: 'api_key', type: 'secret', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithCredentials)
+
+ render()
+
+ // Verify step is shown (active step has different styling)
+ expect(screen.getByText('pluginTrigger.modal.steps.verify')).toBeInTheDocument()
+ })
+
+ it('should not render active indicator for inactive step', () => {
+ const detailWithCredentials = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [
+ { name: 'api_key', type: 'secret', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithCredentials)
+
+ render()
+
+ // Configuration step should be inactive
+ expect(screen.getByText('pluginTrigger.modal.steps.configuration')).toBeInTheDocument()
+ })
+ })
+
+ describe('refetch Optional Chaining', () => {
+ it('should call refetch when available on successful build', async () => {
+ const builder = createMockSubscriptionBuilder()
+ mockBuildSubscription.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ await waitFor(() => {
+ expect(mockRefetch).toHaveBeenCalled()
+ })
+ })
+ })
+
+ describe('Combined Parameter Types', () => {
+ it('should render parameters with mixed types including dynamic-select and boolean', () => {
+ const detailWithMixedTypes = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [
+ { name: 'dynamic_field', type: 'dynamic-select', required: true },
+ { name: 'bool_field', type: 'boolean', required: false },
+ { name: 'text_field', type: 'string', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithMixedTypes)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.getByTestId('form-field-dynamic_field')).toBeInTheDocument()
+ expect(screen.getByTestId('form-field-bool_field')).toBeInTheDocument()
+ expect(screen.getByTestId('form-field-text_field')).toBeInTheDocument()
+ })
+
+ it('should render parameters without dynamic-select type', () => {
+ const detailWithNonDynamic = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [
+ { name: 'text_field', type: 'string', required: true },
+ { name: 'number_field', type: 'number', required: false },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithNonDynamic)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.getByTestId('form-field-text_field')).toBeInTheDocument()
+ expect(screen.getByTestId('form-field-number_field')).toBeInTheDocument()
+ })
+
+ it('should render parameters without boolean type', () => {
+ const detailWithNonBoolean = createMockPluginDetail({
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [
+ { name: 'text_field', type: 'string', required: true },
+ { name: 'secret_field', type: 'password', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithNonBoolean)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.getByTestId('form-field-text_field')).toBeInTheDocument()
+ expect(screen.getByTestId('form-field-secret_field')).toBeInTheDocument()
+ })
+ })
+
+ describe('Endpoint Default Value', () => {
+ it('should handle undefined endpoint in subscription builder', () => {
+ const builderWithoutEndpoint = createMockSubscriptionBuilder({
+ endpoint: undefined,
+ })
+
+ render()
+
+ expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument()
+ })
+
+ it('should handle empty string endpoint in subscription builder', () => {
+ const builderWithEmptyEndpoint = createMockSubscriptionBuilder({
+ endpoint: '',
+ })
+
+ render()
+
+ expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument()
+ })
+ })
+
+ describe('Plugin Detail Fallbacks', () => {
+ it('should handle undefined plugin_id', () => {
+ const detailWithoutPluginId = createMockPluginDetail({
+ plugin_id: '',
+ declaration: {
+ trigger: {
+ subscription_constructor: {
+ credentials_schema: [],
+ parameters: [
+ { name: 'dynamic_field', type: 'dynamic-select', required: true },
+ ],
+ },
+ },
+ },
+ })
+ mockUsePluginStore.mockReturnValue(detailWithoutPluginId)
+
+ const builder = createMockSubscriptionBuilder()
+ render()
+
+ expect(screen.getByTestId('form-field-dynamic_field')).toBeInTheDocument()
+ })
+
+ it('should handle undefined name in plugin detail', () => {
+ const detailWithoutName = createMockPluginDetail({
+ name: '',
+ })
+ mockUsePluginStore.mockReturnValue(detailWithoutName)
+
+ render()
+
+ expect(screen.getByTestId('log-viewer')).toBeInTheDocument()
+ })
+ })
+
+ describe('Log Data Fallback', () => {
+ it('should render log viewer even with empty logs', () => {
+ render()
+
+ // LogViewer should render with empty logs array (from mock)
+ expect(screen.getByTestId('log-viewer')).toBeInTheDocument()
+ })
+ })
+
+ describe('Disabled State', () => {
+ it('should show disabled state when verifying', () => {
+ setMockPendingStates(true, false)
+
+ render()
+
+ expect(screen.getByTestId('modal')).toHaveAttribute('data-disabled', 'true')
+ })
+
+ it('should show disabled state when building', () => {
+ setMockPendingStates(false, true)
+ const builder = createMockSubscriptionBuilder()
+
+ render()
+
+ expect(screen.getByTestId('modal')).toHaveAttribute('data-disabled', 'true')
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx
new file mode 100644
index 0000000000..0a23062717
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx
@@ -0,0 +1,1478 @@
+import type { SimpleDetail } from '../../store'
+import type { TriggerOAuthConfig, TriggerProviderApiEntity, TriggerSubscription, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { SupportedCreationMethods } from '@/app/components/plugins/types'
+import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
+import { CreateButtonType, CreateSubscriptionButton, DEFAULT_METHOD } from './index'
+
+// ==================== Mock Setup ====================
+
+// Mock shared state for portal
+let mockPortalOpenState = false
+
+vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
+ PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => {
+ mockPortalOpenState = open || false
+ return (
+
+ {children}
+
+ )
+ },
+ PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode, onClick?: () => void, className?: string }) => (
+
+ {children}
+
+ ),
+ PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
+ if (!mockPortalOpenState)
+ return null
+ return (
+
+ {children}
+
+ )
+ },
+}))
+
+// Mock Toast
+vi.mock('@/app/components/base/toast', () => ({
+ default: {
+ notify: vi.fn(),
+ },
+}))
+
+// Mock zustand store
+let mockStoreDetail: SimpleDetail | undefined
+vi.mock('../../store', () => ({
+ usePluginStore: (selector: (state: { detail: SimpleDetail | undefined }) => SimpleDetail | undefined) =>
+ selector({ detail: mockStoreDetail }),
+}))
+
+// Mock subscription list hook
+const mockSubscriptions: TriggerSubscription[] = []
+const mockRefetch = vi.fn()
+vi.mock('../use-subscription-list', () => ({
+ useSubscriptionList: () => ({
+ subscriptions: mockSubscriptions,
+ refetch: mockRefetch,
+ }),
+}))
+
+// Mock trigger service hooks
+let mockProviderInfo: { data: TriggerProviderApiEntity | undefined } = { data: undefined }
+let mockOAuthConfig: { data: TriggerOAuthConfig | undefined, refetch: () => void } = { data: undefined, refetch: vi.fn() }
+const mockInitiateOAuth = vi.fn()
+
+vi.mock('@/service/use-triggers', () => ({
+ useTriggerProviderInfo: () => mockProviderInfo,
+ useTriggerOAuthConfig: () => mockOAuthConfig,
+ useInitiateTriggerOAuth: () => ({
+ mutate: mockInitiateOAuth,
+ }),
+}))
+
+// Mock OAuth popup
+vi.mock('@/hooks/use-oauth', () => ({
+ openOAuthPopup: vi.fn((url: string, callback: (data?: unknown) => void) => {
+ callback({ success: true, subscriptionId: 'test-subscription' })
+ }),
+}))
+
+// Mock child modals
+vi.mock('./common-modal', () => ({
+ CommonCreateModal: ({ createType, onClose, builder }: {
+ createType: SupportedCreationMethods
+ onClose: () => void
+ builder?: TriggerSubscriptionBuilder
+ }) => (
+
+
+
+ ),
+}))
+
+vi.mock('./oauth-client', () => ({
+ OAuthClientSettingsModal: ({ oauthConfig, onClose, showOAuthCreateModal }: {
+ oauthConfig?: TriggerOAuthConfig
+ onClose: () => void
+ showOAuthCreateModal: (builder: TriggerSubscriptionBuilder) => void
+ }) => (
+
+
+
+
+ ),
+}))
+
+// Mock CustomSelect
+vi.mock('@/app/components/base/select/custom', () => ({
+ default: ({ options, value, onChange, CustomTrigger, CustomOption, containerProps }: {
+ options: Array<{ value: string, label: string, show: boolean, extra?: React.ReactNode, tag?: React.ReactNode }>
+ value: string
+ onChange: (value: string) => void
+ CustomTrigger: () => React.ReactNode
+ CustomOption: (option: { label: string, tag?: React.ReactNode, extra?: React.ReactNode }) => React.ReactNode
+ containerProps?: { open?: boolean }
+ }) => (
+
+
{CustomTrigger()}
+
+ {options?.map(option => (
+
onChange(option.value)}
+ >
+ {CustomOption(option)}
+
+ ))}
+
+
+ ),
+}))
+
+// ==================== Test Utilities ====================
+
+/**
+ * Factory function to create a TriggerProviderApiEntity with defaults
+ */
+const createProviderInfo = (overrides: Partial = {}): TriggerProviderApiEntity => ({
+ author: 'test-author',
+ name: 'test-provider',
+ label: { en_US: 'Test Provider', zh_Hans: 'Test Provider' },
+ description: { en_US: 'Test Description', zh_Hans: 'Test Description' },
+ icon: 'test-icon',
+ tags: [],
+ plugin_unique_identifier: 'test-plugin',
+ supported_creation_methods: [SupportedCreationMethods.MANUAL],
+ subscription_schema: [],
+ events: [],
+ ...overrides,
+})
+
+/**
+ * Factory function to create a TriggerOAuthConfig with defaults
+ */
+const createOAuthConfig = (overrides: Partial = {}): TriggerOAuthConfig => ({
+ configured: false,
+ custom_configured: false,
+ custom_enabled: false,
+ redirect_uri: 'https://test.com/callback',
+ oauth_client_schema: [],
+ params: {
+ client_id: '',
+ client_secret: '',
+ },
+ system_configured: false,
+ ...overrides,
+})
+
+/**
+ * Factory function to create a SimpleDetail with defaults
+ */
+const createStoreDetail = (overrides: Partial = {}): SimpleDetail => ({
+ plugin_id: 'test-plugin',
+ name: 'Test Plugin',
+ plugin_unique_identifier: 'test-plugin-unique',
+ id: 'test-id',
+ provider: 'test-provider',
+ declaration: {},
+ ...overrides,
+})
+
+/**
+ * Factory function to create a TriggerSubscription with defaults
+ */
+const createSubscription = (overrides: Partial = {}): TriggerSubscription => ({
+ id: 'test-subscription',
+ name: 'Test Subscription',
+ provider: 'test-provider',
+ credential_type: TriggerCredentialTypeEnum.ApiKey,
+ credentials: {},
+ endpoint: 'https://test.com',
+ parameters: {},
+ properties: {},
+ workflows_in_use: 0,
+ ...overrides,
+})
+
+/**
+ * Factory function to create default props
+ */
+const createDefaultProps = (overrides: Partial[0]> = {}) => ({
+ ...overrides,
+})
+
+/**
+ * Helper to set up mock data for testing
+ */
+const setupMocks = (config: {
+ providerInfo?: TriggerProviderApiEntity
+ oauthConfig?: TriggerOAuthConfig
+ storeDetail?: SimpleDetail
+ subscriptions?: TriggerSubscription[]
+} = {}) => {
+ mockProviderInfo = { data: config.providerInfo }
+ mockOAuthConfig = { data: config.oauthConfig, refetch: vi.fn() }
+ mockStoreDetail = config.storeDetail
+ mockSubscriptions.length = 0
+ if (config.subscriptions)
+ mockSubscriptions.push(...config.subscriptions)
+}
+
+// ==================== Tests ====================
+
+describe('CreateSubscriptionButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockPortalOpenState = false
+ setupMocks()
+ })
+
+ // ==================== Rendering Tests ====================
+ describe('Rendering', () => {
+ it('should render null when supportedMethods is empty', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({ supported_creation_methods: [] }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('should render without crashing when supportedMethods is provided', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({ supported_creation_methods: [SupportedCreationMethods.MANUAL] }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ expect(container).not.toBeEmptyDOMElement()
+ })
+
+ it('should render full button by default', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({ supported_creation_methods: [SupportedCreationMethods.MANUAL] }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should render icon button when buttonType is ICON_BUTTON', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({ supported_creation_methods: [SupportedCreationMethods.MANUAL] }),
+ })
+ const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
+
+ // Act
+ render()
+
+ // Assert
+ const actionButton = screen.getByTestId('custom-trigger')
+ expect(actionButton).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Props Testing ====================
+ describe('Props', () => {
+ it('should apply default buttonType as FULL_BUTTON', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({ supported_creation_methods: [SupportedCreationMethods.MANUAL] }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ })
+
+ it('should apply shape prop correctly', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({ supported_creation_methods: [SupportedCreationMethods.MANUAL] }),
+ })
+ const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON, shape: 'circle' })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== State Management ====================
+ describe('State Management', () => {
+ it('should show CommonCreateModal when selectedCreateInfo is set', async () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY],
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Click on MANUAL option to set selectedCreateInfo
+ const manualOption = screen.getByTestId(`option-${SupportedCreationMethods.MANUAL}`)
+ fireEvent.click(manualOption)
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByTestId('common-create-modal')).toBeInTheDocument()
+ expect(screen.getByTestId('common-create-modal')).toHaveAttribute('data-create-type', SupportedCreationMethods.MANUAL)
+ })
+ })
+
+ it('should close CommonCreateModal when onClose is called', async () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY],
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Open modal
+ const manualOption = screen.getByTestId(`option-${SupportedCreationMethods.MANUAL}`)
+ fireEvent.click(manualOption)
+
+ await waitFor(() => {
+ expect(screen.getByTestId('common-create-modal')).toBeInTheDocument()
+ })
+
+ // Close modal
+ fireEvent.click(screen.getByTestId('close-modal'))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.queryByTestId('common-create-modal')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should show OAuthClientSettingsModal when oauth settings is clicked', async () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH],
+ }),
+ oauthConfig: createOAuthConfig({ configured: false }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Click on OAuth option (which should show client settings when not configured)
+ const oauthOption = screen.getByTestId(`option-${SupportedCreationMethods.OAUTH}`)
+ fireEvent.click(oauthOption)
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByTestId('oauth-client-modal')).toBeInTheDocument()
+ })
+ })
+
+ it('should close OAuthClientSettingsModal and refetch config when closed', async () => {
+ // Arrange
+ const mockRefetchOAuth = vi.fn()
+ mockOAuthConfig = { data: createOAuthConfig({ configured: false }), refetch: mockRefetchOAuth }
+
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH],
+ }),
+ oauthConfig: createOAuthConfig({ configured: false }),
+ })
+ // Reset after setupMocks to keep our custom refetch
+ mockOAuthConfig.refetch = mockRefetchOAuth
+
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Open OAuth modal
+ const oauthOption = screen.getByTestId(`option-${SupportedCreationMethods.OAUTH}`)
+ fireEvent.click(oauthOption)
+
+ await waitFor(() => {
+ expect(screen.getByTestId('oauth-client-modal')).toBeInTheDocument()
+ })
+
+ // Close modal
+ fireEvent.click(screen.getByTestId('close-oauth-modal'))
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.queryByTestId('oauth-client-modal')).not.toBeInTheDocument()
+ expect(mockRefetchOAuth).toHaveBeenCalled()
+ })
+ })
+ })
+
+ // ==================== Memoization Logic ====================
+ describe('Memoization - buttonTextMap', () => {
+ it('should display correct button text for OAUTH method', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH],
+ }),
+ oauthConfig: createOAuthConfig({ configured: true }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert - OAuth mode renders with settings button, use getAllByRole
+ const buttons = screen.getAllByRole('button')
+ expect(buttons[0]).toHaveTextContent('pluginTrigger.subscription.createButton.oauth')
+ })
+
+ it('should display correct button text for APIKEY method', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.APIKEY],
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('button')).toHaveTextContent('pluginTrigger.subscription.createButton.apiKey')
+ })
+
+ it('should display correct button text for MANUAL method', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL],
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('button')).toHaveTextContent('pluginTrigger.subscription.createButton.manual')
+ })
+
+ it('should display default button text when multiple methods are supported', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY],
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('button')).toHaveTextContent('pluginTrigger.subscription.empty.button')
+ })
+ })
+
+ describe('Memoization - allOptions', () => {
+ it('should show only OAUTH option when only OAUTH is supported', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH],
+ }),
+ oauthConfig: createOAuthConfig(),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ const customSelect = screen.getByTestId('custom-select')
+ expect(customSelect).toHaveAttribute('data-options-count', '1')
+ })
+
+ it('should show all options when all methods are supported', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [
+ SupportedCreationMethods.OAUTH,
+ SupportedCreationMethods.APIKEY,
+ SupportedCreationMethods.MANUAL,
+ ],
+ }),
+ oauthConfig: createOAuthConfig(),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ const customSelect = screen.getByTestId('custom-select')
+ expect(customSelect).toHaveAttribute('data-options-count', '3')
+ })
+
+ it('should show custom badge when OAuth custom is enabled and configured', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH],
+ }),
+ oauthConfig: createOAuthConfig({
+ custom_enabled: true,
+ custom_configured: true,
+ configured: true,
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert - Custom badge should appear in the button
+ const buttons = screen.getAllByRole('button')
+ expect(buttons[0]).toHaveTextContent('plugin.auth.custom')
+ })
+
+ it('should not show custom badge when OAuth custom is not configured', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH],
+ }),
+ oauthConfig: createOAuthConfig({
+ custom_enabled: true,
+ custom_configured: false,
+ configured: true,
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert - The button should be there but no custom badge text
+ const buttons = screen.getAllByRole('button')
+ expect(buttons[0]).not.toHaveTextContent('plugin.auth.custom')
+ })
+ })
+
+ describe('Memoization - methodType', () => {
+ it('should set methodType to DEFAULT_METHOD when multiple methods supported', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY],
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ const customSelect = screen.getByTestId('custom-select')
+ expect(customSelect).toHaveAttribute('data-value', DEFAULT_METHOD)
+ })
+
+ it('should set methodType to single method when only one supported', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL],
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ const customSelect = screen.getByTestId('custom-select')
+ expect(customSelect).toHaveAttribute('data-value', SupportedCreationMethods.MANUAL)
+ })
+ })
+
+ // ==================== User Interactions ====================
+ // Helper to create max subscriptions array
+ const createMaxSubscriptions = () =>
+ Array.from({ length: 10 }, (_, i) => createSubscription({ id: `sub-${i}` }))
+
+ describe('User Interactions - onClickCreate', () => {
+ it('should prevent action when subscription count is at max', () => {
+ // Arrange
+ const maxSubscriptions = createMaxSubscriptions()
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL],
+ }),
+ subscriptions: maxSubscriptions,
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ // Assert - modal should not open
+ expect(screen.queryByTestId('common-create-modal')).not.toBeInTheDocument()
+ })
+
+ it('should call onChooseCreateType when single method (non-OAuth) is used', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL],
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ // Assert - modal should open
+ expect(screen.getByTestId('common-create-modal')).toBeInTheDocument()
+ })
+
+ it('should not call onChooseCreateType for DEFAULT_METHOD or single OAuth', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH],
+ }),
+ oauthConfig: createOAuthConfig({ configured: true }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+ // For OAuth mode, there are multiple buttons; get the primary button (first one)
+ const buttons = screen.getAllByRole('button')
+ fireEvent.click(buttons[0])
+
+ // Assert - For single OAuth, should not directly create but wait for dropdown
+ // The modal should not immediately open
+ expect(screen.queryByTestId('common-create-modal')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions - onChooseCreateType', () => {
+ it('should open OAuth client settings modal when OAuth not configured', async () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH, SupportedCreationMethods.MANUAL],
+ }),
+ oauthConfig: createOAuthConfig({ configured: false }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Click on OAuth option
+ const oauthOption = screen.getByTestId(`option-${SupportedCreationMethods.OAUTH}`)
+ fireEvent.click(oauthOption)
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByTestId('oauth-client-modal')).toBeInTheDocument()
+ })
+ })
+
+ it('should initiate OAuth flow when OAuth is configured', async () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH, SupportedCreationMethods.MANUAL],
+ }),
+ oauthConfig: createOAuthConfig({ configured: true }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Click on OAuth option
+ const oauthOption = screen.getByTestId(`option-${SupportedCreationMethods.OAUTH}`)
+ fireEvent.click(oauthOption)
+
+ // Assert
+ await waitFor(() => {
+ expect(mockInitiateOAuth).toHaveBeenCalledWith('test-provider', expect.any(Object))
+ })
+ })
+
+ it('should set selectedCreateInfo for APIKEY type', async () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.APIKEY, SupportedCreationMethods.MANUAL],
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Click on APIKEY option
+ const apiKeyOption = screen.getByTestId(`option-${SupportedCreationMethods.APIKEY}`)
+ fireEvent.click(apiKeyOption)
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByTestId('common-create-modal')).toBeInTheDocument()
+ expect(screen.getByTestId('common-create-modal')).toHaveAttribute('data-create-type', SupportedCreationMethods.APIKEY)
+ })
+ })
+
+ it('should set selectedCreateInfo for MANUAL type', async () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY],
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Click on MANUAL option
+ const manualOption = screen.getByTestId(`option-${SupportedCreationMethods.MANUAL}`)
+ fireEvent.click(manualOption)
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByTestId('common-create-modal')).toBeInTheDocument()
+ expect(screen.getByTestId('common-create-modal')).toHaveAttribute('data-create-type', SupportedCreationMethods.MANUAL)
+ })
+ })
+ })
+
+ describe('User Interactions - onClickClientSettings', () => {
+ it('should open OAuth client settings modal when settings icon clicked', async () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH],
+ }),
+ oauthConfig: createOAuthConfig({ configured: true }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Find the settings div inside the button (p-2 class)
+ const buttons = screen.getAllByRole('button')
+ const primaryButton = buttons[0]
+ const settingsDiv = primaryButton.querySelector('.p-2')
+
+ // Assert that settings div exists and click it
+ expect(settingsDiv).toBeInTheDocument()
+ if (settingsDiv) {
+ fireEvent.click(settingsDiv)
+
+ // Assert
+ await waitFor(() => {
+ expect(screen.getByTestId('oauth-client-modal')).toBeInTheDocument()
+ })
+ }
+ })
+ })
+
+ // ==================== API Calls ====================
+ describe('API Calls', () => {
+ it('should call useTriggerProviderInfo with correct provider', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail({ provider: 'my-provider' }),
+ providerInfo: createProviderInfo({ supported_creation_methods: [SupportedCreationMethods.MANUAL] }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert - Component renders, which means hook was called
+ expect(screen.getByTestId('custom-select')).toBeInTheDocument()
+ })
+
+ it('should handle OAuth initiation success', async () => {
+ // Arrange
+ const mockBuilder: TriggerSubscriptionBuilder = {
+ id: 'oauth-builder',
+ name: 'OAuth Builder',
+ provider: 'test-provider',
+ credential_type: TriggerCredentialTypeEnum.Oauth2,
+ credentials: {},
+ endpoint: 'https://test.com',
+ parameters: {},
+ properties: {},
+ workflows_in_use: 0,
+ }
+
+ type OAuthSuccessResponse = {
+ authorization_url: string
+ subscription_builder: TriggerSubscriptionBuilder
+ }
+ type OAuthCallbacks = { onSuccess: (response: OAuthSuccessResponse) => void }
+
+ mockInitiateOAuth.mockImplementation((_provider: string, callbacks: OAuthCallbacks) => {
+ callbacks.onSuccess({
+ authorization_url: 'https://oauth.test.com/authorize',
+ subscription_builder: mockBuilder,
+ })
+ })
+
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH, SupportedCreationMethods.MANUAL],
+ }),
+ oauthConfig: createOAuthConfig({ configured: true }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Click on OAuth option
+ const oauthOption = screen.getByTestId(`option-${SupportedCreationMethods.OAUTH}`)
+ fireEvent.click(oauthOption)
+
+ // Assert - modal should open with OAuth type and builder
+ await waitFor(() => {
+ expect(screen.getByTestId('common-create-modal')).toBeInTheDocument()
+ expect(screen.getByTestId('common-create-modal')).toHaveAttribute('data-has-builder', 'true')
+ })
+ })
+
+ it('should handle OAuth initiation error', async () => {
+ // Arrange
+ const Toast = await import('@/app/components/base/toast')
+
+ mockInitiateOAuth.mockImplementation((_provider: string, callbacks: { onError: () => void }) => {
+ callbacks.onError()
+ })
+
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH, SupportedCreationMethods.MANUAL],
+ }),
+ oauthConfig: createOAuthConfig({ configured: true }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Click on OAuth option
+ const oauthOption = screen.getByTestId(`option-${SupportedCreationMethods.OAUTH}`)
+ fireEvent.click(oauthOption)
+
+ // Assert
+ await waitFor(() => {
+ expect(Toast.default.notify).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'error' }),
+ )
+ })
+ })
+ })
+
+ // ==================== Edge Cases ====================
+ describe('Edge Cases', () => {
+ it('should handle null subscriptions gracefully', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({ supported_creation_methods: [SupportedCreationMethods.MANUAL] }),
+ subscriptions: undefined,
+ })
+ const props = createDefaultProps()
+
+ // Act
+ const { container } = render()
+
+ // Assert
+ expect(container).not.toBeEmptyDOMElement()
+ })
+
+ it('should handle undefined provider gracefully', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: undefined,
+ providerInfo: createProviderInfo({ supported_creation_methods: [SupportedCreationMethods.MANUAL] }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert - component should still render
+ expect(screen.getByTestId('custom-select')).toBeInTheDocument()
+ })
+
+ it('should handle empty oauthConfig gracefully', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH],
+ }),
+ oauthConfig: undefined,
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('custom-select')).toBeInTheDocument()
+ })
+
+ it('should show max count tooltip when subscriptions reach limit', () => {
+ // Arrange
+ const maxSubscriptions = Array.from({ length: 10 }, (_, i) =>
+ createSubscription({ id: `sub-${i}` }))
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL],
+ }),
+ subscriptions: maxSubscriptions,
+ })
+ const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
+
+ // Act
+ render()
+
+ // Assert - ActionButton should be in disabled state
+ expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
+ })
+
+ it('should handle showOAuthCreateModal callback from OAuthClientSettingsModal', async () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH],
+ }),
+ oauthConfig: createOAuthConfig({ configured: false }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Open OAuth modal
+ const oauthOption = screen.getByTestId(`option-${SupportedCreationMethods.OAUTH}`)
+ fireEvent.click(oauthOption)
+
+ await waitFor(() => {
+ expect(screen.getByTestId('oauth-client-modal')).toBeInTheDocument()
+ })
+
+ // Click show create modal button
+ fireEvent.click(screen.getByTestId('show-create-modal'))
+
+ // Assert - CommonCreateModal should be shown with OAuth type and builder
+ await waitFor(() => {
+ expect(screen.getByTestId('common-create-modal')).toBeInTheDocument()
+ expect(screen.getByTestId('common-create-modal')).toHaveAttribute('data-create-type', SupportedCreationMethods.OAUTH)
+ expect(screen.getByTestId('common-create-modal')).toHaveAttribute('data-has-builder', 'true')
+ })
+ })
+ })
+
+ // ==================== Conditional Rendering ====================
+ describe('Conditional Rendering', () => {
+ it('should render settings icon for OAuth in full button mode', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH],
+ }),
+ oauthConfig: createOAuthConfig({ configured: true }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert - settings icon should be present in button, OAuth mode has multiple buttons
+ const buttons = screen.getAllByRole('button')
+ const primaryButton = buttons[0]
+ const settingsDiv = primaryButton.querySelector('.p-2')
+ expect(settingsDiv).toBeInTheDocument()
+ })
+
+ it('should not render settings icon for non-OAuth methods', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL],
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert - should not have settings divider
+ const button = screen.getByRole('button')
+ const divider = button.querySelector('.bg-text-primary-on-surface')
+ expect(divider).not.toBeInTheDocument()
+ })
+
+ it('should apply disabled state when subscription count reaches max', () => {
+ // Arrange
+ const maxSubscriptions = Array.from({ length: 10 }, (_, i) =>
+ createSubscription({ id: `sub-${i}` }))
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL],
+ }),
+ subscriptions: maxSubscriptions,
+ })
+ const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
+
+ // Act
+ render()
+
+ // Assert - icon button should exist
+ expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
+ })
+
+ it('should apply circle shape class when shape is circle', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL],
+ }),
+ })
+ const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON, shape: 'circle' })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== CustomSelect containerProps ====================
+ describe('CustomSelect containerProps', () => {
+ it('should set open to undefined for default method with multiple supported methods', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY],
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert - open should be undefined to allow dropdown to work
+ const customSelect = screen.getByTestId('custom-select')
+ expect(customSelect.getAttribute('data-container-open')).toBeNull()
+ })
+
+ it('should set open to undefined for single OAuth method', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH],
+ }),
+ oauthConfig: createOAuthConfig({ configured: true }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert - for single OAuth, open should be undefined
+ const customSelect = screen.getByTestId('custom-select')
+ expect(customSelect.getAttribute('data-container-open')).toBeNull()
+ })
+
+ it('should set open to false for single non-OAuth method', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL],
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Assert - for single non-OAuth, dropdown should be disabled (open = false)
+ const customSelect = screen.getByTestId('custom-select')
+ expect(customSelect).toHaveAttribute('data-container-open', 'false')
+ })
+ })
+
+ // ==================== Button Type Variations ====================
+ describe('Button Type Variations', () => {
+ it('should render full button with grow class', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL],
+ }),
+ })
+ const props = createDefaultProps({ buttonType: CreateButtonType.FULL_BUTTON })
+
+ // Act
+ render()
+
+ // Assert
+ const button = screen.getByRole('button')
+ expect(button).toHaveClass('w-full')
+ })
+
+ it('should render icon button with float-right class', () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL],
+ }),
+ })
+ const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
+ })
+ })
+
+ // ==================== Export Verification ====================
+ describe('Export Verification', () => {
+ it('should export CreateButtonType enum', () => {
+ // Assert
+ expect(CreateButtonType.FULL_BUTTON).toBe('full-button')
+ expect(CreateButtonType.ICON_BUTTON).toBe('icon-button')
+ })
+
+ it('should export DEFAULT_METHOD constant', () => {
+ // Assert
+ expect(DEFAULT_METHOD).toBe('default')
+ })
+
+ it('should export CreateSubscriptionButton component', () => {
+ // Assert
+ expect(typeof CreateSubscriptionButton).toBe('function')
+ })
+ })
+
+ // ==================== CommonCreateModal Integration Tests ====================
+ // These tests verify that CreateSubscriptionButton correctly interacts with CommonCreateModal
+ describe('CommonCreateModal Integration', () => {
+ it('should pass correct createType to CommonCreateModal for MANUAL', async () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY],
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Click on MANUAL option
+ const manualOption = screen.getByTestId(`option-${SupportedCreationMethods.MANUAL}`)
+ fireEvent.click(manualOption)
+
+ // Assert
+ await waitFor(() => {
+ const modal = screen.getByTestId('common-create-modal')
+ expect(modal).toHaveAttribute('data-create-type', SupportedCreationMethods.MANUAL)
+ })
+ })
+
+ it('should pass correct createType to CommonCreateModal for APIKEY', async () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY],
+ }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Click on APIKEY option
+ const apiKeyOption = screen.getByTestId(`option-${SupportedCreationMethods.APIKEY}`)
+ fireEvent.click(apiKeyOption)
+
+ // Assert
+ await waitFor(() => {
+ const modal = screen.getByTestId('common-create-modal')
+ expect(modal).toHaveAttribute('data-create-type', SupportedCreationMethods.APIKEY)
+ })
+ })
+
+ it('should pass builder to CommonCreateModal for OAuth flow', async () => {
+ // Arrange
+ const mockBuilder: TriggerSubscriptionBuilder = {
+ id: 'oauth-builder',
+ name: 'OAuth Builder',
+ provider: 'test-provider',
+ credential_type: TriggerCredentialTypeEnum.Oauth2,
+ credentials: {},
+ endpoint: 'https://test.com',
+ parameters: {},
+ properties: {},
+ workflows_in_use: 0,
+ }
+
+ type OAuthSuccessResponse = {
+ authorization_url: string
+ subscription_builder: TriggerSubscriptionBuilder
+ }
+ type OAuthCallbacks = { onSuccess: (response: OAuthSuccessResponse) => void }
+
+ mockInitiateOAuth.mockImplementation((_provider: string, callbacks: OAuthCallbacks) => {
+ callbacks.onSuccess({
+ authorization_url: 'https://oauth.test.com/authorize',
+ subscription_builder: mockBuilder,
+ })
+ })
+
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH, SupportedCreationMethods.MANUAL],
+ }),
+ oauthConfig: createOAuthConfig({ configured: true }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Click on OAuth option
+ const oauthOption = screen.getByTestId(`option-${SupportedCreationMethods.OAUTH}`)
+ fireEvent.click(oauthOption)
+
+ // Assert
+ await waitFor(() => {
+ const modal = screen.getByTestId('common-create-modal')
+ expect(modal).toHaveAttribute('data-has-builder', 'true')
+ })
+ })
+ })
+
+ // ==================== OAuthClientSettingsModal Integration Tests ====================
+ // These tests verify that CreateSubscriptionButton correctly interacts with OAuthClientSettingsModal
+ describe('OAuthClientSettingsModal Integration', () => {
+ it('should pass oauthConfig to OAuthClientSettingsModal', async () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH],
+ }),
+ oauthConfig: createOAuthConfig({ configured: false }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Click on OAuth option (opens settings when not configured)
+ const oauthOption = screen.getByTestId(`option-${SupportedCreationMethods.OAUTH}`)
+ fireEvent.click(oauthOption)
+
+ // Assert
+ await waitFor(() => {
+ const modal = screen.getByTestId('oauth-client-modal')
+ expect(modal).toHaveAttribute('data-has-config', 'true')
+ })
+ })
+
+ it('should refetch OAuth config when OAuthClientSettingsModal is closed', async () => {
+ // Arrange
+ const mockRefetchOAuth = vi.fn()
+ mockOAuthConfig = { data: createOAuthConfig({ configured: false }), refetch: mockRefetchOAuth }
+
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH],
+ }),
+ oauthConfig: createOAuthConfig({ configured: false }),
+ })
+ // Reset after setupMocks to keep our custom refetch
+ mockOAuthConfig.refetch = mockRefetchOAuth
+
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Open OAuth modal
+ const oauthOption = screen.getByTestId(`option-${SupportedCreationMethods.OAUTH}`)
+ fireEvent.click(oauthOption)
+
+ await waitFor(() => {
+ expect(screen.getByTestId('oauth-client-modal')).toBeInTheDocument()
+ })
+
+ // Close modal
+ fireEvent.click(screen.getByTestId('close-oauth-modal'))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockRefetchOAuth).toHaveBeenCalled()
+ })
+ })
+
+ it('should show CommonCreateModal with builder when showOAuthCreateModal callback is invoked', async () => {
+ // Arrange
+ setupMocks({
+ storeDetail: createStoreDetail(),
+ providerInfo: createProviderInfo({
+ supported_creation_methods: [SupportedCreationMethods.OAUTH],
+ }),
+ oauthConfig: createOAuthConfig({ configured: false }),
+ })
+ const props = createDefaultProps()
+
+ // Act
+ render()
+
+ // Open OAuth modal
+ const oauthOption = screen.getByTestId(`option-${SupportedCreationMethods.OAUTH}`)
+ fireEvent.click(oauthOption)
+
+ await waitFor(() => {
+ expect(screen.getByTestId('oauth-client-modal')).toBeInTheDocument()
+ })
+
+ // Click showOAuthCreateModal button
+ fireEvent.click(screen.getByTestId('show-create-modal'))
+
+ // Assert - CommonCreateModal should appear with OAuth type and builder
+ await waitFor(() => {
+ expect(screen.getByTestId('common-create-modal')).toBeInTheDocument()
+ expect(screen.getByTestId('common-create-modal')).toHaveAttribute('data-create-type', SupportedCreationMethods.OAUTH)
+ expect(screen.getByTestId('common-create-modal')).toHaveAttribute('data-has-builder', 'true')
+ })
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx
new file mode 100644
index 0000000000..8c2a4109c6
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx
@@ -0,0 +1,1250 @@
+import type { TriggerOAuthConfig, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import * as React from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
+
+// Import after mocks
+import { OAuthClientSettingsModal } from './oauth-client'
+
+// ============================================================================
+// Type Definitions
+// ============================================================================
+
+type PluginDetail = {
+ plugin_id: string
+ provider: string
+ name: string
+}
+
+// ============================================================================
+// Mock Factory Functions
+// ============================================================================
+
+function createMockOAuthConfig(overrides: Partial = {}): TriggerOAuthConfig {
+ return {
+ configured: true,
+ custom_configured: false,
+ custom_enabled: false,
+ system_configured: true,
+ redirect_uri: 'https://example.com/oauth/callback',
+ params: {
+ client_id: 'default-client-id',
+ client_secret: 'default-client-secret',
+ },
+ oauth_client_schema: [
+ { name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown },
+ { name: 'client_secret', type: 'secret-input' as unknown, required: true, label: { 'en-US': 'Client Secret' } as unknown },
+ ] as TriggerOAuthConfig['oauth_client_schema'],
+ ...overrides,
+ }
+}
+
+function createMockPluginDetail(overrides: Partial = {}): PluginDetail {
+ return {
+ plugin_id: 'test-plugin-id',
+ provider: 'test-provider',
+ name: 'Test Plugin',
+ ...overrides,
+ }
+}
+
+function createMockSubscriptionBuilder(overrides: Partial = {}): TriggerSubscriptionBuilder {
+ return {
+ id: 'builder-123',
+ name: 'Test Builder',
+ provider: 'test-provider',
+ credential_type: TriggerCredentialTypeEnum.Oauth2,
+ credentials: {},
+ endpoint: 'https://example.com/callback',
+ parameters: {},
+ properties: {},
+ workflows_in_use: 0,
+ ...overrides,
+ }
+}
+
+// ============================================================================
+// Mock Setup
+// ============================================================================
+
+const mockTranslate = vi.fn((key: string) => key)
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: mockTranslate,
+ }),
+}))
+
+// Mock plugin store
+const mockPluginDetail = createMockPluginDetail()
+const mockUsePluginStore = vi.fn(() => mockPluginDetail)
+vi.mock('../../store', () => ({
+ usePluginStore: () => mockUsePluginStore(),
+}))
+
+// Mock service hooks
+const mockInitiateOAuth = vi.fn()
+const mockVerifyBuilder = vi.fn()
+const mockConfigureOAuth = vi.fn()
+const mockDeleteOAuth = vi.fn()
+
+vi.mock('@/service/use-triggers', () => ({
+ useInitiateTriggerOAuth: () => ({
+ mutate: mockInitiateOAuth,
+ }),
+ useVerifyAndUpdateTriggerSubscriptionBuilder: () => ({
+ mutate: mockVerifyBuilder,
+ }),
+ useConfigureTriggerOAuth: () => ({
+ mutate: mockConfigureOAuth,
+ }),
+ useDeleteTriggerOAuth: () => ({
+ mutate: mockDeleteOAuth,
+ }),
+}))
+
+// Mock OAuth popup
+const mockOpenOAuthPopup = vi.fn()
+vi.mock('@/hooks/use-oauth', () => ({
+ openOAuthPopup: (url: string, callback: (data: unknown) => void) => mockOpenOAuthPopup(url, callback),
+}))
+
+// Mock toast
+const mockToastNotify = vi.fn()
+vi.mock('@/app/components/base/toast', () => ({
+ default: {
+ notify: (params: unknown) => mockToastNotify(params),
+ },
+}))
+
+// Mock clipboard API
+const mockClipboardWriteText = vi.fn()
+Object.assign(navigator, {
+ clipboard: {
+ writeText: mockClipboardWriteText,
+ },
+})
+
+// Mock Modal component
+vi.mock('@/app/components/base/modal/modal', () => ({
+ default: ({
+ children,
+ onClose,
+ onConfirm,
+ onCancel,
+ title,
+ confirmButtonText,
+ cancelButtonText,
+ footerSlot,
+ onExtraButtonClick,
+ extraButtonText,
+ }: {
+ children: React.ReactNode
+ onClose: () => void
+ onConfirm: () => void
+ onCancel: () => void
+ title: string
+ confirmButtonText: string
+ cancelButtonText?: string
+ footerSlot?: React.ReactNode
+ onExtraButtonClick?: () => void
+ extraButtonText?: string
+ }) => (
+
+
{title}
+
{children}
+
+ {footerSlot}
+ {extraButtonText && (
+
+ )}
+ {cancelButtonText && (
+
+ )}
+
+
+
+
+ ),
+}))
+
+// Mock Button component
+vi.mock('@/app/components/base/button', () => ({
+ default: ({ children, onClick, variant, className }: {
+ children: React.ReactNode
+ onClick?: () => void
+ variant?: string
+ className?: string
+ }) => (
+
+ ),
+}))
+// Configurable form mock values
+let mockFormValues: { values: Record, isCheckValidated: boolean } = {
+ values: { client_id: 'test-client-id', client_secret: 'test-client-secret' },
+ isCheckValidated: true,
+}
+const setMockFormValues = (values: typeof mockFormValues) => {
+ mockFormValues = values
+}
+
+vi.mock('@/app/components/base/form/components/base', () => ({
+ BaseForm: React.forwardRef((
+ { formSchemas }: { formSchemas: Array<{ name: string, default?: string }> },
+ ref: React.ForwardedRef<{ getFormValues: () => { values: Record, isCheckValidated: boolean } }>,
+ ) => {
+ React.useImperativeHandle(ref, () => ({
+ getFormValues: () => mockFormValues,
+ }))
+ return (
+
+ {formSchemas.map(schema => (
+
+ ))}
+
+ )
+ }),
+}))
+
+// Mock OptionCard component
+vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({
+ default: ({ title, onSelect, selected, className }: {
+ title: string
+ onSelect: () => void
+ selected: boolean
+ className?: string
+ }) => (
+
+ {title}
+
+ ),
+}))
+
+// ============================================================================
+// Test Suites
+// ============================================================================
+
+describe('OAuthClientSettingsModal', () => {
+ const defaultProps = {
+ oauthConfig: createMockOAuthConfig(),
+ onClose: vi.fn(),
+ showOAuthCreateModal: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUsePluginStore.mockReturnValue(mockPluginDetail)
+ mockClipboardWriteText.mockResolvedValue(undefined)
+ // Reset form values to default
+ setMockFormValues({
+ values: { client_id: 'test-client-id', client_secret: 'test-client-secret' },
+ isCheckValidated: true,
+ })
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render modal with correct title', () => {
+ render()
+
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('pluginTrigger.modal.oauth.title')
+ })
+
+ it('should render client type selector when system_configured is true', () => {
+ render()
+
+ expect(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')).toBeInTheDocument()
+ expect(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')).toBeInTheDocument()
+ })
+
+ it('should not render client type selector when system_configured is false', () => {
+ const configWithoutSystemConfigured = createMockOAuthConfig({
+ system_configured: false,
+ })
+
+ render()
+
+ expect(screen.queryByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')).not.toBeInTheDocument()
+ })
+
+ it('should render redirect URI info when custom client type is selected', () => {
+ const configWithCustomEnabled = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ })
+
+ render()
+
+ expect(screen.getByText('pluginTrigger.modal.oauthRedirectInfo')).toBeInTheDocument()
+ expect(screen.getByText('https://example.com/oauth/callback')).toBeInTheDocument()
+ })
+
+ it('should render client form when custom type is selected', () => {
+ const configWithCustomEnabled = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ })
+
+ render()
+
+ expect(screen.getByTestId('base-form')).toBeInTheDocument()
+ })
+
+ it('should show remove button when custom_enabled and params exist', () => {
+ const configWithCustomEnabled = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ params: { client_id: 'test-id', client_secret: 'test-secret' },
+ })
+
+ render()
+
+ expect(screen.getByText('common.operation.remove')).toBeInTheDocument()
+ })
+ })
+
+ describe('Client Type Selection', () => {
+ it('should default to Default client type when system_configured is true', () => {
+ render()
+
+ const defaultCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')
+ expect(defaultCard).toHaveAttribute('data-selected', 'true')
+ })
+
+ it('should switch to Custom client type when Custom card is clicked', () => {
+ render()
+
+ const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')
+ fireEvent.click(customCard)
+
+ expect(customCard).toHaveAttribute('data-selected', 'true')
+ })
+
+ it('should switch back to Default client type when Default card is clicked', () => {
+ render()
+
+ const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')
+ fireEvent.click(customCard)
+
+ const defaultCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.default')
+ fireEvent.click(defaultCard)
+
+ expect(defaultCard).toHaveAttribute('data-selected', 'true')
+ })
+ })
+
+ describe('Copy Redirect URI', () => {
+ it('should copy redirect URI when copy button is clicked', async () => {
+ const configWithCustomEnabled = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ })
+
+ render()
+
+ const copyButton = screen.getByText('common.operation.copy')
+ fireEvent.click(copyButton)
+
+ await waitFor(() => {
+ expect(mockClipboardWriteText).toHaveBeenCalledWith('https://example.com/oauth/callback')
+ })
+
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'common.actionMsg.copySuccessfully',
+ })
+ })
+ })
+
+ describe('OAuth Authorization Flow', () => {
+ it('should initiate OAuth when confirm button is clicked', () => {
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ expect(mockConfigureOAuth).toHaveBeenCalled()
+ })
+
+ it('should open OAuth popup after successful configuration', () => {
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+ mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
+ onSuccess({
+ authorization_url: 'https://oauth.example.com/authorize',
+ subscription_builder: createMockSubscriptionBuilder(),
+ })
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ expect(mockOpenOAuthPopup).toHaveBeenCalledWith(
+ 'https://oauth.example.com/authorize',
+ expect.any(Function),
+ )
+ })
+
+ it('should show success toast and close modal when OAuth callback succeeds', () => {
+ const mockOnClose = vi.fn()
+ const mockShowOAuthCreateModal = vi.fn()
+
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+ mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
+ const builder = createMockSubscriptionBuilder()
+ onSuccess({
+ authorization_url: 'https://oauth.example.com/authorize',
+ subscription_builder: builder,
+ })
+ })
+ mockOpenOAuthPopup.mockImplementation((url, callback) => {
+ callback({ success: true })
+ })
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'pluginTrigger.modal.oauth.authorization.authSuccess',
+ })
+ expect(mockOnClose).toHaveBeenCalled()
+ })
+
+ it('should show error toast when OAuth initiation fails', () => {
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+ mockInitiateOAuth.mockImplementation((provider, { onError }) => {
+ onError(new Error('OAuth failed'))
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'pluginTrigger.modal.oauth.authorization.authFailed',
+ })
+ })
+ })
+
+ describe('Save Only Flow', () => {
+ it('should save configuration without authorization when cancel button is clicked', () => {
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-cancel'))
+
+ expect(mockConfigureOAuth).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: 'test-provider',
+ enabled: false,
+ }),
+ expect.any(Object),
+ )
+ })
+
+ it('should show success toast when save only succeeds', () => {
+ const mockOnClose = vi.fn()
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-cancel'))
+
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'pluginTrigger.modal.oauth.save.success',
+ })
+ expect(mockOnClose).toHaveBeenCalled()
+ })
+ })
+
+ describe('Remove OAuth Configuration', () => {
+ it('should call deleteOAuth when remove button is clicked', () => {
+ const configWithCustomEnabled = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ params: { client_id: 'test-id', client_secret: 'test-secret' },
+ })
+
+ render()
+
+ const removeButton = screen.getByText('common.operation.remove')
+ fireEvent.click(removeButton)
+
+ expect(mockDeleteOAuth).toHaveBeenCalledWith(
+ 'test-provider',
+ expect.any(Object),
+ )
+ })
+
+ it('should show success toast when remove succeeds', () => {
+ const mockOnClose = vi.fn()
+ const configWithCustomEnabled = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ params: { client_id: 'test-id', client_secret: 'test-secret' },
+ })
+
+ mockDeleteOAuth.mockImplementation((provider, { onSuccess }) => {
+ onSuccess()
+ })
+
+ render(
+ ,
+ )
+
+ const removeButton = screen.getByText('common.operation.remove')
+ fireEvent.click(removeButton)
+
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'success',
+ message: 'pluginTrigger.modal.oauth.remove.success',
+ })
+ expect(mockOnClose).toHaveBeenCalled()
+ })
+
+ it('should show error toast when remove fails', () => {
+ const configWithCustomEnabled = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ params: { client_id: 'test-id', client_secret: 'test-secret' },
+ })
+
+ mockDeleteOAuth.mockImplementation((provider, { onError }) => {
+ onError(new Error('Delete failed'))
+ })
+
+ render()
+
+ const removeButton = screen.getByText('common.operation.remove')
+ fireEvent.click(removeButton)
+
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'Delete failed',
+ })
+ })
+ })
+
+ describe('Modal Actions', () => {
+ it('should call onClose when close button is clicked', () => {
+ const mockOnClose = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-close'))
+
+ expect(mockOnClose).toHaveBeenCalled()
+ })
+
+ it('should call onClose when extra button (cancel) is clicked', () => {
+ const mockOnClose = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-extra'))
+
+ expect(mockOnClose).toHaveBeenCalled()
+ })
+ })
+
+ describe('Button Text States', () => {
+ it('should show default button text initially', () => {
+ render()
+
+ expect(screen.getByTestId('modal-confirm')).toHaveTextContent('plugin.auth.saveAndAuth')
+ })
+
+ it('should show save only button text', () => {
+ render()
+
+ expect(screen.getByTestId('modal-cancel')).toHaveTextContent('plugin.auth.saveOnly')
+ })
+ })
+
+ describe('OAuth Client Schema', () => {
+ it('should populate form with existing params values', () => {
+ const configWithParams = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ params: {
+ client_id: 'existing-client-id',
+ client_secret: 'existing-client-secret',
+ },
+ })
+
+ render()
+
+ const clientIdInput = screen.getByTestId('form-field-client_id') as HTMLInputElement
+ const clientSecretInput = screen.getByTestId('form-field-client_secret') as HTMLInputElement
+
+ expect(clientIdInput.defaultValue).toBe('existing-client-id')
+ expect(clientSecretInput.defaultValue).toBe('existing-client-secret')
+ })
+
+ it('should handle empty oauth_client_schema', () => {
+ const configWithEmptySchema = createMockOAuthConfig({
+ system_configured: false,
+ oauth_client_schema: [],
+ })
+
+ render()
+
+ expect(screen.queryByTestId('base-form')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle undefined oauthConfig', () => {
+ render()
+
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+
+ it('should handle missing provider', () => {
+ const detailWithoutProvider = createMockPluginDetail({ provider: '' })
+ mockUsePluginStore.mockReturnValue(detailWithoutProvider)
+
+ render()
+
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+ })
+
+ describe('Authorization Status Polling', () => {
+ it('should initiate polling setup after OAuth starts', () => {
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+ mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
+ onSuccess({
+ authorization_url: 'https://oauth.example.com/authorize',
+ subscription_builder: createMockSubscriptionBuilder(),
+ })
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ // Verify OAuth flow was initiated
+ expect(mockInitiateOAuth).toHaveBeenCalledWith(
+ 'test-provider',
+ expect.any(Object),
+ )
+ })
+
+ it('should continue polling when verifyBuilder returns an error', async () => {
+ vi.useFakeTimers()
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+ mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
+ onSuccess({
+ authorization_url: 'https://oauth.example.com/authorize',
+ subscription_builder: createMockSubscriptionBuilder(),
+ })
+ })
+ mockVerifyBuilder.mockImplementation((params, { onError }) => {
+ onError(new Error('Verify failed'))
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ vi.advanceTimersByTime(3000)
+ expect(mockVerifyBuilder).toHaveBeenCalled()
+
+ // Should still be in pending state (polling continues)
+ expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.common.authorizing')
+
+ vi.useRealTimers()
+ })
+ })
+
+ describe('getErrorMessage helper', () => {
+ it('should extract error message from Error object', () => {
+ const configWithCustomEnabled = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ params: { client_id: 'test-id', client_secret: 'test-secret' },
+ })
+
+ mockDeleteOAuth.mockImplementation((provider, { onError }) => {
+ onError(new Error('Custom error message'))
+ })
+
+ render()
+
+ fireEvent.click(screen.getByText('common.operation.remove'))
+
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'Custom error message',
+ })
+ })
+
+ it('should extract error message from object with message property', () => {
+ const configWithCustomEnabled = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ params: { client_id: 'test-id', client_secret: 'test-secret' },
+ })
+
+ mockDeleteOAuth.mockImplementation((provider, { onError }) => {
+ onError({ message: 'Object error message' })
+ })
+
+ render()
+
+ fireEvent.click(screen.getByText('common.operation.remove'))
+
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'Object error message',
+ })
+ })
+
+ it('should use fallback message when error has no message', () => {
+ const configWithCustomEnabled = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ params: { client_id: 'test-id', client_secret: 'test-secret' },
+ })
+
+ mockDeleteOAuth.mockImplementation((provider, { onError }) => {
+ onError({})
+ })
+
+ render()
+
+ fireEvent.click(screen.getByText('common.operation.remove'))
+
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'pluginTrigger.modal.oauth.remove.failed',
+ })
+ })
+
+ it('should use fallback when error.message is not a string', () => {
+ const configWithCustomEnabled = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ params: { client_id: 'test-id', client_secret: 'test-secret' },
+ })
+
+ mockDeleteOAuth.mockImplementation((provider, { onError }) => {
+ onError({ message: 123 })
+ })
+
+ render()
+
+ fireEvent.click(screen.getByText('common.operation.remove'))
+
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'pluginTrigger.modal.oauth.remove.failed',
+ })
+ })
+
+ it('should use fallback when error.message is empty string', () => {
+ const configWithCustomEnabled = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ params: { client_id: 'test-id', client_secret: 'test-secret' },
+ })
+
+ mockDeleteOAuth.mockImplementation((provider, { onError }) => {
+ onError({ message: '' })
+ })
+
+ render()
+
+ fireEvent.click(screen.getByText('common.operation.remove'))
+
+ expect(mockToastNotify).toHaveBeenCalledWith({
+ type: 'error',
+ message: 'pluginTrigger.modal.oauth.remove.failed',
+ })
+ })
+ })
+
+ describe('OAuth callback edge cases', () => {
+ it('should not show success toast when OAuth callback returns falsy data', () => {
+ const mockOnClose = vi.fn()
+ const mockShowOAuthCreateModal = vi.fn()
+
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+ mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
+ onSuccess({
+ authorization_url: 'https://oauth.example.com/authorize',
+ subscription_builder: createMockSubscriptionBuilder(),
+ })
+ })
+ mockOpenOAuthPopup.mockImplementation((url, callback) => {
+ callback(null)
+ })
+
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ // Should not show success toast or call callbacks
+ expect(mockToastNotify).not.toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'pluginTrigger.modal.oauth.authorization.authSuccess' }),
+ )
+ expect(mockShowOAuthCreateModal).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Custom Client Type Save Flow', () => {
+ it('should send enabled: true when custom client type is selected', () => {
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ render()
+
+ // Switch to custom
+ const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')
+ fireEvent.click(customCard)
+
+ fireEvent.click(screen.getByTestId('modal-cancel'))
+
+ expect(mockConfigureOAuth).toHaveBeenCalledWith(
+ expect.objectContaining({
+ enabled: true,
+ }),
+ expect.any(Object),
+ )
+ })
+
+ it('should send enabled: false when default client type is selected', () => {
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ render()
+
+ // Default is already selected
+ fireEvent.click(screen.getByTestId('modal-cancel'))
+
+ expect(mockConfigureOAuth).toHaveBeenCalledWith(
+ expect.objectContaining({
+ enabled: false,
+ }),
+ expect.any(Object),
+ )
+ })
+ })
+
+ describe('OAuth Client Schema Default Values', () => {
+ it('should set default values from params to schema', () => {
+ const configWithParams = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ params: {
+ client_id: 'my-client-id',
+ client_secret: 'my-client-secret',
+ },
+ })
+
+ render()
+
+ const clientIdInput = screen.getByTestId('form-field-client_id') as HTMLInputElement
+ const clientSecretInput = screen.getByTestId('form-field-client_secret') as HTMLInputElement
+
+ expect(clientIdInput.defaultValue).toBe('my-client-id')
+ expect(clientSecretInput.defaultValue).toBe('my-client-secret')
+ })
+
+ it('should return empty array when oauth_client_schema is empty', () => {
+ const configWithEmptySchema = createMockOAuthConfig({
+ system_configured: false,
+ oauth_client_schema: [],
+ })
+
+ render()
+
+ expect(screen.queryByTestId('base-form')).not.toBeInTheDocument()
+ })
+
+ it('should skip setting default when schema name is not in params', () => {
+ const configWithPartialParams = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ params: {
+ client_id: 'my-client-id',
+ client_secret: '', // empty value - will not be set as default
+ },
+ oauth_client_schema: [
+ { name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown },
+ { name: 'client_secret', type: 'secret-input' as unknown, required: true, label: { 'en-US': 'Client Secret' } as unknown },
+ { name: 'extra_param', type: 'text-input' as unknown, required: false, label: { 'en-US': 'Extra Param' } as unknown },
+ ] as TriggerOAuthConfig['oauth_client_schema'],
+ })
+
+ render()
+
+ const clientIdInput = screen.getByTestId('form-field-client_id') as HTMLInputElement
+ expect(clientIdInput.defaultValue).toBe('my-client-id')
+
+ // client_secret should have empty default since value is empty
+ const clientSecretInput = screen.getByTestId('form-field-client_secret') as HTMLInputElement
+ expect(clientSecretInput.defaultValue).toBe('')
+ })
+ })
+
+ describe('Confirm Button Text States', () => {
+ it('should show saveAndAuth text by default', () => {
+ render()
+
+ expect(screen.getByTestId('modal-confirm')).toHaveTextContent('plugin.auth.saveAndAuth')
+ })
+
+ it('should show authorizing text when authorization is pending', () => {
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+ mockInitiateOAuth.mockImplementation(() => {
+ // Don't call callback - stays pending
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.common.authorizing')
+ })
+ })
+
+ describe('Authorization Failed Status', () => {
+ it('should set authorization status to Failed when OAuth initiation fails', () => {
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+ mockInitiateOAuth.mockImplementation((provider, { onError }) => {
+ onError(new Error('OAuth failed'))
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ // After failure, button text should return to default
+ expect(screen.getByTestId('modal-confirm')).toHaveTextContent('plugin.auth.saveAndAuth')
+ })
+ })
+
+ describe('Redirect URI Display', () => {
+ it('should not show redirect URI info when redirect_uri is empty', () => {
+ const configWithEmptyRedirectUri = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ redirect_uri: '',
+ })
+
+ render()
+
+ expect(screen.queryByText('pluginTrigger.modal.oauthRedirectInfo')).not.toBeInTheDocument()
+ })
+
+ it('should show redirect URI info when custom type and redirect_uri exists', () => {
+ const configWithRedirectUri = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: true,
+ redirect_uri: 'https://my-app.com/oauth/callback',
+ })
+
+ render()
+
+ expect(screen.getByText('pluginTrigger.modal.oauthRedirectInfo')).toBeInTheDocument()
+ expect(screen.getByText('https://my-app.com/oauth/callback')).toBeInTheDocument()
+ })
+ })
+
+ describe('Remove Button Visibility', () => {
+ it('should not show remove button when custom_enabled is false', () => {
+ const configWithCustomDisabled = createMockOAuthConfig({
+ system_configured: false,
+ custom_enabled: false,
+ params: { client_id: 'test-id', client_secret: 'test-secret' },
+ })
+
+ render()
+
+ expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument()
+ })
+
+ it('should not show remove button when default client type is selected', () => {
+ const configWithCustomEnabled = createMockOAuthConfig({
+ system_configured: true,
+ custom_enabled: true,
+ params: { client_id: 'test-id', client_secret: 'test-secret' },
+ })
+
+ render()
+
+ // Default is selected by default when system_configured is true
+ expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('OAuth Client Title', () => {
+ it('should render client type title', () => {
+ render()
+
+ expect(screen.getByText('pluginTrigger.subscription.addType.options.oauth.clientTitle')).toBeInTheDocument()
+ })
+ })
+
+ describe('Form Validation on Custom Save', () => {
+ it('should not call configureOAuth when form validation fails', () => {
+ setMockFormValues({
+ values: { client_id: '', client_secret: '' },
+ isCheckValidated: false,
+ })
+
+ render()
+
+ // Switch to custom type
+ const customCard = screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom')
+ fireEvent.click(customCard)
+
+ fireEvent.click(screen.getByTestId('modal-cancel'))
+
+ // Should not call configureOAuth because form validation failed
+ expect(mockConfigureOAuth).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Client Params Hidden Value Transform', () => {
+ it('should transform client_id to hidden when unchanged', () => {
+ setMockFormValues({
+ values: { client_id: 'default-client-id', client_secret: 'new-secret' },
+ isCheckValidated: true,
+ })
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ render()
+
+ // Switch to custom type
+ fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom'))
+
+ fireEvent.click(screen.getByTestId('modal-cancel'))
+
+ expect(mockConfigureOAuth).toHaveBeenCalledWith(
+ expect.objectContaining({
+ client_params: expect.objectContaining({
+ client_id: '[__HIDDEN__]',
+ client_secret: 'new-secret',
+ }),
+ }),
+ expect.any(Object),
+ )
+ })
+
+ it('should transform client_secret to hidden when unchanged', () => {
+ setMockFormValues({
+ values: { client_id: 'new-id', client_secret: 'default-client-secret' },
+ isCheckValidated: true,
+ })
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ render()
+
+ // Switch to custom type
+ fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom'))
+
+ fireEvent.click(screen.getByTestId('modal-cancel'))
+
+ expect(mockConfigureOAuth).toHaveBeenCalledWith(
+ expect.objectContaining({
+ client_params: expect.objectContaining({
+ client_id: 'new-id',
+ client_secret: '[__HIDDEN__]',
+ }),
+ }),
+ expect.any(Object),
+ )
+ })
+
+ it('should transform both client_id and client_secret to hidden when both unchanged', () => {
+ setMockFormValues({
+ values: { client_id: 'default-client-id', client_secret: 'default-client-secret' },
+ isCheckValidated: true,
+ })
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ render()
+
+ // Switch to custom type
+ fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom'))
+
+ fireEvent.click(screen.getByTestId('modal-cancel'))
+
+ expect(mockConfigureOAuth).toHaveBeenCalledWith(
+ expect.objectContaining({
+ client_params: expect.objectContaining({
+ client_id: '[__HIDDEN__]',
+ client_secret: '[__HIDDEN__]',
+ }),
+ }),
+ expect.any(Object),
+ )
+ })
+
+ it('should send new values when both changed', () => {
+ setMockFormValues({
+ values: { client_id: 'new-client-id', client_secret: 'new-client-secret' },
+ isCheckValidated: true,
+ })
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+
+ render()
+
+ // Switch to custom type
+ fireEvent.click(screen.getByTestId('option-card-pluginTrigger.subscription.addType.options.oauth.custom'))
+
+ fireEvent.click(screen.getByTestId('modal-cancel'))
+
+ expect(mockConfigureOAuth).toHaveBeenCalledWith(
+ expect.objectContaining({
+ client_params: expect.objectContaining({
+ client_id: 'new-client-id',
+ client_secret: 'new-client-secret',
+ }),
+ }),
+ expect.any(Object),
+ )
+ })
+ })
+
+ describe('Polling Verification Success', () => {
+ it('should call verifyBuilder and update status on success', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+ mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
+ onSuccess({
+ authorization_url: 'https://oauth.example.com/authorize',
+ subscription_builder: createMockSubscriptionBuilder(),
+ })
+ })
+ mockVerifyBuilder.mockImplementation((params, { onSuccess }) => {
+ onSuccess({ verified: true })
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ // Advance timer to trigger polling
+ await vi.advanceTimersByTimeAsync(3000)
+
+ expect(mockVerifyBuilder).toHaveBeenCalled()
+
+ // Button text should show waitingJump after verified
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.oauth.authorization.waitingJump')
+ })
+
+ vi.useRealTimers()
+ })
+
+ it('should continue polling when not verified', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ mockConfigureOAuth.mockImplementation((params, { onSuccess }) => {
+ onSuccess()
+ })
+ mockInitiateOAuth.mockImplementation((provider, { onSuccess }) => {
+ onSuccess({
+ authorization_url: 'https://oauth.example.com/authorize',
+ subscription_builder: createMockSubscriptionBuilder(),
+ })
+ })
+ mockVerifyBuilder.mockImplementation((params, { onSuccess }) => {
+ onSuccess({ verified: false })
+ })
+
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm'))
+
+ // First poll
+ await vi.advanceTimersByTimeAsync(3000)
+ expect(mockVerifyBuilder).toHaveBeenCalledTimes(1)
+
+ // Second poll
+ await vi.advanceTimersByTimeAsync(3000)
+ expect(mockVerifyBuilder).toHaveBeenCalledTimes(2)
+
+ // Should still be in authorizing state
+ expect(screen.getByTestId('modal-confirm')).toHaveTextContent('pluginTrigger.modal.common.authorizing')
+
+ vi.useRealTimers()
+ })
+ })
+})
diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.spec.tsx
new file mode 100644
index 0000000000..e814774621
--- /dev/null
+++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.spec.tsx
@@ -0,0 +1,1552 @@
+import type { PluginDetail } from '@/app/components/plugins/types'
+import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { FormTypeEnum } from '@/app/components/base/form/types'
+import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
+import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
+import { ApiKeyEditModal } from './apikey-edit-modal'
+import { EditModal } from './index'
+import { ManualEditModal } from './manual-edit-modal'
+import { OAuthEditModal } from './oauth-edit-modal'
+
+// ==================== Mock Setup ====================
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}))
+
+const mockToastNotify = vi.fn()
+vi.mock('@/app/components/base/toast', () => ({
+ default: { notify: (params: unknown) => mockToastNotify(params) },
+}))
+
+const mockParsePluginErrorMessage = vi.fn()
+vi.mock('@/utils/error-parser', () => ({
+ parsePluginErrorMessage: (error: unknown) => mockParsePluginErrorMessage(error),
+}))
+
+// Schema types
+type SubscriptionSchema = {
+ name: string
+ label: Record
+ type: string
+ required: boolean
+ default?: string
+ description?: Record
+ multiple: boolean
+ auto_generate: null
+ template: null
+ scope: null
+ min: null
+ max: null
+ precision: null
+}
+
+type CredentialSchema = {
+ name: string
+ label: Record
+ type: string
+ required: boolean
+ default?: string
+ help?: Record
+}
+
+const mockPluginStoreDetail = {
+ plugin_id: 'test-plugin-id',
+ provider: 'test-provider',
+ declaration: {
+ trigger: {
+ subscription_schema: [] as SubscriptionSchema[],
+ subscription_constructor: {
+ credentials_schema: [] as CredentialSchema[],
+ parameters: [] as SubscriptionSchema[],
+ oauth_schema: { client_schema: [], credentials_schema: [] },
+ },
+ },
+ },
+}
+
+vi.mock('../../store', () => ({
+ usePluginStore: (selector: (state: { detail: typeof mockPluginStoreDetail }) => unknown) =>
+ selector({ detail: mockPluginStoreDetail }),
+}))
+
+const mockRefetch = vi.fn()
+vi.mock('../use-subscription-list', () => ({
+ useSubscriptionList: () => ({ refetch: mockRefetch }),
+}))
+
+const mockUpdateSubscription = vi.fn()
+const mockVerifyCredentials = vi.fn()
+let mockIsUpdating = false
+let mockIsVerifying = false
+
+vi.mock('@/service/use-triggers', () => ({
+ useUpdateTriggerSubscription: () => ({
+ mutate: mockUpdateSubscription,
+ isPending: mockIsUpdating,
+ }),
+ useVerifyTriggerSubscription: () => ({
+ mutate: mockVerifyCredentials,
+ isPending: mockIsVerifying,
+ }),
+}))
+
+vi.mock('@/app/components/plugins/readme-panel/entrance', () => ({
+ ReadmeEntrance: ({ pluginDetail }: { pluginDetail: PluginDetail }) => (
+ ReadmeEntrance
+ ),
+}))
+
+vi.mock('@/app/components/base/encrypted-bottom', () => ({
+ EncryptedBottom: () => EncryptedBottom
,
+}))
+
+// Form values storage keyed by form identifier
+const formValuesMap = new Map, isCheckValidated: boolean }>()
+
+// Track which modal is being tested to properly identify forms
+let currentModalType: 'manual' | 'oauth' | 'apikey' = 'manual'
+
+// Helper to get form identifier based on schemas and context
+const getFormId = (schemas: Array<{ name: string }>, preventDefaultSubmit?: boolean): string => {
+ if (preventDefaultSubmit)
+ return 'credentials'
+ if (schemas.some(s => s.name === 'subscription_name')) {
+ // For ApiKey modal step 2, basic form only has subscription_name and callback_url
+ if (currentModalType === 'apikey' && schemas.length === 2)
+ return 'basic'
+ // For ManualEditModal and OAuthEditModal, the main form always includes subscription_name
+ return 'main'
+ }
+ return 'parameters'
+}
+
+vi.mock('@/app/components/base/form/components/base', () => ({
+ BaseForm: vi.fn().mockImplementation(({ formSchemas, ref, preventDefaultSubmit }) => {
+ const formId = getFormId(formSchemas || [], preventDefaultSubmit)
+ if (ref) {
+ ref.current = {
+ getFormValues: () => formValuesMap.get(formId) || { values: {}, isCheckValidated: true },
+ }
+ }
+ return (
+
+ {formSchemas?.map((schema: {
+ name: string
+ type: string
+ default?: unknown
+ dynamicSelectParams?: unknown
+ fieldClassName?: string
+ labelClassName?: string
+ }) => (
+
+ {schema.name}
+
+ ))}
+
+ )
+ }),
+}))
+
+vi.mock('@/app/components/base/modal/modal', () => ({
+ default: ({
+ title,
+ confirmButtonText,
+ onClose,
+ onCancel,
+ onConfirm,
+ disabled,
+ children,
+ showExtraButton,
+ extraButtonText,
+ onExtraButtonClick,
+ bottomSlot,
+ }: {
+ title: string
+ confirmButtonText: string
+ onClose: () => void
+ onCancel: () => void
+ onConfirm: () => void
+ disabled?: boolean
+ children: React.ReactNode
+ showExtraButton?: boolean
+ extraButtonText?: string
+ onExtraButtonClick?: () => void
+ bottomSlot?: React.ReactNode
+ }) => (
+
+
{children}
+
+
+
+ {showExtraButton && (
+
+ )}
+ {bottomSlot &&
{bottomSlot}
}
+
+ ),
+}))
+
+// ==================== Test Utilities ====================
+
+const createSubscription = (overrides: Partial = {}): TriggerSubscription => ({
+ id: 'test-subscription-id',
+ name: 'Test Subscription',
+ provider: 'test-provider',
+ credential_type: TriggerCredentialTypeEnum.Unauthorized,
+ credentials: {},
+ endpoint: 'https://example.com/webhook',
+ parameters: {},
+ properties: {},
+ workflows_in_use: 0,
+ ...overrides,
+})
+
+const createPluginDetail = (overrides: Partial = {}): PluginDetail => ({
+ id: 'test-plugin-id',
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ name: 'Test Plugin',
+ plugin_id: 'test-plugin',
+ plugin_unique_identifier: 'test-plugin-unique-id',
+ declaration: {
+ plugin_unique_identifier: 'test-plugin-unique-id',
+ version: '1.0.0',
+ author: 'Test Author',
+ icon: 'test-icon',
+ name: 'test-plugin',
+ category: PluginCategoryEnum.trigger,
+ label: {} as Record,
+ description: {} as Record,
+ created_at: '2024-01-01T00:00:00Z',
+ resource: {},
+ plugins: [],
+ verified: true,
+ endpoint: { settings: [], endpoints: [] },
+ model: {},
+ tags: [],
+ agent_strategy: {},
+ meta: { version: '1.0.0' },
+ trigger: {
+ events: [],
+ identity: {
+ author: 'Test Author',
+ name: 'test-trigger',
+ label: {} as Record,
+ description: {} as Record,
+ icon: 'test-icon',
+ tags: [],
+ },
+ subscription_constructor: {
+ credentials_schema: [],
+ oauth_schema: { client_schema: [], credentials_schema: [] },
+ parameters: [],
+ },
+ subscription_schema: [],
+ },
+ },
+ installation_id: 'test-installation-id',
+ tenant_id: 'test-tenant-id',
+ endpoints_setups: 0,
+ endpoints_active: 0,
+ version: '1.0.0',
+ latest_version: '1.0.0',
+ latest_unique_identifier: 'test-plugin-unique-id',
+ source: PluginSource.marketplace,
+ status: 'active' as const,
+ deprecated_reason: '',
+ alternative_plugin_id: '',
+ ...overrides,
+})
+
+const createSchemaField = (name: string, type: string = 'string', overrides = {}): SubscriptionSchema => ({
+ name,
+ label: { en_US: name },
+ type,
+ required: true,
+ multiple: false,
+ auto_generate: null,
+ template: null,
+ scope: null,
+ min: null,
+ max: null,
+ precision: null,
+ ...overrides,
+})
+
+const createCredentialSchema = (name: string, type: string = 'secret-input', overrides = {}): CredentialSchema => ({
+ name,
+ label: { en_US: name },
+ type,
+ required: true,
+ ...overrides,
+})
+
+const resetMocks = () => {
+ mockPluginStoreDetail.plugin_id = 'test-plugin-id'
+ mockPluginStoreDetail.provider = 'test-provider'
+ mockPluginStoreDetail.declaration.trigger.subscription_schema = []
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = []
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = []
+ formValuesMap.clear()
+ // Set default form values
+ formValuesMap.set('main', { values: { subscription_name: 'Test' }, isCheckValidated: true })
+ formValuesMap.set('basic', { values: { subscription_name: 'Test' }, isCheckValidated: true })
+ formValuesMap.set('credentials', { values: {}, isCheckValidated: true })
+ formValuesMap.set('parameters', { values: {}, isCheckValidated: true })
+ // Reset pending states
+ mockIsUpdating = false
+ mockIsVerifying = false
+}
+
+// ==================== Tests ====================
+
+describe('Edit Modal Components', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetMocks()
+ })
+
+ // ==================== EditModal (Router) Tests ====================
+
+ describe('EditModal (Router)', () => {
+ it.each([
+ { type: TriggerCredentialTypeEnum.Unauthorized, name: 'ManualEditModal' },
+ { type: TriggerCredentialTypeEnum.Oauth2, name: 'OAuthEditModal' },
+ { type: TriggerCredentialTypeEnum.ApiKey, name: 'ApiKeyEditModal' },
+ ])('should render $name for $type credential type', ({ type }) => {
+ render()
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+
+ it('should render nothing for unknown credential type', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('should pass pluginDetail to child modal', () => {
+ const pluginDetail = createPluginDetail({ id: 'custom-plugin' })
+ render(
+ ,
+ )
+ expect(screen.getByTestId('readme-entrance')).toHaveAttribute('data-plugin-id', 'custom-plugin')
+ })
+ })
+
+ // ==================== ManualEditModal Tests ====================
+
+ describe('ManualEditModal', () => {
+ beforeEach(() => {
+ currentModalType = 'manual'
+ })
+
+ const createProps = (overrides = {}) => ({
+ onClose: vi.fn(),
+ subscription: createSubscription(),
+ ...overrides,
+ })
+
+ describe('Rendering', () => {
+ it('should render modal with correct title', () => {
+ render()
+ expect(screen.getByTestId('modal')).toHaveAttribute(
+ 'data-title',
+ 'pluginTrigger.subscription.list.item.actions.edit.title',
+ )
+ })
+
+ it('should render ReadmeEntrance when pluginDetail is provided', () => {
+ render()
+ expect(screen.getByTestId('readme-entrance')).toBeInTheDocument()
+ })
+
+ it('should not render ReadmeEntrance when pluginDetail is not provided', () => {
+ render()
+ expect(screen.queryByTestId('readme-entrance')).not.toBeInTheDocument()
+ })
+
+ it('should render subscription_name and callback_url fields', () => {
+ render()
+ expect(screen.getByTestId('form-field-subscription_name')).toBeInTheDocument()
+ expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument()
+ })
+
+ it('should render properties schema fields from store', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_schema = [
+ createSchemaField('custom_field'),
+ createSchemaField('another_field', 'number'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-custom_field')).toBeInTheDocument()
+ expect(screen.getByTestId('form-field-another_field')).toBeInTheDocument()
+ })
+ })
+
+ describe('Form Schema Default Values', () => {
+ it('should use subscription name as default', () => {
+ render()
+ expect(screen.getByTestId('form-field-subscription_name')).toHaveAttribute('data-field-default', 'My Sub')
+ })
+
+ it('should use endpoint as callback_url default', () => {
+ render()
+ expect(screen.getByTestId('form-field-callback_url')).toHaveAttribute('data-field-default', 'https://test.com')
+ })
+
+ it('should use empty string when endpoint is empty', () => {
+ render()
+ expect(screen.getByTestId('form-field-callback_url')).toHaveAttribute('data-field-default', '')
+ })
+
+ it('should use subscription properties as defaults for custom fields', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_schema = [createSchemaField('custom')]
+ render()
+ expect(screen.getByTestId('form-field-custom')).toHaveAttribute('data-field-default', 'value')
+ })
+
+ it('should use schema default when subscription property is missing', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_schema = [
+ createSchemaField('custom', 'string', { default: 'schema_default' }),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-custom')).toHaveAttribute('data-field-default', 'schema_default')
+ })
+ })
+
+ describe('Confirm Button Text', () => {
+ it('should show "save" when not updating', () => {
+ render()
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save')
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should call onClose when cancel button is clicked', () => {
+ const onClose = vi.fn()
+ render()
+ fireEvent.click(screen.getByTestId('modal-cancel-button'))
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call onClose when close button is clicked', () => {
+ const onClose = vi.fn()
+ render()
+ fireEvent.click(screen.getByTestId('modal-close-button'))
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should call updateSubscription when confirm is clicked with valid form', () => {
+ formValuesMap.set('main', { values: { subscription_name: 'New Name' }, isCheckValidated: true })
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ expect(mockUpdateSubscription).toHaveBeenCalledWith(
+ expect.objectContaining({ subscriptionId: 'test-subscription-id', name: 'New Name' }),
+ expect.any(Object),
+ )
+ })
+
+ it('should not call updateSubscription when form validation fails', () => {
+ formValuesMap.set('main', { values: {}, isCheckValidated: false })
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ expect(mockUpdateSubscription).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Properties Change Detection', () => {
+ it('should not send properties when unchanged', () => {
+ const subscription = createSubscription({ properties: { custom: 'value' } })
+ formValuesMap.set('main', {
+ values: { subscription_name: 'Name', callback_url: '', custom: 'value' },
+ isCheckValidated: true,
+ })
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ expect(mockUpdateSubscription).toHaveBeenCalledWith(
+ expect.objectContaining({ properties: undefined }),
+ expect.any(Object),
+ )
+ })
+
+ it('should send properties when changed', () => {
+ const subscription = createSubscription({ properties: { custom: 'old' } })
+ formValuesMap.set('main', {
+ values: { subscription_name: 'Name', callback_url: '', custom: 'new' },
+ isCheckValidated: true,
+ })
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ expect(mockUpdateSubscription).toHaveBeenCalledWith(
+ expect.objectContaining({ properties: { custom: 'new' } }),
+ expect.any(Object),
+ )
+ })
+ })
+
+ describe('Update Callbacks', () => {
+ it('should show success toast and call onClose on success', async () => {
+ formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ mockUpdateSubscription.mockImplementation((_p, cb) => cb.onSuccess())
+ const onClose = vi.fn()
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }))
+ })
+ expect(mockRefetch).toHaveBeenCalled()
+ expect(onClose).toHaveBeenCalled()
+ })
+
+ it('should show error toast with Error message on failure', async () => {
+ formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError(new Error('Custom error')))
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'Custom error',
+ }))
+ })
+ })
+
+ it('should use error.message from object when available', async () => {
+ formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({ message: 'Object error' }))
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'Object error',
+ }))
+ })
+ })
+
+ it('should use fallback message when error has no message', async () => {
+ formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({}))
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'pluginTrigger.subscription.list.item.actions.edit.error',
+ }))
+ })
+ })
+
+ it('should use fallback message when error is null', async () => {
+ formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError(null))
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'pluginTrigger.subscription.list.item.actions.edit.error',
+ }))
+ })
+ })
+
+ it('should use fallback when error.message is not a string', async () => {
+ formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({ message: 123 }))
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'pluginTrigger.subscription.list.item.actions.edit.error',
+ }))
+ })
+ })
+
+ it('should use fallback when error.message is empty string', async () => {
+ formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({ message: '' }))
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'pluginTrigger.subscription.list.item.actions.edit.error',
+ }))
+ })
+ })
+ })
+
+ describe('normalizeFormType in ManualEditModal', () => {
+ it('should normalize number type', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_schema = [
+ createSchemaField('num_field', 'number'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-num_field')).toHaveAttribute('data-field-type', FormTypeEnum.textNumber)
+ })
+
+ it('should normalize select type', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_schema = [
+ createSchemaField('sel_field', 'select'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-sel_field')).toHaveAttribute('data-field-type', FormTypeEnum.select)
+ })
+
+ it('should return textInput for unknown type', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_schema = [
+ createSchemaField('unknown_field', 'unknown-custom-type'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-unknown_field')).toHaveAttribute('data-field-type', FormTypeEnum.textInput)
+ })
+ })
+
+ describe('Button Text State', () => {
+ it('should show saving text when isUpdating is true', () => {
+ mockIsUpdating = true
+ render()
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.saving')
+ })
+ })
+ })
+
+ // ==================== OAuthEditModal Tests ====================
+
+ describe('OAuthEditModal', () => {
+ beforeEach(() => {
+ currentModalType = 'oauth'
+ })
+
+ const createProps = (overrides = {}) => ({
+ onClose: vi.fn(),
+ subscription: createSubscription({ credential_type: TriggerCredentialTypeEnum.Oauth2 }),
+ ...overrides,
+ })
+
+ describe('Rendering', () => {
+ it('should render modal with correct title', () => {
+ render()
+ expect(screen.getByTestId('modal')).toHaveAttribute(
+ 'data-title',
+ 'pluginTrigger.subscription.list.item.actions.edit.title',
+ )
+ })
+
+ it('should render ReadmeEntrance when pluginDetail is provided', () => {
+ render()
+ expect(screen.getByTestId('readme-entrance')).toBeInTheDocument()
+ })
+
+ it('should render parameters schema fields from store', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('oauth_param'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-oauth_param')).toBeInTheDocument()
+ })
+ })
+
+ describe('Form Schema Default Values', () => {
+ it('should use subscription parameters as defaults', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('channel'),
+ ]
+ render(
+ ,
+ )
+ expect(screen.getByTestId('form-field-channel')).toHaveAttribute('data-field-default', 'general')
+ })
+ })
+
+ describe('Dynamic Select Support', () => {
+ it('should add dynamicSelectParams for dynamic-select type fields', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('dynamic_field', FormTypeEnum.dynamicSelect),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-dynamic_field')).toHaveAttribute('data-has-dynamic-select', 'true')
+ })
+
+ it('should not add dynamicSelectParams for non-dynamic-select fields', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('text_field', 'string'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-text_field')).toHaveAttribute('data-has-dynamic-select', 'false')
+ })
+ })
+
+ describe('Boolean Field Styling', () => {
+ it('should add fieldClassName and labelClassName for boolean type', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('bool_field', FormTypeEnum.boolean),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-bool_field')).toHaveAttribute(
+ 'data-field-class',
+ 'flex items-center justify-between',
+ )
+ expect(screen.getByTestId('form-field-bool_field')).toHaveAttribute('data-label-class', 'mb-0')
+ })
+ })
+
+ describe('Parameters Change Detection', () => {
+ it('should not send parameters when unchanged', () => {
+ formValuesMap.set('main', {
+ values: { subscription_name: 'Name', callback_url: '', channel: 'general' },
+ isCheckValidated: true,
+ })
+ render(
+ ,
+ )
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ expect(mockUpdateSubscription).toHaveBeenCalledWith(
+ expect.objectContaining({ parameters: undefined }),
+ expect.any(Object),
+ )
+ })
+
+ it('should send parameters when changed', () => {
+ formValuesMap.set('main', {
+ values: { subscription_name: 'Name', callback_url: '', channel: 'new' },
+ isCheckValidated: true,
+ })
+ render(
+ ,
+ )
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ expect(mockUpdateSubscription).toHaveBeenCalledWith(
+ expect.objectContaining({ parameters: { channel: 'new' } }),
+ expect.any(Object),
+ )
+ })
+ })
+
+ describe('Update Callbacks', () => {
+ it('should show success toast and call onClose on success', async () => {
+ formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ mockUpdateSubscription.mockImplementation((_p, cb) => cb.onSuccess())
+ const onClose = vi.fn()
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }))
+ })
+ expect(onClose).toHaveBeenCalled()
+ })
+
+ it('should show error toast on failure', async () => {
+ formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError(new Error('Failed')))
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
+ })
+ })
+
+ it('should use fallback when error.message is not a string', async () => {
+ formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({ message: 123 }))
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'pluginTrigger.subscription.list.item.actions.edit.error',
+ }))
+ })
+ })
+
+ it('should use fallback when error.message is empty string', async () => {
+ formValuesMap.set('main', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError({ message: '' }))
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'pluginTrigger.subscription.list.item.actions.edit.error',
+ }))
+ })
+ })
+ })
+
+ describe('Form Validation', () => {
+ it('should not call updateSubscription when form validation fails', () => {
+ formValuesMap.set('main', { values: {}, isCheckValidated: false })
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ expect(mockUpdateSubscription).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('normalizeFormType in OAuthEditModal', () => {
+ it('should normalize number type', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('num_field', 'number'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-num_field')).toHaveAttribute('data-field-type', FormTypeEnum.textNumber)
+ })
+
+ it('should normalize integer type', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('int_field', 'integer'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-int_field')).toHaveAttribute('data-field-type', FormTypeEnum.textNumber)
+ })
+
+ it('should normalize select type', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('sel_field', 'select'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-sel_field')).toHaveAttribute('data-field-type', FormTypeEnum.select)
+ })
+
+ it('should normalize password type', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('pwd_field', 'password'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-pwd_field')).toHaveAttribute('data-field-type', FormTypeEnum.secretInput)
+ })
+
+ it('should return textInput for unknown type', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('unknown_field', 'custom-unknown-type'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-unknown_field')).toHaveAttribute('data-field-type', FormTypeEnum.textInput)
+ })
+ })
+
+ describe('Button Text State', () => {
+ it('should show saving text when isUpdating is true', () => {
+ mockIsUpdating = true
+ render()
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.saving')
+ })
+ })
+ })
+
+ // ==================== ApiKeyEditModal Tests ====================
+
+ describe('ApiKeyEditModal', () => {
+ beforeEach(() => {
+ currentModalType = 'apikey'
+ })
+
+ const createProps = (overrides = {}) => ({
+ onClose: vi.fn(),
+ subscription: createSubscription({ credential_type: TriggerCredentialTypeEnum.ApiKey }),
+ ...overrides,
+ })
+
+ // Setup credentials schema for ApiKeyEditModal tests
+ const setupCredentialsSchema = () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [
+ createCredentialSchema('api_key'),
+ ]
+ }
+
+ describe('Rendering - Step 1 (Credentials)', () => {
+ it('should render modal with correct title', () => {
+ render()
+ expect(screen.getByTestId('modal')).toHaveAttribute(
+ 'data-title',
+ 'pluginTrigger.subscription.list.item.actions.edit.title',
+ )
+ })
+
+ it('should render EncryptedBottom in credentials step', () => {
+ render()
+ expect(screen.getByTestId('modal-bottom-slot')).toBeInTheDocument()
+ expect(screen.getByTestId('encrypted-bottom')).toBeInTheDocument()
+ })
+
+ it('should render credentials form fields', () => {
+ setupCredentialsSchema()
+ render()
+ expect(screen.getByTestId('form-field-api_key')).toBeInTheDocument()
+ })
+
+ it('should show verify button text in credentials step', () => {
+ render()
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('pluginTrigger.modal.common.verify')
+ })
+
+ it('should not show extra button (back) in credentials step', () => {
+ render()
+ expect(screen.queryByTestId('modal-extra-button')).not.toBeInTheDocument()
+ })
+
+ it('should render ReadmeEntrance when pluginDetail is provided', () => {
+ render()
+ expect(screen.getByTestId('readme-entrance')).toBeInTheDocument()
+ })
+ })
+
+ describe('Credentials Form Defaults', () => {
+ it('should use subscription credentials as defaults', () => {
+ setupCredentialsSchema()
+ render(
+ ,
+ )
+ expect(screen.getByTestId('form-field-api_key')).toHaveAttribute('data-field-default', '[__HIDDEN__]')
+ })
+ })
+
+ describe('Credential Verification', () => {
+ beforeEach(() => {
+ setupCredentialsSchema()
+ })
+
+ it('should call verifyCredentials when confirm clicked in credentials step', () => {
+ formValuesMap.set('credentials', { values: { api_key: 'test-key' }, isCheckValidated: true })
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ expect(mockVerifyCredentials).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: 'test-provider',
+ subscriptionId: 'test-subscription-id',
+ credentials: { api_key: 'test-key' },
+ }),
+ expect.any(Object),
+ )
+ })
+
+ it('should not call verifyCredentials when form validation fails', () => {
+ formValuesMap.set('credentials', { values: {}, isCheckValidated: false })
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ expect(mockVerifyCredentials).not.toHaveBeenCalled()
+ })
+
+ it('should show success toast and move to step 2 on successful verification', async () => {
+ formValuesMap.set('credentials', { values: { api_key: 'new-key' }, isCheckValidated: true })
+ mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess())
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'success',
+ message: 'pluginTrigger.modal.apiKey.verify.success',
+ }))
+ })
+ // Should now be in step 2
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save')
+ })
+
+ it('should show error toast on verification failure', async () => {
+ formValuesMap.set('credentials', { values: { api_key: 'bad-key' }, isCheckValidated: true })
+ mockParsePluginErrorMessage.mockResolvedValue('Invalid API key')
+ mockVerifyCredentials.mockImplementation((_p, cb) => cb.onError(new Error('Invalid')))
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'Invalid API key',
+ }))
+ })
+ })
+
+ it('should use fallback error message when parsePluginErrorMessage returns null', async () => {
+ formValuesMap.set('credentials', { values: { api_key: 'bad-key' }, isCheckValidated: true })
+ mockParsePluginErrorMessage.mockResolvedValue(null)
+ mockVerifyCredentials.mockImplementation((_p, cb) => cb.onError(new Error('Invalid')))
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'pluginTrigger.modal.apiKey.verify.error',
+ }))
+ })
+ })
+
+ it('should set verifiedCredentials to null when all credentials are hidden', async () => {
+ formValuesMap.set('credentials', { values: { api_key: '[__HIDDEN__]' }, isCheckValidated: true })
+ formValuesMap.set('basic', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess())
+ render()
+
+ // Verify credentials
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save')
+ })
+
+ // Update subscription
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ expect(mockUpdateSubscription).toHaveBeenCalledWith(
+ expect.objectContaining({ credentials: undefined }),
+ expect.any(Object),
+ )
+ })
+ })
+
+ describe('Step 2 (Configuration)', () => {
+ beforeEach(() => {
+ setupCredentialsSchema()
+ formValuesMap.set('credentials', { values: { api_key: 'new-key' }, isCheckValidated: true })
+ mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess())
+ })
+
+ it('should show save button text in configuration step', async () => {
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save')
+ })
+ })
+
+ it('should show extra button (back) in configuration step', async () => {
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-extra-button')).toBeInTheDocument()
+ expect(screen.getByTestId('modal-extra-button')).toHaveTextContent('pluginTrigger.modal.common.back')
+ })
+ })
+
+ it('should not show EncryptedBottom in configuration step', async () => {
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.queryByTestId('modal-bottom-slot')).not.toBeInTheDocument()
+ })
+ })
+
+ it('should render basic form fields in step 2', async () => {
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('form-field-subscription_name')).toBeInTheDocument()
+ expect(screen.getByTestId('form-field-callback_url')).toBeInTheDocument()
+ })
+ })
+
+ it('should render parameters form when parameters schema exists', async () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('param1'),
+ ]
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('form-field-param1')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Back Button', () => {
+ beforeEach(() => {
+ setupCredentialsSchema()
+ })
+
+ it('should go back to credentials step when back button is clicked', async () => {
+ formValuesMap.set('credentials', { values: { api_key: 'new-key' }, isCheckValidated: true })
+ mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess())
+ render()
+
+ // Go to step 2
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-extra-button')).toBeInTheDocument()
+ })
+
+ // Click back
+ fireEvent.click(screen.getByTestId('modal-extra-button'))
+
+ // Should be back in step 1
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('pluginTrigger.modal.common.verify')
+ })
+ expect(screen.queryByTestId('modal-extra-button')).not.toBeInTheDocument()
+ })
+
+ it('should go back to credentials step when clicking step indicator', async () => {
+ formValuesMap.set('credentials', { values: { api_key: 'new-key' }, isCheckValidated: true })
+ mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess())
+ render()
+
+ // Go to step 2
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save')
+ })
+
+ // Find and click the step indicator (first step text should be clickable in step 2)
+ const stepIndicator = screen.getByText('pluginTrigger.modal.steps.verify')
+ fireEvent.click(stepIndicator)
+
+ // Should be back in step 1
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('pluginTrigger.modal.common.verify')
+ })
+ })
+ })
+
+ describe('Update Subscription', () => {
+ beforeEach(() => {
+ setupCredentialsSchema()
+ formValuesMap.set('credentials', { values: { api_key: 'new-key' }, isCheckValidated: true })
+ mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess())
+ })
+
+ it('should call updateSubscription with verified credentials', async () => {
+ formValuesMap.set('basic', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ render()
+
+ // Step 1: Verify
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save')
+ })
+
+ // Step 2: Update
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ expect(mockUpdateSubscription).toHaveBeenCalledWith(
+ expect.objectContaining({
+ subscriptionId: 'test-subscription-id',
+ name: 'Name',
+ credentials: { api_key: 'new-key' },
+ }),
+ expect.any(Object),
+ )
+ })
+
+ it('should not call updateSubscription when basic form validation fails', async () => {
+ formValuesMap.set('basic', { values: {}, isCheckValidated: false })
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save')
+ })
+
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ expect(mockUpdateSubscription).not.toHaveBeenCalled()
+ })
+
+ it('should show success toast and close on successful update', async () => {
+ formValuesMap.set('basic', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ mockUpdateSubscription.mockImplementation((_p, cb) => cb.onSuccess())
+ const onClose = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save')
+ })
+
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'success',
+ message: 'pluginTrigger.subscription.list.item.actions.edit.success',
+ }))
+ })
+ expect(mockRefetch).toHaveBeenCalled()
+ expect(onClose).toHaveBeenCalled()
+ })
+
+ it('should show error toast on update failure', async () => {
+ formValuesMap.set('basic', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ mockParsePluginErrorMessage.mockResolvedValue('Update failed')
+ mockUpdateSubscription.mockImplementation((_p, cb) => cb.onError(new Error('Failed')))
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save')
+ })
+
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'error',
+ message: 'Update failed',
+ }))
+ })
+ })
+ })
+
+ describe('Parameters Change Detection', () => {
+ beforeEach(() => {
+ setupCredentialsSchema()
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('param1'),
+ ]
+ formValuesMap.set('credentials', { values: { api_key: 'new-key' }, isCheckValidated: true })
+ mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess())
+ })
+
+ it('should not send parameters when unchanged', async () => {
+ formValuesMap.set('basic', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ formValuesMap.set('parameters', { values: { param1: 'value' }, isCheckValidated: true })
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save')
+ })
+
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ expect(mockUpdateSubscription).toHaveBeenCalledWith(
+ expect.objectContaining({ parameters: undefined }),
+ expect.any(Object),
+ )
+ })
+
+ it('should send parameters when changed', async () => {
+ formValuesMap.set('basic', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ formValuesMap.set('parameters', { values: { param1: 'new_value' }, isCheckValidated: true })
+ render(
+ ,
+ )
+
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save')
+ })
+
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ expect(mockUpdateSubscription).toHaveBeenCalledWith(
+ expect.objectContaining({ parameters: { param1: 'new_value' } }),
+ expect.any(Object),
+ )
+ })
+ })
+
+ describe('normalizeFormType in ApiKeyEditModal', () => {
+ it('should normalize number type for credentials schema', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [
+ createCredentialSchema('port', 'number'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-port')).toHaveAttribute('data-field-type', FormTypeEnum.textNumber)
+ })
+
+ it('should normalize select type for credentials schema', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [
+ createCredentialSchema('region', 'select'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-region')).toHaveAttribute('data-field-type', FormTypeEnum.select)
+ })
+
+ it('should normalize text type for credentials schema', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [
+ createCredentialSchema('name', 'text'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-name')).toHaveAttribute('data-field-type', FormTypeEnum.textInput)
+ })
+ })
+
+ describe('Dynamic Select in Parameters', () => {
+ beforeEach(() => {
+ setupCredentialsSchema()
+ formValuesMap.set('credentials', { values: { api_key: 'key' }, isCheckValidated: true })
+ mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess())
+ })
+
+ it('should include dynamicSelectParams for dynamic-select type parameters', async () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('channel', FormTypeEnum.dynamicSelect),
+ ]
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save')
+ })
+
+ expect(screen.getByTestId('form-field-channel')).toHaveAttribute('data-has-dynamic-select', 'true')
+ })
+ })
+
+ describe('Boolean Field Styling', () => {
+ beforeEach(() => {
+ setupCredentialsSchema()
+ formValuesMap.set('credentials', { values: { api_key: 'key' }, isCheckValidated: true })
+ mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess())
+ })
+
+ it('should add special class for boolean type parameters', async () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('enabled', FormTypeEnum.boolean),
+ ]
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save')
+ })
+
+ expect(screen.getByTestId('form-field-enabled')).toHaveAttribute(
+ 'data-field-class',
+ 'flex items-center justify-between',
+ )
+ })
+ })
+
+ describe('normalizeFormType in ApiKeyEditModal - Credentials Schema', () => {
+ it('should normalize password type for credentials', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [
+ createCredentialSchema('secret_key', 'password'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-secret_key')).toHaveAttribute('data-field-type', FormTypeEnum.secretInput)
+ })
+
+ it('should normalize secret type for credentials', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [
+ createCredentialSchema('api_secret', 'secret'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-api_secret')).toHaveAttribute('data-field-type', FormTypeEnum.secretInput)
+ })
+
+ it('should normalize string type for credentials', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [
+ createCredentialSchema('username', 'string'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-username')).toHaveAttribute('data-field-type', FormTypeEnum.textInput)
+ })
+
+ it('should normalize integer type for credentials', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [
+ createCredentialSchema('timeout', 'integer'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-timeout')).toHaveAttribute('data-field-type', FormTypeEnum.textNumber)
+ })
+
+ it('should pass through valid FormTypeEnum for credentials', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [
+ createCredentialSchema('file_field', FormTypeEnum.files),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-file_field')).toHaveAttribute('data-field-type', FormTypeEnum.files)
+ })
+
+ it('should default to textInput for unknown credential types', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = [
+ createCredentialSchema('custom', 'unknown-type'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-custom')).toHaveAttribute('data-field-type', FormTypeEnum.textInput)
+ })
+ })
+
+ describe('Parameters Form Validation', () => {
+ beforeEach(() => {
+ setupCredentialsSchema()
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('param1'),
+ ]
+ formValuesMap.set('credentials', { values: { api_key: 'new-key' }, isCheckValidated: true })
+ mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess())
+ })
+
+ it('should not update when parameters form validation fails', async () => {
+ formValuesMap.set('basic', { values: { subscription_name: 'Name' }, isCheckValidated: true })
+ formValuesMap.set('parameters', { values: {}, isCheckValidated: false })
+ render()
+
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-confirm-button')).toHaveTextContent('common.operation.save')
+ })
+
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ expect(mockUpdateSubscription).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('ApiKeyEditModal without credentials schema', () => {
+ it('should not render credentials form when credentials_schema is empty', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.credentials_schema = []
+ render()
+ // Should still show the modal but no credentials form fields
+ expect(screen.getByTestId('modal')).toBeInTheDocument()
+ })
+ })
+
+ describe('normalizeFormType in Parameters Schema', () => {
+ beforeEach(() => {
+ setupCredentialsSchema()
+ formValuesMap.set('credentials', { values: { api_key: 'key' }, isCheckValidated: true })
+ mockVerifyCredentials.mockImplementation((_p, cb) => cb.onSuccess())
+ })
+
+ it('should normalize password type for parameters', async () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('secret_param', 'password'),
+ ]
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('form-field-secret_param')).toHaveAttribute('data-field-type', FormTypeEnum.secretInput)
+ })
+ })
+
+ it('should normalize secret type for parameters', async () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('api_secret', 'secret'),
+ ]
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('form-field-api_secret')).toHaveAttribute('data-field-type', FormTypeEnum.secretInput)
+ })
+ })
+
+ it('should normalize integer type for parameters', async () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_constructor.parameters = [
+ createSchemaField('count', 'integer'),
+ ]
+ render()
+ fireEvent.click(screen.getByTestId('modal-confirm-button'))
+ await waitFor(() => {
+ expect(screen.getByTestId('form-field-count')).toHaveAttribute('data-field-type', FormTypeEnum.textNumber)
+ })
+ })
+ })
+ })
+
+ // ==================== normalizeFormType Tests ====================
+
+ describe('normalizeFormType behavior', () => {
+ const testCases = [
+ { input: 'string', expected: FormTypeEnum.textInput },
+ { input: 'text', expected: FormTypeEnum.textInput },
+ { input: 'password', expected: FormTypeEnum.secretInput },
+ { input: 'secret', expected: FormTypeEnum.secretInput },
+ { input: 'number', expected: FormTypeEnum.textNumber },
+ { input: 'integer', expected: FormTypeEnum.textNumber },
+ { input: 'boolean', expected: FormTypeEnum.boolean },
+ { input: 'select', expected: FormTypeEnum.select },
+ ]
+
+ testCases.forEach(({ input, expected }) => {
+ it(`should normalize ${input} to ${expected}`, () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_schema = [createSchemaField('field', input)]
+ render()
+ expect(screen.getByTestId('form-field-field')).toHaveAttribute('data-field-type', expected)
+ })
+ })
+
+ it('should return textInput for unknown types', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_schema = [createSchemaField('field', 'unknown')]
+ render()
+ expect(screen.getByTestId('form-field-field')).toHaveAttribute('data-field-type', FormTypeEnum.textInput)
+ })
+
+ it('should pass through valid FormTypeEnum values', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_schema = [createSchemaField('field', FormTypeEnum.files)]
+ render()
+ expect(screen.getByTestId('form-field-field')).toHaveAttribute('data-field-type', FormTypeEnum.files)
+ })
+ })
+
+ // ==================== Edge Cases ====================
+
+ describe('Edge Cases', () => {
+ it('should handle empty subscription name', () => {
+ render()
+ expect(screen.getByTestId('form-field-subscription_name')).toHaveAttribute('data-field-default', '')
+ })
+
+ it('should handle special characters in subscription data', () => {
+ render(alert("xss")' })} />)
+ expect(screen.getByTestId('form-field-subscription_name')).toHaveAttribute('data-field-default', '')
+ })
+
+ it('should handle Unicode characters', () => {
+ render()
+ expect(screen.getByTestId('form-field-subscription_name')).toHaveAttribute('data-field-default', '测试订阅 🚀')
+ })
+
+ it('should handle multiple schema fields', () => {
+ mockPluginStoreDetail.declaration.trigger.subscription_schema = [
+ createSchemaField('field1', 'string'),
+ createSchemaField('field2', 'number'),
+ createSchemaField('field3', 'boolean'),
+ ]
+ render()
+ expect(screen.getByTestId('form-field-field1')).toBeInTheDocument()
+ expect(screen.getByTestId('form-field-field2')).toBeInTheDocument()
+ expect(screen.getByTestId('form-field-field3')).toBeInTheDocument()
+ })
+ })
+})