From 5a90b027acdae4e28a64b68ebde8e18707d150c1 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Mon, 29 Dec 2025 13:16:20 +0800 Subject: [PATCH] test: add unit tests for plugin detail panel components including model selector, TTS parameters, and subscription management --- .../model-selector/index.spec.tsx | 1422 +++++++++++++ .../model-selector/llm-params-panel.spec.tsx | 717 +++++++ .../model-selector/tts-params-panel.spec.tsx | 623 ++++++ .../multiple-tool-selector/index.spec.tsx | 1028 +++++++++ .../create/common-modal.spec.tsx | 1884 +++++++++++++++++ .../subscription-list/create/index.spec.tsx | 1478 +++++++++++++ .../create/oauth-client.spec.tsx | 1250 +++++++++++ .../subscription-list/edit/index.spec.tsx | 1552 ++++++++++++++ 8 files changed, 9954 insertions(+) create mode 100644 web/app/components/plugins/plugin-detail-panel/model-selector/index.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/subscription-list/edit/index.spec.tsx diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/index.spec.tsx new file mode 100644 index 0000000000..ea7a9dca8b --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/index.spec.tsx @@ -0,0 +1,1422 @@ +import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +// Import component after mocks +import Toast from '@/app/components/base/toast' + +import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import ModelParameterModal 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} +
+ ) + }, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: vi.fn(), + }, +})) + +// Mock provider context +const mockProviderContextValue = { + isAPIKeySet: true, + modelProviders: [], +} +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => mockProviderContextValue, +})) + +// Mock model list hook +const mockTextGenerationList: Model[] = [] +const mockTextEmbeddingList: Model[] = [] +const mockRerankList: Model[] = [] +const mockModerationList: Model[] = [] +const mockSttList: Model[] = [] +const mockTtsList: Model[] = [] + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelList: (type: ModelTypeEnum) => { + switch (type) { + case ModelTypeEnum.textGeneration: + return { data: mockTextGenerationList } + case ModelTypeEnum.textEmbedding: + return { data: mockTextEmbeddingList } + case ModelTypeEnum.rerank: + return { data: mockRerankList } + case ModelTypeEnum.moderation: + return { data: mockModerationList } + case ModelTypeEnum.speech2text: + return { data: mockSttList } + case ModelTypeEnum.tts: + return { data: mockTtsList } + default: + return { data: [] } + } + }, +})) + +// Mock fetchAndMergeValidCompletionParams +const mockFetchAndMergeValidCompletionParams = vi.fn() +vi.mock('@/utils/completion-params', () => ({ + fetchAndMergeValidCompletionParams: (...args: unknown[]) => mockFetchAndMergeValidCompletionParams(...args), +})) + +// Mock child components +vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ + default: ({ defaultModel, modelList, scopeFeatures, onSelect }: { + defaultModel?: { provider?: string, model?: string } + modelList?: Model[] + scopeFeatures?: string[] + onSelect?: (model: { provider: string, model: string }) => void + }) => ( +
onSelect?.({ provider: 'openai', model: 'gpt-4' })} + > + Model Selector +
+ ), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger', () => ({ + default: ({ disabled, hasDeprecated, modelDisabled, currentProvider, currentModel, providerName, modelId, isInWorkflow }: { + disabled?: boolean + hasDeprecated?: boolean + modelDisabled?: boolean + currentProvider?: Model + currentModel?: ModelItem + providerName?: string + modelId?: string + isInWorkflow?: boolean + }) => ( +
+ Trigger +
+ ), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger', () => ({ + default: ({ disabled, hasDeprecated, currentProvider, currentModel, providerName, modelId, scope }: { + disabled?: boolean + hasDeprecated?: boolean + currentProvider?: Model + currentModel?: ModelItem + providerName?: string + modelId?: string + scope?: string + }) => ( +
+ Agent Model Trigger +
+ ), +})) + +vi.mock('./llm-params-panel', () => ({ + default: ({ provider, modelId, onCompletionParamsChange, isAdvancedMode }: { + provider: string + modelId: string + completionParams?: Record + onCompletionParamsChange?: (params: Record) => void + isAdvancedMode: boolean + }) => ( +
onCompletionParamsChange?.({ temperature: 0.8 })} + > + LLM Params Panel +
+ ), +})) + +vi.mock('./tts-params-panel', () => ({ + default: ({ language, voice, onChange }: { + currentModel?: ModelItem + language?: string + voice?: string + onChange?: (language: string, voice: string) => void + }) => ( +
onChange?.('en-US', 'alloy')} + > + TTS Params Panel +
+ ), +})) + +// ==================== Test Utilities ==================== + +/** + * Factory function to create a ModelItem with defaults + */ +const createModelItem = (overrides: Partial = {}): ModelItem => ({ + model: 'test-model', + label: { en_US: 'Test Model', zh_Hans: 'Test Model' }, + model_type: ModelTypeEnum.textGeneration, + features: [], + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: { mode: 'chat' }, + load_balancing_enabled: false, + ...overrides, +}) + +/** + * Factory function to create a Model (provider with models) with defaults + */ +const createModel = (overrides: Partial = {}): Model => ({ + provider: 'openai', + icon_large: { en_US: 'icon-large.png', zh_Hans: 'icon-large.png' }, + icon_small: { en_US: 'icon-small.png', zh_Hans: 'icon-small.png' }, + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + models: [createModelItem()], + status: ModelStatusEnum.active, + ...overrides, +}) + +/** + * Factory function to create default props + */ +const createDefaultProps = (overrides: Partial[0]> = {}) => ({ + isAdvancedMode: false, + value: null, + setModel: vi.fn(), + ...overrides, +}) + +/** + * Helper to set up model lists for testing + */ +const setupModelLists = (config: { + textGeneration?: Model[] + textEmbedding?: Model[] + rerank?: Model[] + moderation?: Model[] + stt?: Model[] + tts?: Model[] +} = {}) => { + mockTextGenerationList.length = 0 + mockTextEmbeddingList.length = 0 + mockRerankList.length = 0 + mockModerationList.length = 0 + mockSttList.length = 0 + mockTtsList.length = 0 + + if (config.textGeneration) + mockTextGenerationList.push(...config.textGeneration) + if (config.textEmbedding) + mockTextEmbeddingList.push(...config.textEmbedding) + if (config.rerank) + mockRerankList.push(...config.rerank) + if (config.moderation) + mockModerationList.push(...config.moderation) + if (config.stt) + mockSttList.push(...config.stt) + if (config.tts) + mockTtsList.push(...config.tts) +} + +// ==================== Tests ==================== + +describe('ModelParameterModal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPortalOpenState = false + mockProviderContextValue.isAPIKeySet = true + mockProviderContextValue.modelProviders = [] + setupModelLists() + mockFetchAndMergeValidCompletionParams.mockResolvedValue({ params: {}, removedDetails: {} }) + }) + + // ==================== Rendering Tests ==================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert + expect(container).toBeInTheDocument() + }) + + it('should render trigger component by default', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('trigger')).toBeInTheDocument() + }) + + it('should render agent model trigger when isAgentStrategy is true', () => { + // Arrange + const props = createDefaultProps({ isAgentStrategy: true }) + + // Act + render() + + // Assert + expect(screen.getByTestId('agent-model-trigger')).toBeInTheDocument() + expect(screen.queryByTestId('trigger')).not.toBeInTheDocument() + }) + + it('should render custom trigger when renderTrigger is provided', () => { + // Arrange + const renderTrigger = vi.fn().mockReturnValue(
Custom
) + const props = createDefaultProps({ renderTrigger }) + + // Act + render() + + // Assert + expect(screen.getByTestId('custom-trigger')).toBeInTheDocument() + expect(screen.queryByTestId('trigger')).not.toBeInTheDocument() + }) + + it('should call renderTrigger with correct props', () => { + // Arrange + const renderTrigger = vi.fn().mockReturnValue(
Custom
) + const value = { provider: 'openai', model: 'gpt-4' } + const props = createDefaultProps({ renderTrigger, value }) + + // Act + render() + + // Assert + expect(renderTrigger).toHaveBeenCalledWith( + expect.objectContaining({ + open: false, + providerName: 'openai', + modelId: 'gpt-4', + }), + ) + }) + + it('should not render portal content when closed', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + + it('should render model selector inside portal content when open', async () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + }) + }) + + // ==================== Props Testing ==================== + describe('Props', () => { + it('should pass isInWorkflow to trigger', () => { + // Arrange + const props = createDefaultProps({ isInWorkflow: true }) + + // Act + render() + + // Assert + expect(screen.getByTestId('trigger')).toHaveAttribute('data-in-workflow', 'true') + }) + + it('should pass scope to agent model trigger', () => { + // Arrange + const props = createDefaultProps({ isAgentStrategy: true, scope: 'llm&vision' }) + + // Act + render() + + // Assert + expect(screen.getByTestId('agent-model-trigger')).toHaveAttribute('data-scope', 'llm&vision') + }) + + it('should apply popupClassName to portal content', async () => { + // Arrange + const props = createDefaultProps({ popupClassName: 'custom-popup-class' }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + const content = screen.getByTestId('portal-content') + expect(content.querySelector('.custom-popup-class')).toBeInTheDocument() + }) + }) + + it('should default scope to textGeneration', () => { + // Arrange + const textGenModel = createModel({ provider: 'openai' }) + setupModelLists({ textGeneration: [textGenModel] }) + const props = createDefaultProps({ value: { provider: 'openai', model: 'test-model' } }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + const selector = screen.getByTestId('model-selector') + expect(selector).toHaveAttribute('data-model-list-count', '1') + }) + }) + + // ==================== State Management ==================== + describe('State Management', () => { + it('should toggle open state when trigger is clicked', async () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + }) + + it('should not toggle open state when readonly is true', async () => { + // Arrange + const props = createDefaultProps({ readonly: true }) + + // Act + const { rerender } = render() + expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false') + + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Force a re-render to ensure state is stable + rerender() + + // Assert - open state should remain false due to readonly + expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false') + }) + }) + + // ==================== Memoization Logic ==================== + describe('Memoization - scopeFeatures', () => { + it('should return empty array when scope includes all', async () => { + // Arrange + const props = createDefaultProps({ scope: 'all' }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + const selector = screen.getByTestId('model-selector') + expect(selector).toHaveAttribute('data-scope-features', '[]') + }) + }) + + it('should filter out model type enums from scope', async () => { + // Arrange + const props = createDefaultProps({ scope: 'llm&tool-call&vision' }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + const selector = screen.getByTestId('model-selector') + const features = JSON.parse(selector.getAttribute('data-scope-features') || '[]') + expect(features).toContain('tool-call') + expect(features).toContain('vision') + expect(features).not.toContain('llm') + }) + }) + }) + + describe('Memoization - scopedModelList', () => { + it('should return all models when scope is all', async () => { + // Arrange + const textGenModel = createModel({ provider: 'openai' }) + const embeddingModel = createModel({ provider: 'embedding-provider' }) + setupModelLists({ textGeneration: [textGenModel], textEmbedding: [embeddingModel] }) + const props = createDefaultProps({ scope: 'all' }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + const selector = screen.getByTestId('model-selector') + expect(selector).toHaveAttribute('data-model-list-count', '2') + }) + }) + + it('should return only textGeneration models for llm scope', async () => { + // Arrange + const textGenModel = createModel({ provider: 'openai' }) + const embeddingModel = createModel({ provider: 'embedding-provider' }) + setupModelLists({ textGeneration: [textGenModel], textEmbedding: [embeddingModel] }) + const props = createDefaultProps({ scope: ModelTypeEnum.textGeneration }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + const selector = screen.getByTestId('model-selector') + expect(selector).toHaveAttribute('data-model-list-count', '1') + }) + }) + + it('should return text embedding models for text-embedding scope', async () => { + // Arrange + const embeddingModel = createModel({ provider: 'embedding-provider' }) + setupModelLists({ textEmbedding: [embeddingModel] }) + const props = createDefaultProps({ scope: ModelTypeEnum.textEmbedding }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + const selector = screen.getByTestId('model-selector') + expect(selector).toHaveAttribute('data-model-list-count', '1') + }) + }) + + it('should return rerank models for rerank scope', async () => { + // Arrange + const rerankModel = createModel({ provider: 'rerank-provider' }) + setupModelLists({ rerank: [rerankModel] }) + const props = createDefaultProps({ scope: ModelTypeEnum.rerank }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + const selector = screen.getByTestId('model-selector') + expect(selector).toHaveAttribute('data-model-list-count', '1') + }) + }) + + it('should return tts models for tts scope', async () => { + // Arrange + const ttsModel = createModel({ provider: 'tts-provider' }) + setupModelLists({ tts: [ttsModel] }) + const props = createDefaultProps({ scope: ModelTypeEnum.tts }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + const selector = screen.getByTestId('model-selector') + expect(selector).toHaveAttribute('data-model-list-count', '1') + }) + }) + + it('should return moderation models for moderation scope', async () => { + // Arrange + const moderationModel = createModel({ provider: 'moderation-provider' }) + setupModelLists({ moderation: [moderationModel] }) + const props = createDefaultProps({ scope: ModelTypeEnum.moderation }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + const selector = screen.getByTestId('model-selector') + expect(selector).toHaveAttribute('data-model-list-count', '1') + }) + }) + + it('should return stt models for speech2text scope', async () => { + // Arrange + const sttModel = createModel({ provider: 'stt-provider' }) + setupModelLists({ stt: [sttModel] }) + const props = createDefaultProps({ scope: ModelTypeEnum.speech2text }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + const selector = screen.getByTestId('model-selector') + expect(selector).toHaveAttribute('data-model-list-count', '1') + }) + }) + + it('should return empty list for unknown scope', async () => { + // Arrange + const textGenModel = createModel({ provider: 'openai' }) + setupModelLists({ textGeneration: [textGenModel] }) + const props = createDefaultProps({ scope: 'unknown-scope' }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + const selector = screen.getByTestId('model-selector') + expect(selector).toHaveAttribute('data-model-list-count', '0') + }) + }) + }) + + describe('Memoization - currentProvider and currentModel', () => { + it('should find current provider and model from value', () => { + // Arrange + const model = createModel({ + provider: 'openai', + models: [createModelItem({ model: 'gpt-4', status: ModelStatusEnum.active })], + }) + setupModelLists({ textGeneration: [model] }) + const props = createDefaultProps({ value: { provider: 'openai', model: 'gpt-4' } }) + + // Act + render() + + // Assert + const trigger = screen.getByTestId('trigger') + expect(trigger).toHaveAttribute('data-has-current-provider', 'true') + expect(trigger).toHaveAttribute('data-has-current-model', 'true') + }) + + it('should not find provider when value.provider does not match', () => { + // Arrange + const model = createModel({ provider: 'openai' }) + setupModelLists({ textGeneration: [model] }) + const props = createDefaultProps({ value: { provider: 'anthropic', model: 'claude-3' } }) + + // Act + render() + + // Assert + const trigger = screen.getByTestId('trigger') + expect(trigger).toHaveAttribute('data-has-current-provider', 'false') + expect(trigger).toHaveAttribute('data-has-current-model', 'false') + }) + }) + + describe('Memoization - hasDeprecated', () => { + it('should set hasDeprecated to true when provider is not found', () => { + // Arrange + const props = createDefaultProps({ value: { provider: 'unknown', model: 'unknown-model' } }) + + // Act + render() + + // Assert + expect(screen.getByTestId('trigger')).toHaveAttribute('data-has-deprecated', 'true') + }) + + it('should set hasDeprecated to true when model is not found', () => { + // Arrange + const model = createModel({ provider: 'openai', models: [createModelItem({ model: 'gpt-3.5' })] }) + setupModelLists({ textGeneration: [model] }) + const props = createDefaultProps({ value: { provider: 'openai', model: 'gpt-4' } }) + + // Act + render() + + // Assert + expect(screen.getByTestId('trigger')).toHaveAttribute('data-has-deprecated', 'true') + }) + + it('should set hasDeprecated to false when provider and model are found', () => { + // Arrange + const model = createModel({ + provider: 'openai', + models: [createModelItem({ model: 'gpt-4' })], + }) + setupModelLists({ textGeneration: [model] }) + const props = createDefaultProps({ value: { provider: 'openai', model: 'gpt-4' } }) + + // Act + render() + + // Assert + expect(screen.getByTestId('trigger')).toHaveAttribute('data-has-deprecated', 'false') + }) + }) + + describe('Memoization - modelDisabled', () => { + it('should set modelDisabled to true when model status is not active', () => { + // Arrange + const model = createModel({ + provider: 'openai', + models: [createModelItem({ model: 'gpt-4', status: ModelStatusEnum.quotaExceeded })], + }) + setupModelLists({ textGeneration: [model] }) + const props = createDefaultProps({ value: { provider: 'openai', model: 'gpt-4' } }) + + // Act + render() + + // Assert + expect(screen.getByTestId('trigger')).toHaveAttribute('data-model-disabled', 'true') + }) + + it('should set modelDisabled to false when model status is active', () => { + // Arrange + const model = createModel({ + provider: 'openai', + models: [createModelItem({ model: 'gpt-4', status: ModelStatusEnum.active })], + }) + setupModelLists({ textGeneration: [model] }) + const props = createDefaultProps({ value: { provider: 'openai', model: 'gpt-4' } }) + + // Act + render() + + // Assert + expect(screen.getByTestId('trigger')).toHaveAttribute('data-model-disabled', 'false') + }) + }) + + describe('Memoization - disabled', () => { + it('should set disabled to true when isAPIKeySet is false', () => { + // Arrange + mockProviderContextValue.isAPIKeySet = false + const model = createModel({ + provider: 'openai', + models: [createModelItem({ model: 'gpt-4', status: ModelStatusEnum.active })], + }) + setupModelLists({ textGeneration: [model] }) + const props = createDefaultProps({ value: { provider: 'openai', model: 'gpt-4' } }) + + // Act + render() + + // Assert + expect(screen.getByTestId('trigger')).toHaveAttribute('data-disabled', 'true') + }) + + it('should set disabled to true when hasDeprecated is true', () => { + // Arrange + const props = createDefaultProps({ value: { provider: 'unknown', model: 'unknown' } }) + + // Act + render() + + // Assert + expect(screen.getByTestId('trigger')).toHaveAttribute('data-disabled', 'true') + }) + + it('should set disabled to true when modelDisabled is true', () => { + // Arrange + const model = createModel({ + provider: 'openai', + models: [createModelItem({ model: 'gpt-4', status: ModelStatusEnum.quotaExceeded })], + }) + setupModelLists({ textGeneration: [model] }) + const props = createDefaultProps({ value: { provider: 'openai', model: 'gpt-4' } }) + + // Act + render() + + // Assert + expect(screen.getByTestId('trigger')).toHaveAttribute('data-disabled', 'true') + }) + + it('should set disabled to false when all conditions are met', () => { + // Arrange + mockProviderContextValue.isAPIKeySet = true + const model = createModel({ + provider: 'openai', + models: [createModelItem({ model: 'gpt-4', status: ModelStatusEnum.active })], + }) + setupModelLists({ textGeneration: [model] }) + const props = createDefaultProps({ value: { provider: 'openai', model: 'gpt-4' } }) + + // Act + render() + + // Assert + expect(screen.getByTestId('trigger')).toHaveAttribute('data-disabled', 'false') + }) + }) + + // ==================== User Interactions ==================== + describe('User Interactions', () => { + describe('handleChangeModel', () => { + it('should call setModel with selected model for non-textGeneration type', async () => { + // Arrange + const setModel = vi.fn() + const ttsModel = createModel({ + provider: 'openai', + models: [createModelItem({ model: 'tts-1', model_type: ModelTypeEnum.tts })], + }) + setupModelLists({ tts: [ttsModel] }) + const props = createDefaultProps({ setModel, scope: ModelTypeEnum.tts }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + await waitFor(() => { + fireEvent.click(screen.getByTestId('model-selector')) + }) + + // Assert + await waitFor(() => { + expect(setModel).toHaveBeenCalled() + }) + }) + + it('should call fetchAndMergeValidCompletionParams for textGeneration type', async () => { + // Arrange + const setModel = vi.fn() + const textGenModel = createModel({ + provider: 'openai', + models: [createModelItem({ model: 'gpt-4', model_type: ModelTypeEnum.textGeneration, model_properties: { mode: 'chat' } })], + }) + setupModelLists({ textGeneration: [textGenModel] }) + mockFetchAndMergeValidCompletionParams.mockResolvedValue({ params: { temperature: 0.7 }, removedDetails: {} }) + const props = createDefaultProps({ setModel, scope: ModelTypeEnum.textGeneration }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + await waitFor(() => { + fireEvent.click(screen.getByTestId('model-selector')) + }) + + // Assert + await waitFor(() => { + expect(mockFetchAndMergeValidCompletionParams).toHaveBeenCalled() + }) + }) + + it('should show warning toast when parameters are removed', async () => { + // Arrange + const setModel = vi.fn() + const textGenModel = createModel({ + provider: 'openai', + models: [createModelItem({ model: 'gpt-4', model_type: ModelTypeEnum.textGeneration, model_properties: { mode: 'chat' } })], + }) + setupModelLists({ textGeneration: [textGenModel] }) + mockFetchAndMergeValidCompletionParams.mockResolvedValue({ + params: {}, + removedDetails: { invalid_param: 'unsupported' }, + }) + const props = createDefaultProps({ + setModel, + scope: ModelTypeEnum.textGeneration, + value: { completion_params: { invalid_param: 'value' } }, + }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + await waitFor(() => { + fireEvent.click(screen.getByTestId('model-selector')) + }) + + // Assert + await waitFor(() => { + expect(Toast.notify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'warning' }), + ) + }) + }) + + it('should show error toast when fetchAndMergeValidCompletionParams fails', async () => { + // Arrange + const setModel = vi.fn() + const textGenModel = createModel({ + provider: 'openai', + models: [createModelItem({ model: 'gpt-4', model_type: ModelTypeEnum.textGeneration, model_properties: { mode: 'chat' } })], + }) + setupModelLists({ textGeneration: [textGenModel] }) + mockFetchAndMergeValidCompletionParams.mockRejectedValue(new Error('Network error')) + const props = createDefaultProps({ setModel, scope: ModelTypeEnum.textGeneration }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + await waitFor(() => { + fireEvent.click(screen.getByTestId('model-selector')) + }) + + // Assert + await waitFor(() => { + expect(Toast.notify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + }) + }) + + describe('handleLLMParamsChange', () => { + it('should call setModel with updated completion_params', async () => { + // Arrange + const setModel = vi.fn() + const textGenModel = createModel({ + provider: 'openai', + models: [createModelItem({ + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + status: ModelStatusEnum.active, + })], + }) + setupModelLists({ textGeneration: [textGenModel] }) + const props = createDefaultProps({ + setModel, + scope: ModelTypeEnum.textGeneration, + value: { provider: 'openai', model: 'gpt-4' }, + }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + await waitFor(() => { + const panel = screen.getByTestId('llm-params-panel') + fireEvent.click(panel) + }) + + // Assert + await waitFor(() => { + expect(setModel).toHaveBeenCalledWith( + expect.objectContaining({ completion_params: { temperature: 0.8 } }), + ) + }) + }) + }) + + describe('handleTTSParamsChange', () => { + it('should call setModel with updated language and voice', async () => { + // Arrange + const setModel = vi.fn() + const ttsModel = createModel({ + provider: 'openai', + models: [createModelItem({ + model: 'tts-1', + model_type: ModelTypeEnum.tts, + status: ModelStatusEnum.active, + })], + }) + setupModelLists({ tts: [ttsModel] }) + const props = createDefaultProps({ + setModel, + scope: ModelTypeEnum.tts, + value: { provider: 'openai', model: 'tts-1' }, + }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + await waitFor(() => { + const panel = screen.getByTestId('tts-params-panel') + fireEvent.click(panel) + }) + + // Assert + await waitFor(() => { + expect(setModel).toHaveBeenCalledWith( + expect.objectContaining({ language: 'en-US', voice: 'alloy' }), + ) + }) + }) + }) + }) + + // ==================== Conditional Rendering ==================== + describe('Conditional Rendering', () => { + it('should render LLMParamsPanel when model type is textGeneration', async () => { + // Arrange + const textGenModel = createModel({ + provider: 'openai', + models: [createModelItem({ + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + status: ModelStatusEnum.active, + })], + }) + setupModelLists({ textGeneration: [textGenModel] }) + const props = createDefaultProps({ + value: { provider: 'openai', model: 'gpt-4' }, + scope: ModelTypeEnum.textGeneration, + }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + expect(screen.getByTestId('llm-params-panel')).toBeInTheDocument() + }) + }) + + it('should render TTSParamsPanel when model type is tts', async () => { + // Arrange + const ttsModel = createModel({ + provider: 'openai', + models: [createModelItem({ + model: 'tts-1', + model_type: ModelTypeEnum.tts, + status: ModelStatusEnum.active, + })], + }) + setupModelLists({ tts: [ttsModel] }) + const props = createDefaultProps({ + value: { provider: 'openai', model: 'tts-1' }, + scope: ModelTypeEnum.tts, + }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + expect(screen.getByTestId('tts-params-panel')).toBeInTheDocument() + }) + }) + + it('should not render LLMParamsPanel when model type is not textGeneration', async () => { + // Arrange + const embeddingModel = createModel({ + provider: 'openai', + models: [createModelItem({ + model: 'text-embedding-ada', + model_type: ModelTypeEnum.textEmbedding, + status: ModelStatusEnum.active, + })], + }) + setupModelLists({ textEmbedding: [embeddingModel] }) + const props = createDefaultProps({ + value: { provider: 'openai', model: 'text-embedding-ada' }, + scope: ModelTypeEnum.textEmbedding, + }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + }) + expect(screen.queryByTestId('llm-params-panel')).not.toBeInTheDocument() + }) + + it('should render divider when model type is textGeneration or tts', async () => { + // Arrange + const textGenModel = createModel({ + provider: 'openai', + models: [createModelItem({ + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + status: ModelStatusEnum.active, + })], + }) + setupModelLists({ textGeneration: [textGenModel] }) + const props = createDefaultProps({ + value: { provider: 'openai', model: 'gpt-4' }, + scope: ModelTypeEnum.textGeneration, + }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + const content = screen.getByTestId('portal-content') + expect(content.querySelector('.bg-divider-subtle')).toBeInTheDocument() + }) + }) + }) + + // ==================== Edge Cases ==================== + describe('Edge Cases', () => { + it('should handle null value', () => { + // Arrange + const props = createDefaultProps({ value: null }) + + // Act + render() + + // Assert + expect(screen.getByTestId('trigger')).toBeInTheDocument() + expect(screen.getByTestId('trigger')).toHaveAttribute('data-has-deprecated', 'true') + }) + + it('should handle undefined value', () => { + // Arrange + const props = createDefaultProps({ value: undefined }) + + // Act + render() + + // Assert + expect(screen.getByTestId('trigger')).toBeInTheDocument() + }) + + it('should handle empty model list', async () => { + // Arrange + setupModelLists({}) + const props = createDefaultProps({ value: { provider: 'openai', model: 'gpt-4' } }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + const selector = screen.getByTestId('model-selector') + expect(selector).toHaveAttribute('data-model-list-count', '0') + }) + }) + + it('should handle value with only provider', () => { + // Arrange + const model = createModel({ provider: 'openai' }) + setupModelLists({ textGeneration: [model] }) + const props = createDefaultProps({ value: { provider: 'openai' } }) + + // Act + render() + + // Assert + expect(screen.getByTestId('trigger')).toHaveAttribute('data-provider', 'openai') + }) + + it('should handle value with only model', () => { + // Arrange + const props = createDefaultProps({ value: { model: 'gpt-4' } }) + + // Act + render() + + // Assert + expect(screen.getByTestId('trigger')).toHaveAttribute('data-model', 'gpt-4') + }) + + it('should handle complex scope with multiple features', async () => { + // Arrange + const props = createDefaultProps({ scope: 'llm&tool-call&multi-tool-call&vision' }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + const selector = screen.getByTestId('model-selector') + const features = JSON.parse(selector.getAttribute('data-scope-features') || '[]') + expect(features).toContain('tool-call') + expect(features).toContain('multi-tool-call') + expect(features).toContain('vision') + }) + }) + + it('should handle model with all status types', () => { + // Arrange + const statuses = [ + ModelStatusEnum.active, + ModelStatusEnum.noConfigure, + ModelStatusEnum.quotaExceeded, + ModelStatusEnum.noPermission, + ModelStatusEnum.disabled, + ] + + statuses.forEach((status) => { + const model = createModel({ + provider: `provider-${status}`, + models: [createModelItem({ model: 'test', status })], + }) + setupModelLists({ textGeneration: [model] }) + + // Act + const props = createDefaultProps({ value: { provider: `provider-${status}`, model: 'test' } }) + const { unmount } = render() + + // Assert + const trigger = screen.getByTestId('trigger') + if (status === ModelStatusEnum.active) + expect(trigger).toHaveAttribute('data-model-disabled', 'false') + else + expect(trigger).toHaveAttribute('data-model-disabled', 'true') + + unmount() + }) + }) + }) + + // ==================== Portal Placement ==================== + describe('Portal Placement', () => { + it('should use left placement when isInWorkflow is true', () => { + // Arrange + const props = createDefaultProps({ isInWorkflow: true }) + + // Act + render() + + // Assert + // Portal placement is handled internally, but we verify the prop is passed + expect(screen.getByTestId('trigger')).toHaveAttribute('data-in-workflow', 'true') + }) + + it('should use bottom-end placement when isInWorkflow is false', () => { + // Arrange + const props = createDefaultProps({ isInWorkflow: false }) + + // Act + render() + + // Assert + expect(screen.getByTestId('trigger')).toHaveAttribute('data-in-workflow', 'false') + }) + }) + + // ==================== Model Selector Default Model ==================== + describe('Model Selector Default Model', () => { + it('should pass defaultModel to ModelSelector when provider and model exist', async () => { + // Arrange + const props = createDefaultProps({ value: { provider: 'openai', model: 'gpt-4' } }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + const selector = screen.getByTestId('model-selector') + const defaultModel = JSON.parse(selector.getAttribute('data-default-model') || '{}') + expect(defaultModel).toEqual({ provider: 'openai', model: 'gpt-4' }) + }) + }) + + it('should pass partial defaultModel when provider is missing', async () => { + // Arrange - component creates defaultModel when either provider or model exists + const props = createDefaultProps({ value: { model: 'gpt-4' } }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert - defaultModel is created with undefined provider + await waitFor(() => { + const selector = screen.getByTestId('model-selector') + const defaultModel = JSON.parse(selector.getAttribute('data-default-model') || '{}') + expect(defaultModel.model).toBe('gpt-4') + expect(defaultModel.provider).toBeUndefined() + }) + }) + + it('should pass partial defaultModel when model is missing', async () => { + // Arrange - component creates defaultModel when either provider or model exists + const props = createDefaultProps({ value: { provider: 'openai' } }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert - defaultModel is created with undefined model + await waitFor(() => { + const selector = screen.getByTestId('model-selector') + const defaultModel = JSON.parse(selector.getAttribute('data-default-model') || '{}') + expect(defaultModel.provider).toBe('openai') + expect(defaultModel.model).toBeUndefined() + }) + }) + + it('should pass undefined defaultModel when both provider and model are missing', async () => { + // Arrange + const props = createDefaultProps({ value: {} }) + + // Act + render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert - when defaultModel is undefined, attribute is not set (returns null) + await waitFor(() => { + const selector = screen.getByTestId('model-selector') + expect(selector.getAttribute('data-default-model')).toBeNull() + }) + }) + }) + + // ==================== Re-render Behavior ==================== + describe('Re-render Behavior', () => { + it('should update trigger when value changes', () => { + // Arrange + const props = createDefaultProps({ value: { provider: 'openai', model: 'gpt-3.5' } }) + + // Act + const { rerender } = render() + expect(screen.getByTestId('trigger')).toHaveAttribute('data-model', 'gpt-3.5') + + rerender() + + // Assert + expect(screen.getByTestId('trigger')).toHaveAttribute('data-model', 'gpt-4') + }) + + it('should update model list when scope changes', async () => { + // Arrange + const textGenModel = createModel({ provider: 'openai' }) + const embeddingModel = createModel({ provider: 'embedding-provider' }) + setupModelLists({ textGeneration: [textGenModel], textEmbedding: [embeddingModel] }) + + const props = createDefaultProps({ scope: ModelTypeEnum.textGeneration }) + + // Act + const { rerender } = render() + fireEvent.click(screen.getByTestId('portal-trigger')) + + await waitFor(() => { + expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model-list-count', '1') + }) + + // Rerender with different scope + mockPortalOpenState = true + rerender() + + // Assert + await waitFor(() => { + expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model-list-count', '1') + }) + }) + + it('should update disabled state when isAPIKeySet changes', () => { + // Arrange + const model = createModel({ + provider: 'openai', + models: [createModelItem({ model: 'gpt-4', status: ModelStatusEnum.active })], + }) + setupModelLists({ textGeneration: [model] }) + mockProviderContextValue.isAPIKeySet = true + const props = createDefaultProps({ value: { provider: 'openai', model: 'gpt-4' } }) + + // Act + const { rerender } = render() + expect(screen.getByTestId('trigger')).toHaveAttribute('data-disabled', 'false') + + mockProviderContextValue.isAPIKeySet = false + rerender() + + // Assert + expect(screen.getByTestId('trigger')).toHaveAttribute('data-disabled', 'true') + }) + }) + + // ==================== Accessibility ==================== + describe('Accessibility', () => { + it('should be keyboard accessible', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + const trigger = screen.getByTestId('portal-trigger') + expect(trigger).toBeInTheDocument() + }) + }) + + // ==================== Component Type ==================== + describe('Component Type', () => { + it('should be a functional component', () => { + // Assert + expect(typeof ModelParameterModal).toBe('function') + }) + + it('should accept all required props', () => { + // Arrange + const props = createDefaultProps() + + // Act & Assert + expect(() => render()).not.toThrow() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.spec.tsx new file mode 100644 index 0000000000..27505146b0 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/llm-params-panel.spec.tsx @@ -0,0 +1,717 @@ +import type { FormValue, ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Import component after mocks +import LLMParamsPanel from './llm-params-panel' + +// ==================== Mock Setup ==================== +// All vi.mock() calls are hoisted, so inline all mock data + +// Mock useModelParameterRules hook +const mockUseModelParameterRules = vi.fn() +vi.mock('@/service/use-common', () => ({ + useModelParameterRules: (provider: string, modelId: string) => mockUseModelParameterRules(provider, modelId), +})) + +// Mock config constants with inline data +vi.mock('@/config', () => ({ + TONE_LIST: [ + { + id: 1, + name: 'Creative', + config: { + temperature: 0.8, + top_p: 0.9, + presence_penalty: 0.1, + frequency_penalty: 0.1, + }, + }, + { + id: 2, + name: 'Balanced', + config: { + temperature: 0.5, + top_p: 0.85, + presence_penalty: 0.2, + frequency_penalty: 0.3, + }, + }, + { + id: 3, + name: 'Precise', + config: { + temperature: 0.2, + top_p: 0.75, + presence_penalty: 0.5, + frequency_penalty: 0.5, + }, + }, + { + id: 4, + name: 'Custom', + }, + ], + STOP_PARAMETER_RULE: { + default: [], + help: { + en_US: 'Stop sequences help text', + zh_Hans: '停止序列帮助文本', + }, + label: { + en_US: 'Stop sequences', + zh_Hans: '停止序列', + }, + name: 'stop', + required: false, + type: 'tag', + tagPlaceholder: { + en_US: 'Enter sequence and press Tab', + zh_Hans: '输入序列并按 Tab 键', + }, + }, + PROVIDER_WITH_PRESET_TONE: ['langgenius/openai/openai', 'langgenius/azure_openai/azure_openai'], +})) + +// Mock PresetsParameter component +vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter', () => ({ + default: ({ onSelect }: { onSelect: (toneId: number) => void }) => ( +
+ + + + +
+ ), +})) + +// Mock ParameterItem component +vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item', () => ({ + default: ({ parameterRule, value, onChange, onSwitch, isInWorkflow }: { + parameterRule: { name: string, label: { en_US: string }, default?: unknown } + value: unknown + onChange: (v: unknown) => void + onSwitch: (checked: boolean, assignValue: unknown) => void + isInWorkflow?: boolean + }) => ( +
+ {parameterRule.label.en_US} + + + +
+ ), +})) + +// ==================== Test Utilities ==================== + +/** + * Factory function to create a ModelParameterRule with defaults + */ +const createParameterRule = (overrides: Partial = {}): 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() + }) + }) +})