From 9a6b4147bc0173cc62023a10313a1bb2fa6e0df7 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Mon, 29 Dec 2025 16:45:25 +0800 Subject: [PATCH] test: add comprehensive tests for plugin authentication components (#30094) Co-authored-by: CodingOnStar --- .../authorize/add-oauth-button.tsx | 1 + .../authorize/authorize-components.spec.tsx | 2252 +++++++++++++++++ .../plugin-auth/authorize/index.spec.tsx | 786 ++++++ .../plugins/plugin-auth/index.spec.tsx | 2035 +++++++++++++++ .../plugins/plugin-item/action.spec.tsx | 937 +++++++ .../plugins/plugin-item/index.spec.tsx | 1016 ++++++++ .../plugins/plugin-page/empty/index.spec.tsx | 583 +++++ .../filter-management/index.spec.tsx | 1175 +++++++++ .../plugins/plugin-page/list/index.spec.tsx | 702 +++++ 9 files changed, 9487 insertions(+) create mode 100644 web/app/components/plugins/plugin-auth/authorize/authorize-components.spec.tsx create mode 100644 web/app/components/plugins/plugin-auth/authorize/index.spec.tsx create mode 100644 web/app/components/plugins/plugin-auth/index.spec.tsx create mode 100644 web/app/components/plugins/plugin-item/action.spec.tsx create mode 100644 web/app/components/plugins/plugin-item/index.spec.tsx create mode 100644 web/app/components/plugins/plugin-page/empty/index.spec.tsx create mode 100644 web/app/components/plugins/plugin-page/filter-management/index.spec.tsx create mode 100644 web/app/components/plugins/plugin-page/list/index.spec.tsx diff --git a/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx index e5c1541214..def33d4957 100644 --- a/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx @@ -225,6 +225,7 @@ const AddOAuthButton = ({ >
+ new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }) + +const createWrapper = () => { + const testQueryClient = createTestQueryClient() + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +// Mock API hooks - these make network requests so must be mocked +const mockGetPluginOAuthUrl = vi.fn() +const mockGetPluginOAuthClientSchema = vi.fn() +const mockSetPluginOAuthCustomClient = vi.fn() +const mockDeletePluginOAuthCustomClient = vi.fn() +const mockInvalidPluginOAuthClientSchema = vi.fn() +const mockAddPluginCredential = vi.fn() +const mockUpdatePluginCredential = vi.fn() +const mockGetPluginCredentialSchema = vi.fn() + +vi.mock('../hooks/use-credential', () => ({ + useGetPluginOAuthUrlHook: () => ({ + mutateAsync: mockGetPluginOAuthUrl, + }), + useGetPluginOAuthClientSchemaHook: () => ({ + data: mockGetPluginOAuthClientSchema(), + isLoading: false, + }), + useSetPluginOAuthCustomClientHook: () => ({ + mutateAsync: mockSetPluginOAuthCustomClient, + }), + useDeletePluginOAuthCustomClientHook: () => ({ + mutateAsync: mockDeletePluginOAuthCustomClient, + }), + useInvalidPluginOAuthClientSchemaHook: () => mockInvalidPluginOAuthClientSchema, + useAddPluginCredentialHook: () => ({ + mutateAsync: mockAddPluginCredential, + }), + useUpdatePluginCredentialHook: () => ({ + mutateAsync: mockUpdatePluginCredential, + }), + useGetPluginCredentialSchemaHook: () => ({ + data: mockGetPluginCredentialSchema(), + isLoading: false, + }), +})) + +// Mock openOAuthPopup - requires window operations +const mockOpenOAuthPopup = vi.fn() +vi.mock('@/hooks/use-oauth', () => ({ + openOAuthPopup: (...args: unknown[]) => mockOpenOAuthPopup(...args), +})) + +// Mock service/use-triggers - API service +vi.mock('@/service/use-triggers', () => ({ + useTriggerPluginDynamicOptions: () => ({ + data: { options: [] }, + isLoading: false, + }), + useTriggerPluginDynamicOptionsInfo: () => ({ + data: null, + isLoading: false, + }), + useInvalidTriggerDynamicOptions: () => vi.fn(), +})) + +// Mock AuthForm to control form validation in tests +const mockGetFormValues = vi.fn() +vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({ + default: vi.fn().mockImplementation(({ ref }: { ref: { current: unknown } }) => { + if (ref) + ref.current = { getFormValues: mockGetFormValues } + + return
Auth Form
+ }), +})) + +// Mock useToastContext +const mockNotify = vi.fn() +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ notify: mockNotify }), +})) + +// Factory function for creating test PluginPayload +const createPluginPayload = (overrides: Partial = {}): PluginPayload => ({ + category: AuthCategory.tool, + provider: 'test-provider', + ...overrides, +}) + +// Factory for form schemas +const createFormSchema = (overrides: Partial = {}): FormSchema => ({ + type: 'text-input' as FormSchema['type'], + name: 'test-field', + label: 'Test Field', + required: false, + ...overrides, +}) + +// ==================== AddApiKeyButton Tests ==================== +describe('AddApiKeyButton', () => { + let AddApiKeyButton: typeof import('./add-api-key-button').default + + beforeEach(async () => { + vi.clearAllMocks() + mockGetPluginCredentialSchema.mockReturnValue([]) + const importedAddApiKeyButton = await import('./add-api-key-button') + AddApiKeyButton = importedAddApiKeyButton.default + }) + + describe('Rendering', () => { + it('should render button with default text', () => { + const pluginPayload = createPluginPayload() + + render(, { wrapper: createWrapper() }) + + expect(screen.getByRole('button')).toHaveTextContent('Use Api Key') + }) + + it('should render button with custom text', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button')).toHaveTextContent('Custom API Key') + }) + + it('should apply button variant', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button').className).toContain('btn-primary') + }) + + it('should use secondary-accent variant by default', () => { + const pluginPayload = createPluginPayload() + + render(, { wrapper: createWrapper() }) + + // Verify the default button has secondary-accent variant class + expect(screen.getByRole('button').className).toContain('btn-secondary-accent') + }) + }) + + describe('Props Testing', () => { + it('should disable button when disabled prop is true', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should not disable button when disabled prop is false', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button')).not.toBeDisabled() + }) + + it('should accept formSchemas prop', () => { + const pluginPayload = createPluginPayload() + const formSchemas = [createFormSchema({ name: 'api_key', label: 'API Key' })] + + expect(() => { + render( + , + { wrapper: createWrapper() }, + ) + }).not.toThrow() + }) + }) + + describe('User Interactions', () => { + it('should open modal when button is clicked', async () => { + const pluginPayload = createPluginPayload() + mockGetPluginCredentialSchema.mockReturnValue([ + createFormSchema({ name: 'api_key', label: 'API Key' }), + ]) + + render(, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() + }) + }) + + it('should not open modal when button is disabled', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + const button = screen.getByRole('button') + fireEvent.click(button) + + // Modal should not appear + expect(screen.queryByText('plugin.auth.useApiAuth')).not.toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty pluginPayload properties', () => { + const pluginPayload = createPluginPayload({ + provider: '', + providerType: undefined, + }) + + expect(() => { + render(, { wrapper: createWrapper() }) + }).not.toThrow() + }) + + it('should handle all auth categories', () => { + const categories = [AuthCategory.tool, AuthCategory.datasource, AuthCategory.model, AuthCategory.trigger] + + categories.forEach((category) => { + const pluginPayload = createPluginPayload({ category }) + const { unmount } = render(, { wrapper: createWrapper() }) + expect(screen.getByRole('button')).toBeInTheDocument() + unmount() + }) + }) + }) + + describe('Modal Behavior', () => { + it('should close modal when onClose is called from ApiKeyModal', async () => { + const pluginPayload = createPluginPayload() + mockGetPluginCredentialSchema.mockReturnValue([ + createFormSchema({ name: 'api_key', label: 'API Key' }), + ]) + + render(, { wrapper: createWrapper() }) + + // Open modal + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() + }) + + // Close modal via cancel button + fireEvent.click(screen.getByText('common.operation.cancel')) + + await waitFor(() => { + expect(screen.queryByText('plugin.auth.useApiAuth')).not.toBeInTheDocument() + }) + }) + + it('should call onUpdate when provided and modal triggers update', async () => { + const pluginPayload = createPluginPayload() + const onUpdate = vi.fn() + mockGetPluginCredentialSchema.mockReturnValue([ + createFormSchema({ name: 'api_key', label: 'API Key' }), + ]) + + render( + , + { wrapper: createWrapper() }, + ) + + // Open modal + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() + }) + }) + }) + + describe('Memoization', () => { + it('should be a memoized component', async () => { + const AddApiKeyButtonDefault = (await import('./add-api-key-button')).default + expect(typeof AddApiKeyButtonDefault).toBe('object') + }) + }) +}) + +// ==================== AddOAuthButton Tests ==================== +describe('AddOAuthButton', () => { + let AddOAuthButton: typeof import('./add-oauth-button').default + + beforeEach(async () => { + vi.clearAllMocks() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + client_params: {}, + redirect_uri: 'https://example.com/callback', + }) + mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: 'https://oauth.example.com/auth' }) + const importedAddOAuthButton = await import('./add-oauth-button') + AddOAuthButton = importedAddOAuthButton.default + }) + + describe('Rendering - Not Configured State', () => { + it('should render setup OAuth button when not configured', () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + }) + + render(, { wrapper: createWrapper() }) + + expect(screen.getByText('plugin.auth.setupOAuth')).toBeInTheDocument() + }) + + it('should apply button variant to setup button', () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + }) + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button').className).toContain('btn-secondary') + }) + }) + + describe('Rendering - Configured State', () => { + it('should render OAuth button when system OAuth params exist', () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: true, + }) + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('Connect OAuth')).toBeInTheDocument() + }) + + it('should render OAuth button when custom client is enabled', () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: true, + is_system_oauth_params_exists: false, + }) + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('OAuth')).toBeInTheDocument() + }) + + it('should show custom badge when custom client is enabled', () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: true, + is_system_oauth_params_exists: false, + }) + + render(, { wrapper: createWrapper() }) + + expect(screen.getByText('plugin.auth.custom')).toBeInTheDocument() + }) + }) + + describe('Props Testing', () => { + it('should disable button when disabled prop is true', () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + }) + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should apply custom className', () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: true, + is_system_oauth_params_exists: false, + }) + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button').className).toContain('custom-class') + }) + + it('should use oAuthData prop when provided', () => { + const pluginPayload = createPluginPayload() + const oAuthData = { + schema: [], + is_oauth_custom_client_enabled: true, + is_system_oauth_params_exists: true, + client_params: {}, + redirect_uri: 'https://custom.example.com/callback', + } + + render( + , + { wrapper: createWrapper() }, + ) + + // Should render configured button since oAuthData has is_system_oauth_params_exists=true + expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should trigger OAuth flow when configured button is clicked', async () => { + const pluginPayload = createPluginPayload() + const onUpdate = vi.fn() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: true, + is_system_oauth_params_exists: false, + }) + mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: 'https://oauth.example.com/auth' }) + + render( + , + { wrapper: createWrapper() }, + ) + + // Click the main button area (left side) + const buttonText = screen.getByText('use oauth') + fireEvent.click(buttonText) + + await waitFor(() => { + expect(mockGetPluginOAuthUrl).toHaveBeenCalled() + }) + }) + + it('should open settings when setup button is clicked', async () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + redirect_uri: 'https://example.com/callback', + }) + + render(, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByText('plugin.auth.setupOAuth')) + + await waitFor(() => { + expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() + }) + }) + + it('should not trigger OAuth when no authorization_url is returned', async () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: true, + is_system_oauth_params_exists: false, + }) + mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: '' }) + + render(, { wrapper: createWrapper() }) + + const buttonText = screen.getByText('use oauth') + fireEvent.click(buttonText) + + await waitFor(() => { + expect(mockGetPluginOAuthUrl).toHaveBeenCalled() + }) + + expect(mockOpenOAuthPopup).not.toHaveBeenCalled() + }) + + it('should call onUpdate callback after successful OAuth', async () => { + const pluginPayload = createPluginPayload() + const onUpdate = vi.fn() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: true, + is_system_oauth_params_exists: false, + }) + mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: 'https://oauth.example.com/auth' }) + // Simulate openOAuthPopup calling the success callback + mockOpenOAuthPopup.mockImplementation((url, callback) => { + callback?.() + }) + + render( + , + { wrapper: createWrapper() }, + ) + + const buttonText = screen.getByText('use oauth') + fireEvent.click(buttonText) + + await waitFor(() => { + expect(mockOpenOAuthPopup).toHaveBeenCalledWith( + 'https://oauth.example.com/auth', + expect.any(Function), + ) + }) + + // Verify onUpdate was called through the callback + expect(onUpdate).toHaveBeenCalled() + }) + + it('should open OAuth settings when settings icon is clicked', async () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })], + is_oauth_custom_client_enabled: true, + is_system_oauth_params_exists: false, + redirect_uri: 'https://example.com/callback', + }) + + render(, { wrapper: createWrapper() }) + + // Click the settings icon using data-testid for reliable selection + const settingsButton = screen.getByTestId('oauth-settings-button') + fireEvent.click(settingsButton) + + await waitFor(() => { + expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() + }) + }) + + it('should close OAuth settings modal when onClose is called', async () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + redirect_uri: 'https://example.com/callback', + }) + + render(, { wrapper: createWrapper() }) + + // Open settings + fireEvent.click(screen.getByText('plugin.auth.setupOAuth')) + + await waitFor(() => { + expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() + }) + + // Close settings via cancel button + fireEvent.click(screen.getByText('common.operation.cancel')) + + await waitFor(() => { + expect(screen.queryByText('plugin.auth.oauthClientSettings')).not.toBeInTheDocument() + }) + }) + }) + + describe('Schema Processing', () => { + it('should handle is_system_oauth_params_exists state', async () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: true, + redirect_uri: 'https://example.com/callback', + }) + + render(, { wrapper: createWrapper() }) + + // Should show the configured button, not setup button + expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument() + }) + + it('should open OAuth settings modal with correct data', async () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [createFormSchema({ name: 'client_id', label: 'Client ID', required: true })], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + redirect_uri: 'https://example.com/callback', + }) + + render(, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByText('plugin.auth.setupOAuth')) + + await waitFor(() => { + // OAuthClientSettings modal should open + expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() + }) + }) + + it('should handle client_params defaults in schema', async () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [ + createFormSchema({ name: 'client_id', label: 'Client ID' }), + createFormSchema({ name: 'client_secret', label: 'Client Secret' }), + ], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: true, + client_params: { + client_id: 'preset-client-id', + client_secret: 'preset-secret', + }, + redirect_uri: 'https://example.com/callback', + }) + + render(, { wrapper: createWrapper() }) + + // Open settings by clicking the gear icon + const button = screen.getByRole('button') + const gearIconContainer = button.querySelector('[class*="shrink-0"][class*="w-8"]') + if (gearIconContainer) + fireEvent.click(gearIconContainer) + + await waitFor(() => { + expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() + }) + }) + + it('should handle __auth_client__ logic when configured with system OAuth and no custom client', () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: true, + client_params: {}, + }) + + render(, { wrapper: createWrapper() }) + + // Should render configured button (not setup button) + expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument() + }) + + it('should open OAuth settings when system OAuth params exist', async () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [createFormSchema({ name: 'client_id', label: 'Client ID', required: true })], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: true, + redirect_uri: 'https://example.com/callback', + }) + + render(, { wrapper: createWrapper() }) + + // Click the settings icon + const button = screen.getByRole('button') + const gearIconContainer = button.querySelector('[class*="shrink-0"][class*="w-8"]') + if (gearIconContainer) + fireEvent.click(gearIconContainer) + + await waitFor(() => { + // OAuthClientSettings modal should open + expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() + }) + }) + }) + + describe('Clipboard Operations', () => { + it('should have clipboard API available for copy operations', async () => { + const pluginPayload = createPluginPayload() + const mockWriteText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: mockWriteText }, + configurable: true, + }) + + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [createFormSchema({ name: 'client_id', label: 'Client ID', required: true })], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + redirect_uri: 'https://example.com/callback', + }) + + render(, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByText('plugin.auth.setupOAuth')) + + await waitFor(() => { + // OAuthClientSettings modal opens + expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() + }) + + // Verify clipboard API is available + expect(navigator.clipboard.writeText).toBeDefined() + }) + }) + + describe('__auth_client__ Logic', () => { + it('should return default when not configured and system OAuth params exist', () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: true, + client_params: {}, + }) + + render(, { wrapper: createWrapper() }) + + // When isConfigured is true (is_system_oauth_params_exists=true), it should show the configured button + expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument() + }) + + it('should return custom when not configured and no system OAuth params', () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + client_params: {}, + }) + + render(, { wrapper: createWrapper() }) + + // When not configured, it should show the setup button + expect(screen.getByText('plugin.auth.setupOAuth')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty schema', () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + }) + + expect(() => { + render(, { wrapper: createWrapper() }) + }).not.toThrow() + }) + + it('should handle undefined oAuthData fields', () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue(undefined) + + expect(() => { + render(, { wrapper: createWrapper() }) + }).not.toThrow() + }) + + it('should handle null client_params', () => { + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [createFormSchema({ name: 'test' })], + is_oauth_custom_client_enabled: true, + is_system_oauth_params_exists: true, + client_params: null, + }) + + expect(() => { + render(, { wrapper: createWrapper() }) + }).not.toThrow() + }) + }) +}) + +// ==================== ApiKeyModal Tests ==================== +describe('ApiKeyModal', () => { + let ApiKeyModal: typeof import('./api-key-modal').default + + beforeEach(async () => { + vi.clearAllMocks() + mockGetPluginCredentialSchema.mockReturnValue([ + createFormSchema({ name: 'api_key', label: 'API Key', required: true }), + ]) + mockAddPluginCredential.mockResolvedValue({}) + mockUpdatePluginCredential.mockResolvedValue({}) + // Reset form values mock to return validation failed by default + mockGetFormValues.mockReturnValue({ + isCheckValidated: false, + values: {}, + }) + const importedApiKeyModal = await import('./api-key-modal') + ApiKeyModal = importedApiKeyModal.default + }) + + describe('Rendering', () => { + it('should render modal with title', () => { + const pluginPayload = createPluginPayload() + + render(, { wrapper: createWrapper() }) + + expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() + }) + + it('should render modal with subtitle', () => { + const pluginPayload = createPluginPayload() + + render(, { wrapper: createWrapper() }) + + expect(screen.getByText('plugin.auth.useApiAuthDesc')).toBeInTheDocument() + }) + + it('should render form when data is loaded', () => { + const pluginPayload = createPluginPayload() + + render(, { wrapper: createWrapper() }) + + // AuthForm is mocked, so check for the mock element + expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument() + }) + }) + + describe('Props Testing', () => { + it('should call onClose when modal is closed', () => { + const pluginPayload = createPluginPayload() + const onClose = vi.fn() + + render( + , + { wrapper: createWrapper() }, + ) + + // Find and click cancel button + const cancelButton = screen.getByText('common.operation.cancel') + fireEvent.click(cancelButton) + + expect(onClose).toHaveBeenCalled() + }) + + it('should disable confirm button when disabled prop is true', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + const confirmButton = screen.getByText('common.operation.save') + expect(confirmButton.closest('button')).toBeDisabled() + }) + + it('should show modal when editValues is provided', () => { + const pluginPayload = createPluginPayload() + const editValues = { + __name__: 'Test Name', + __credential_id__: 'test-id', + api_key: 'test-key', + } + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() + }) + + it('should use formSchemas from props when provided', () => { + const pluginPayload = createPluginPayload() + const customSchemas = [ + createFormSchema({ name: 'custom_field', label: 'Custom Field' }), + ] + + render( + , + { wrapper: createWrapper() }, + ) + + // AuthForm is mocked, verify modal renders + expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument() + }) + }) + + describe('Form Behavior', () => { + it('should render AuthForm component', () => { + const pluginPayload = createPluginPayload() + + render(, { wrapper: createWrapper() }) + + // AuthForm is mocked, verify it's rendered + expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument() + }) + + it('should render modal with editValues', () => { + const pluginPayload = createPluginPayload() + const editValues = { + __name__: 'Existing Name', + api_key: 'existing-key', + } + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() + }) + }) + + describe('Form Submission - handleConfirm', () => { + beforeEach(() => { + // Default: form validation passes with empty values + mockGetFormValues.mockReturnValue({ + isCheckValidated: true, + values: { + __name__: 'Test Name', + api_key: 'test-api-key', + }, + }) + }) + + it('should call addPluginCredential when creating new credential', async () => { + const pluginPayload = createPluginPayload() + const onClose = vi.fn() + const onUpdate = vi.fn() + mockGetPluginCredentialSchema.mockReturnValue([ + createFormSchema({ name: 'api_key', label: 'API Key' }), + ]) + mockAddPluginCredential.mockResolvedValue({}) + + render( + , + { wrapper: createWrapper() }, + ) + + // Click confirm button + const confirmButton = screen.getByText('common.operation.save') + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(mockAddPluginCredential).toHaveBeenCalled() + }) + }) + + it('should call updatePluginCredential when editing existing credential', async () => { + const pluginPayload = createPluginPayload() + const onClose = vi.fn() + const onUpdate = vi.fn() + const editValues = { + __name__: 'Test Credential', + __credential_id__: 'test-credential-id', + api_key: 'existing-key', + } + mockGetPluginCredentialSchema.mockReturnValue([ + createFormSchema({ name: 'api_key', label: 'API Key' }), + ]) + mockUpdatePluginCredential.mockResolvedValue({}) + mockGetFormValues.mockReturnValue({ + isCheckValidated: true, + values: { + __name__: 'Test Credential', + __credential_id__: 'test-credential-id', + api_key: 'updated-key', + }, + }) + + render( + , + { wrapper: createWrapper() }, + ) + + // Click confirm button + const confirmButton = screen.getByText('common.operation.save') + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(mockUpdatePluginCredential).toHaveBeenCalled() + }) + }) + + it('should call onClose and onUpdate after successful submission', async () => { + const pluginPayload = createPluginPayload() + const onClose = vi.fn() + const onUpdate = vi.fn() + mockGetPluginCredentialSchema.mockReturnValue([ + createFormSchema({ name: 'api_key', label: 'API Key' }), + ]) + mockAddPluginCredential.mockResolvedValue({}) + + render( + , + { wrapper: createWrapper() }, + ) + + // Click confirm button + const confirmButton = screen.getByText('common.operation.save') + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(onClose).toHaveBeenCalled() + expect(onUpdate).toHaveBeenCalled() + }) + }) + + it('should not call API when form validation fails', async () => { + const pluginPayload = createPluginPayload() + mockGetPluginCredentialSchema.mockReturnValue([ + createFormSchema({ name: 'api_key', label: 'API Key', required: true }), + ]) + mockGetFormValues.mockReturnValue({ + isCheckValidated: false, + values: {}, + }) + + render( + , + { wrapper: createWrapper() }, + ) + + // Click confirm button + const confirmButton = screen.getByText('common.operation.save') + fireEvent.click(confirmButton) + + // Verify API was not called since validation failed synchronously + expect(mockAddPluginCredential).not.toHaveBeenCalled() + }) + + it('should handle doingAction state to prevent double submission', async () => { + const pluginPayload = createPluginPayload() + mockGetPluginCredentialSchema.mockReturnValue([ + createFormSchema({ name: 'api_key', label: 'API Key' }), + ]) + // Make the API call slow + mockAddPluginCredential.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))) + + render( + , + { wrapper: createWrapper() }, + ) + + // Click confirm button twice quickly + const confirmButton = screen.getByText('common.operation.save') + fireEvent.click(confirmButton) + fireEvent.click(confirmButton) + + // Should only be called once due to doingAction guard + await waitFor(() => { + expect(mockAddPluginCredential).toHaveBeenCalledTimes(1) + }) + }) + + it('should return early if doingActionRef is true during concurrent clicks', async () => { + const pluginPayload = createPluginPayload() + mockGetPluginCredentialSchema.mockReturnValue([ + createFormSchema({ name: 'api_key', label: 'API Key' }), + ]) + + // Create a promise that we can control + let resolveFirstCall: (value?: unknown) => void = () => {} + let apiCallCount = 0 + + mockAddPluginCredential.mockImplementation(() => { + apiCallCount++ + if (apiCallCount === 1) { + // First call: return a pending promise + return new Promise((resolve) => { + resolveFirstCall = resolve + }) + } + // Subsequent calls should not happen but return resolved promise + return Promise.resolve({}) + }) + + render( + , + { wrapper: createWrapper() }, + ) + + const confirmButton = screen.getByText('common.operation.save') + + // First click starts the request + fireEvent.click(confirmButton) + + // Wait for the first API call to be made + await waitFor(() => { + expect(apiCallCount).toBe(1) + }) + + // Second click while first request is still pending should be ignored + fireEvent.click(confirmButton) + + // Verify only one API call was made (no additional calls) + expect(apiCallCount).toBe(1) + + // Clean up by resolving the promise + resolveFirstCall() + }) + + it('should call onRemove when extra button is clicked in edit mode', async () => { + const pluginPayload = createPluginPayload() + const onRemove = vi.fn() + const editValues = { + __name__: 'Test Credential', + __credential_id__: 'test-credential-id', + } + mockGetPluginCredentialSchema.mockReturnValue([ + createFormSchema({ name: 'api_key', label: 'API Key' }), + ]) + + render( + , + { wrapper: createWrapper() }, + ) + + // Find and click the remove button + const removeButton = screen.getByText('common.operation.remove') + fireEvent.click(removeButton) + + expect(onRemove).toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty credentials schema', () => { + const pluginPayload = createPluginPayload() + mockGetPluginCredentialSchema.mockReturnValue([]) + + render(, { wrapper: createWrapper() }) + + // Should still render the modal with authorization name field + expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() + }) + + it('should handle undefined detail in pluginPayload', () => { + const pluginPayload = createPluginPayload({ detail: undefined }) + + expect(() => { + render(, { wrapper: createWrapper() }) + }).not.toThrow() + }) + + it('should handle form schema with default values', () => { + const pluginPayload = createPluginPayload() + mockGetPluginCredentialSchema.mockReturnValue([ + createFormSchema({ name: 'api_key', label: 'API Key', default: 'default-key' }), + ]) + + expect(() => { + render( + , + { wrapper: createWrapper() }, + ) + }).not.toThrow() + + expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument() + }) + }) +}) + +// ==================== OAuthClientSettings Tests ==================== +describe('OAuthClientSettings', () => { + let OAuthClientSettings: typeof import('./oauth-client-settings').default + + beforeEach(async () => { + vi.clearAllMocks() + mockSetPluginOAuthCustomClient.mockResolvedValue({}) + mockDeletePluginOAuthCustomClient.mockResolvedValue({}) + const importedOAuthClientSettings = await import('./oauth-client-settings') + OAuthClientSettings = importedOAuthClientSettings.default + }) + + const defaultSchemas: FormSchema[] = [ + createFormSchema({ name: 'client_id', label: 'Client ID', required: true }), + createFormSchema({ name: 'client_secret', label: 'Client Secret', required: true }), + ] + + describe('Rendering', () => { + it('should render modal with correct title', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() + }) + + it('should render Save and Auth button', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('plugin.auth.saveAndAuth')).toBeInTheDocument() + }) + + it('should render Save Only button', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('plugin.auth.saveOnly')).toBeInTheDocument() + }) + + it('should render Cancel button', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() + }) + + it('should render form from schemas', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + // AuthForm is mocked + expect(screen.getByTestId('mock-auth-form')).toBeInTheDocument() + }) + }) + + describe('Props Testing', () => { + it('should call onClose when cancel button is clicked', () => { + const pluginPayload = createPluginPayload() + const onClose = vi.fn() + + render( + , + { wrapper: createWrapper() }, + ) + + fireEvent.click(screen.getByText('common.operation.cancel')) + expect(onClose).toHaveBeenCalled() + }) + + it('should disable buttons when disabled prop is true', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + const confirmButton = screen.getByText('plugin.auth.saveAndAuth') + expect(confirmButton.closest('button')).toBeDisabled() + }) + + it('should render with editValues', () => { + const pluginPayload = createPluginPayload() + const editValues = { + client_id: 'existing-client-id', + client_secret: 'existing-secret', + __oauth_client__: 'custom', + } + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() + }) + }) + + describe('Remove Button', () => { + it('should show remove button when custom client and hasOriginalClientParams', () => { + const pluginPayload = createPluginPayload() + const schemasWithOAuthClient: FormSchema[] = [ + { + name: '__oauth_client__', + label: 'OAuth Client', + type: 'radio' as FormSchema['type'], + options: [ + { label: 'Default', value: 'default' }, + { label: 'Custom', value: 'custom' }, + ], + default: 'custom', + required: false, + }, + ...defaultSchemas, + ] + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('common.operation.remove')).toBeInTheDocument() + }) + + it('should not show remove button when using default client', () => { + const pluginPayload = createPluginPayload() + const schemasWithOAuthClient: FormSchema[] = [ + { + name: '__oauth_client__', + label: 'OAuth Client', + type: 'radio' as FormSchema['type'], + options: [ + { label: 'Default', value: 'default' }, + { label: 'Custom', value: 'custom' }, + ], + default: 'default', + required: false, + }, + ...defaultSchemas, + ] + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument() + }) + }) + + describe('Form Submission', () => { + beforeEach(() => { + // Default: form validation passes + mockGetFormValues.mockReturnValue({ + isCheckValidated: true, + values: { + __oauth_client__: 'custom', + client_id: 'test-client-id', + client_secret: 'test-secret', + }, + }) + }) + + it('should render Save and Auth button that is clickable', async () => { + const pluginPayload = createPluginPayload() + const onAuth = vi.fn().mockResolvedValue(undefined) + + render( + , + { wrapper: createWrapper() }, + ) + + const saveAndAuthButton = screen.getByText('plugin.auth.saveAndAuth') + expect(saveAndAuthButton).toBeInTheDocument() + expect(saveAndAuthButton.closest('button')).not.toBeDisabled() + }) + + it('should call setPluginOAuthCustomClient when Save Only is clicked', async () => { + const pluginPayload = createPluginPayload() + const onClose = vi.fn() + const onUpdate = vi.fn() + mockSetPluginOAuthCustomClient.mockResolvedValue({}) + + render( + , + { wrapper: createWrapper() }, + ) + + // Click Save Only button + fireEvent.click(screen.getByText('plugin.auth.saveOnly')) + + await waitFor(() => { + expect(mockSetPluginOAuthCustomClient).toHaveBeenCalled() + }) + }) + + it('should call onClose and onUpdate after successful submission', async () => { + const pluginPayload = createPluginPayload() + const onClose = vi.fn() + const onUpdate = vi.fn() + mockSetPluginOAuthCustomClient.mockResolvedValue({}) + + render( + , + { wrapper: createWrapper() }, + ) + + fireEvent.click(screen.getByText('plugin.auth.saveOnly')) + + await waitFor(() => { + expect(onClose).toHaveBeenCalled() + expect(onUpdate).toHaveBeenCalled() + }) + }) + + it('should call onAuth after handleConfirmAndAuthorize', async () => { + const pluginPayload = createPluginPayload() + const onAuth = vi.fn().mockResolvedValue(undefined) + const onClose = vi.fn() + mockSetPluginOAuthCustomClient.mockResolvedValue({}) + + render( + , + { wrapper: createWrapper() }, + ) + + // Click Save and Auth button + fireEvent.click(screen.getByText('plugin.auth.saveAndAuth')) + + await waitFor(() => { + expect(mockSetPluginOAuthCustomClient).toHaveBeenCalled() + expect(onAuth).toHaveBeenCalled() + }) + }) + + it('should handle form with empty values', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + // Modal should render with save buttons + expect(screen.getByText('plugin.auth.saveOnly')).toBeInTheDocument() + expect(screen.getByText('plugin.auth.saveAndAuth')).toBeInTheDocument() + }) + + it('should call deletePluginOAuthCustomClient when Remove is clicked', async () => { + const pluginPayload = createPluginPayload() + const onClose = vi.fn() + const onUpdate = vi.fn() + mockDeletePluginOAuthCustomClient.mockResolvedValue({}) + + const schemasWithOAuthClient: FormSchema[] = [ + { + name: '__oauth_client__', + label: 'OAuth Client', + type: 'radio' as FormSchema['type'], + options: [ + { label: 'Default', value: 'default' }, + { label: 'Custom', value: 'custom' }, + ], + default: 'custom', + required: false, + }, + ...defaultSchemas, + ] + + render( + , + { wrapper: createWrapper() }, + ) + + // Click Remove button + fireEvent.click(screen.getByText('common.operation.remove')) + + await waitFor(() => { + expect(mockDeletePluginOAuthCustomClient).toHaveBeenCalled() + }) + }) + + it('should call onClose and onUpdate after successful removal', async () => { + const pluginPayload = createPluginPayload() + const onClose = vi.fn() + const onUpdate = vi.fn() + mockDeletePluginOAuthCustomClient.mockResolvedValue({}) + + const schemasWithOAuthClient: FormSchema[] = [ + { + name: '__oauth_client__', + label: 'OAuth Client', + type: 'radio' as FormSchema['type'], + options: [ + { label: 'Default', value: 'default' }, + { label: 'Custom', value: 'custom' }, + ], + default: 'custom', + required: false, + }, + ...defaultSchemas, + ] + + render( + , + { wrapper: createWrapper() }, + ) + + fireEvent.click(screen.getByText('common.operation.remove')) + + await waitFor(() => { + expect(onClose).toHaveBeenCalled() + expect(onUpdate).toHaveBeenCalled() + }) + }) + + it('should prevent double submission when doingAction is true', async () => { + const pluginPayload = createPluginPayload() + // Make the API call slow + mockSetPluginOAuthCustomClient.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))) + + render( + , + { wrapper: createWrapper() }, + ) + + // Click Save Only button twice quickly + const saveButton = screen.getByText('plugin.auth.saveOnly') + fireEvent.click(saveButton) + fireEvent.click(saveButton) + + await waitFor(() => { + expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledTimes(1) + }) + }) + + it('should return early from handleConfirm if doingActionRef is true', async () => { + const pluginPayload = createPluginPayload() + let resolveFirstCall: (value?: unknown) => void = () => {} + let apiCallCount = 0 + + mockSetPluginOAuthCustomClient.mockImplementation(() => { + apiCallCount++ + if (apiCallCount === 1) { + return new Promise((resolve) => { + resolveFirstCall = resolve + }) + } + return Promise.resolve({}) + }) + + render( + , + { wrapper: createWrapper() }, + ) + + const saveButton = screen.getByText('plugin.auth.saveOnly') + + // First click starts the request + fireEvent.click(saveButton) + + // Wait for the first API call to be made + await waitFor(() => { + expect(apiCallCount).toBe(1) + }) + + // Second click while first request is pending should be ignored + fireEvent.click(saveButton) + + // Verify only one API call was made (no additional calls) + expect(apiCallCount).toBe(1) + + // Clean up + resolveFirstCall() + }) + + it('should return early from handleRemove if doingActionRef is true', async () => { + const pluginPayload = createPluginPayload() + let resolveFirstCall: (value?: unknown) => void = () => {} + let deleteCallCount = 0 + + mockDeletePluginOAuthCustomClient.mockImplementation(() => { + deleteCallCount++ + if (deleteCallCount === 1) { + return new Promise((resolve) => { + resolveFirstCall = resolve + }) + } + return Promise.resolve({}) + }) + + const schemasWithOAuthClient: FormSchema[] = [ + { + name: '__oauth_client__', + label: 'OAuth Client', + type: 'radio' as FormSchema['type'], + options: [ + { label: 'Default', value: 'default' }, + { label: 'Custom', value: 'custom' }, + ], + default: 'custom', + required: false, + }, + ...defaultSchemas, + ] + + render( + , + { wrapper: createWrapper() }, + ) + + const removeButton = screen.getByText('common.operation.remove') + + // First click starts the delete request + fireEvent.click(removeButton) + + // Wait for the first delete call to be made + await waitFor(() => { + expect(deleteCallCount).toBe(1) + }) + + // Second click while first request is pending should be ignored + fireEvent.click(removeButton) + + // Verify only one delete call was made (no additional calls) + expect(deleteCallCount).toBe(1) + + // Clean up + resolveFirstCall() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty schemas', () => { + const pluginPayload = createPluginPayload() + + expect(() => { + render( + , + { wrapper: createWrapper() }, + ) + }).not.toThrow() + }) + + it('should handle schemas without default values', () => { + const pluginPayload = createPluginPayload() + const schemasWithoutDefaults: FormSchema[] = [ + createFormSchema({ name: 'field1', label: 'Field 1', default: undefined }), + ] + + expect(() => { + render( + , + { wrapper: createWrapper() }, + ) + }).not.toThrow() + }) + + it('should handle undefined editValues', () => { + const pluginPayload = createPluginPayload() + + expect(() => { + render( + , + { wrapper: createWrapper() }, + ) + }).not.toThrow() + }) + }) + + describe('Branch Coverage - defaultValues computation', () => { + it('should compute defaultValues from schemas with default values', () => { + const pluginPayload = createPluginPayload() + const schemasWithDefaults: FormSchema[] = [ + createFormSchema({ name: 'client_id', label: 'Client ID', default: 'default-id' }), + createFormSchema({ name: 'client_secret', label: 'Client Secret', default: 'default-secret' }), + ] + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() + }) + + it('should skip schemas without default values in defaultValues computation', () => { + const pluginPayload = createPluginPayload() + const mixedSchemas: FormSchema[] = [ + createFormSchema({ name: 'field_with_default', label: 'With Default', default: 'value' }), + createFormSchema({ name: 'field_without_default', label: 'Without Default', default: undefined }), + createFormSchema({ name: 'field_with_empty', label: 'Empty Default', default: '' }), + ] + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() + }) + }) + + describe('Branch Coverage - __oauth_client__ value', () => { + beforeEach(() => { + mockGetFormValues.mockReturnValue({ + isCheckValidated: true, + values: { + __oauth_client__: 'default', + client_id: 'test-id', + }, + }) + }) + + it('should send enable_oauth_custom_client=false when __oauth_client__ is default', async () => { + const pluginPayload = createPluginPayload() + mockSetPluginOAuthCustomClient.mockResolvedValue({}) + + render( + , + { wrapper: createWrapper() }, + ) + + fireEvent.click(screen.getByText('plugin.auth.saveOnly')) + + await waitFor(() => { + expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledWith( + expect.objectContaining({ + enable_oauth_custom_client: false, + }), + ) + }) + }) + + it('should send enable_oauth_custom_client=true when __oauth_client__ is custom', async () => { + const pluginPayload = createPluginPayload() + mockSetPluginOAuthCustomClient.mockResolvedValue({}) + mockGetFormValues.mockReturnValue({ + isCheckValidated: true, + values: { + __oauth_client__: 'custom', + client_id: 'test-id', + }, + }) + + render( + , + { wrapper: createWrapper() }, + ) + + fireEvent.click(screen.getByText('plugin.auth.saveOnly')) + + await waitFor(() => { + expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledWith( + expect.objectContaining({ + enable_oauth_custom_client: true, + }), + ) + }) + }) + }) + + describe('Branch Coverage - onAuth callback', () => { + beforeEach(() => { + mockGetFormValues.mockReturnValue({ + isCheckValidated: true, + values: { __oauth_client__: 'custom' }, + }) + }) + + it('should call onAuth when provided and Save and Auth is clicked', async () => { + const pluginPayload = createPluginPayload() + const onAuth = vi.fn().mockResolvedValue(undefined) + mockSetPluginOAuthCustomClient.mockResolvedValue({}) + + render( + , + { wrapper: createWrapper() }, + ) + + fireEvent.click(screen.getByText('plugin.auth.saveAndAuth')) + + await waitFor(() => { + expect(onAuth).toHaveBeenCalled() + }) + }) + + it('should not call onAuth when not provided', async () => { + const pluginPayload = createPluginPayload() + mockSetPluginOAuthCustomClient.mockResolvedValue({}) + + render( + , + { wrapper: createWrapper() }, + ) + + fireEvent.click(screen.getByText('plugin.auth.saveAndAuth')) + + await waitFor(() => { + expect(mockSetPluginOAuthCustomClient).toHaveBeenCalled() + }) + // No onAuth to call, but should not throw + }) + }) + + describe('Branch Coverage - disabled states', () => { + it('should disable buttons when disabled prop is true', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('plugin.auth.saveAndAuth').closest('button')).toBeDisabled() + expect(screen.getByText('plugin.auth.saveOnly').closest('button')).toBeDisabled() + }) + + it('should disable Remove button when editValues is undefined', () => { + const pluginPayload = createPluginPayload() + const schemasWithOAuthClient: FormSchema[] = [ + { + name: '__oauth_client__', + label: 'OAuth Client', + type: 'radio' as FormSchema['type'], + options: [ + { label: 'Default', value: 'default' }, + { label: 'Custom', value: 'custom' }, + ], + default: 'custom', + required: false, + }, + ...defaultSchemas, + ] + + render( + , + { wrapper: createWrapper() }, + ) + + // Remove button should exist but be disabled + const removeButton = screen.queryByText('common.operation.remove') + if (removeButton) { + expect(removeButton.closest('button')).toBeDisabled() + } + }) + + it('should disable Remove button when disabled prop is true', () => { + const pluginPayload = createPluginPayload() + const schemasWithOAuthClient: FormSchema[] = [ + { + name: '__oauth_client__', + label: 'OAuth Client', + type: 'radio' as FormSchema['type'], + options: [ + { label: 'Default', value: 'default' }, + { label: 'Custom', value: 'custom' }, + ], + default: 'custom', + required: false, + }, + ...defaultSchemas, + ] + + render( + , + { wrapper: createWrapper() }, + ) + + const removeButton = screen.getByText('common.operation.remove') + expect(removeButton.closest('button')).toBeDisabled() + }) + }) + + describe('Branch Coverage - pluginPayload.detail', () => { + it('should render ReadmeEntrance when pluginPayload has detail', () => { + const pluginPayload = createPluginPayload({ + detail: { + name: 'test-plugin', + label: { en_US: 'Test Plugin' }, + } as unknown as PluginPayload['detail'], + }) + + render( + , + { wrapper: createWrapper() }, + ) + + // ReadmeEntrance should be rendered (it's mocked in vitest.setup) + expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() + }) + + it('should not render ReadmeEntrance when pluginPayload has no detail', () => { + const pluginPayload = createPluginPayload({ detail: undefined }) + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() + }) + }) + + describe('Branch Coverage - footerSlot conditions', () => { + it('should show Remove button only when __oauth_client__=custom AND hasOriginalClientParams=true', () => { + const pluginPayload = createPluginPayload() + const schemasWithCustomOAuth: FormSchema[] = [ + { + name: '__oauth_client__', + label: 'OAuth Client', + type: 'radio' as FormSchema['type'], + options: [ + { label: 'Default', value: 'default' }, + { label: 'Custom', value: 'custom' }, + ], + default: 'custom', + required: false, + }, + ...defaultSchemas, + ] + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('common.operation.remove')).toBeInTheDocument() + }) + + it('should not show Remove button when hasOriginalClientParams=false', () => { + const pluginPayload = createPluginPayload() + const schemasWithCustomOAuth: FormSchema[] = [ + { + name: '__oauth_client__', + label: 'OAuth Client', + type: 'radio' as FormSchema['type'], + options: [ + { label: 'Default', value: 'default' }, + { label: 'Custom', value: 'custom' }, + ], + default: 'custom', + required: false, + }, + ...defaultSchemas, + ] + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument() + }) + }) + + describe('Memoization', () => { + it('should be a memoized component', async () => { + const OAuthClientSettingsDefault = (await import('./oauth-client-settings')).default + expect(typeof OAuthClientSettingsDefault).toBe('object') + }) + }) +}) + +// ==================== Integration Tests ==================== +describe('Authorize Components Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetPluginCredentialSchema.mockReturnValue([ + createFormSchema({ name: 'api_key', label: 'API Key' }), + ]) + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + redirect_uri: 'https://example.com/callback', + }) + }) + + describe('AddApiKeyButton -> ApiKeyModal Flow', () => { + it('should open ApiKeyModal when AddApiKeyButton is clicked', async () => { + const AddApiKeyButton = (await import('./add-api-key-button')).default + const pluginPayload = createPluginPayload() + + render(, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(screen.getByText('plugin.auth.useApiAuth')).toBeInTheDocument() + }) + }) + }) + + describe('AddOAuthButton -> OAuthClientSettings Flow', () => { + it('should open OAuthClientSettings when setup button is clicked', async () => { + const AddOAuthButton = (await import('./add-oauth-button')).default + const pluginPayload = createPluginPayload() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [createFormSchema({ name: 'client_id', label: 'Client ID' })], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + redirect_uri: 'https://example.com/callback', + }) + + render(, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByText('plugin.auth.setupOAuth')) + + await waitFor(() => { + expect(screen.getByText('plugin.auth.oauthClientSettings')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-auth/authorize/index.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/index.spec.tsx new file mode 100644 index 0000000000..354ef8eeea --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/index.spec.tsx @@ -0,0 +1,786 @@ +import type { ReactNode } from 'react' +import type { PluginPayload } from '../types' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthCategory } from '../types' +import Authorize from './index' + +// Create a wrapper with QueryClientProvider for real component testing +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }) + +const createWrapper = () => { + const testQueryClient = createTestQueryClient() + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +// Mock API hooks - only mock network-related hooks +const mockGetPluginOAuthClientSchema = vi.fn() + +vi.mock('../hooks/use-credential', () => ({ + useGetPluginOAuthUrlHook: () => ({ + mutateAsync: vi.fn().mockResolvedValue({ authorization_url: '' }), + }), + useGetPluginOAuthClientSchemaHook: () => ({ + data: mockGetPluginOAuthClientSchema(), + isLoading: false, + }), + useSetPluginOAuthCustomClientHook: () => ({ + mutateAsync: vi.fn().mockResolvedValue({}), + }), + useDeletePluginOAuthCustomClientHook: () => ({ + mutateAsync: vi.fn().mockResolvedValue({}), + }), + useInvalidPluginOAuthClientSchemaHook: () => vi.fn(), + useAddPluginCredentialHook: () => ({ + mutateAsync: vi.fn().mockResolvedValue({}), + }), + useUpdatePluginCredentialHook: () => ({ + mutateAsync: vi.fn().mockResolvedValue({}), + }), + useGetPluginCredentialSchemaHook: () => ({ + data: [], + isLoading: false, + }), +})) + +// Mock openOAuthPopup - window operations +vi.mock('@/hooks/use-oauth', () => ({ + openOAuthPopup: vi.fn(), +})) + +// Mock service/use-triggers - API service +vi.mock('@/service/use-triggers', () => ({ + useTriggerPluginDynamicOptions: () => ({ + data: { options: [] }, + isLoading: false, + }), + useTriggerPluginDynamicOptionsInfo: () => ({ + data: null, + isLoading: false, + }), + useInvalidTriggerDynamicOptions: () => vi.fn(), +})) + +// Factory function for creating test PluginPayload +const createPluginPayload = (overrides: Partial = {}): PluginPayload => ({ + category: AuthCategory.tool, + provider: 'test-provider', + ...overrides, +}) + +describe('Authorize', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + }) + }) + + // ==================== Rendering Tests ==================== + describe('Rendering', () => { + it('should render nothing when canOAuth and canApiKey are both false/undefined', () => { + const pluginPayload = createPluginPayload() + + const { container } = render( + , + { wrapper: createWrapper() }, + ) + + // No buttons should be rendered + expect(screen.queryByRole('button')).not.toBeInTheDocument() + // Container should only have wrapper element + expect(container.querySelector('.flex')).toBeInTheDocument() + }) + + it('should render only OAuth button when canOAuth is true and canApiKey is false', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + // OAuth button should exist (either configured or setup button) + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should render only API Key button when canApiKey is true and canOAuth is false', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should render both OAuth and API Key buttons when both are true', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBe(2) + }) + + it('should render divider when showDivider is true and both buttons are shown', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('or')).toBeInTheDocument() + }) + + it('should not render divider when showDivider is false', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.queryByText('or')).not.toBeInTheDocument() + }) + + it('should not render divider when only one button type is shown', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.queryByText('or')).not.toBeInTheDocument() + }) + + it('should render divider by default (showDivider defaults to true)', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('or')).toBeInTheDocument() + }) + }) + + // ==================== Props Testing ==================== + describe('Props Testing', () => { + describe('theme prop', () => { + it('should render buttons with secondary theme variant when theme is secondary', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + const buttons = screen.getAllByRole('button') + buttons.forEach((button) => { + expect(button.className).toContain('btn-secondary') + }) + }) + }) + + describe('disabled prop', () => { + it('should disable OAuth button when disabled is true', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should disable API Key button when disabled is true', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should not disable buttons when disabled is false', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + const buttons = screen.getAllByRole('button') + buttons.forEach((button) => { + expect(button).not.toBeDisabled() + }) + }) + }) + + describe('notAllowCustomCredential prop', () => { + it('should disable OAuth button when notAllowCustomCredential is true', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should disable API Key button when notAllowCustomCredential is true', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should add opacity class when notAllowCustomCredential is true', () => { + const pluginPayload = createPluginPayload() + + const { container } = render( + , + { wrapper: createWrapper() }, + ) + + const wrappers = container.querySelectorAll('.opacity-50') + expect(wrappers.length).toBe(2) // Both OAuth and API Key wrappers + }) + }) + }) + + // ==================== Button Text Variations ==================== + describe('Button Text Variations', () => { + it('should show correct OAuth text based on canApiKey', () => { + const pluginPayload = createPluginPayload() + + // When canApiKey is false, should show "useOAuthAuth" + const { rerender } = render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button')).toHaveTextContent('plugin.auth') + + // When canApiKey is true, button text changes + rerender( + , + ) + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBe(2) + }) + }) + + // ==================== Memoization Dependencies ==================== + describe('Memoization and Re-rendering', () => { + it('should maintain stable props across re-renders with same dependencies', () => { + const pluginPayload = createPluginPayload() + const onUpdate = vi.fn() + + const { rerender } = render( + , + { wrapper: createWrapper() }, + ) + + const initialButtonCount = screen.getAllByRole('button').length + + rerender( + , + ) + + expect(screen.getAllByRole('button').length).toBe(initialButtonCount) + }) + + it('should update when canApiKey changes', () => { + const pluginPayload = createPluginPayload() + + const { rerender } = render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getAllByRole('button').length).toBe(1) + + rerender( + , + ) + + expect(screen.getAllByRole('button').length).toBe(2) + }) + + it('should update when canOAuth changes', () => { + const pluginPayload = createPluginPayload() + + const { rerender } = render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getAllByRole('button').length).toBe(1) + + rerender( + , + ) + + expect(screen.getAllByRole('button').length).toBe(2) + }) + + it('should update button variant when theme changes', () => { + const pluginPayload = createPluginPayload() + + const { rerender } = render( + , + { wrapper: createWrapper() }, + ) + + const buttonPrimary = screen.getByRole('button') + // Primary theme with canOAuth=false should have primary variant + expect(buttonPrimary.className).toContain('btn-primary') + + rerender( + , + ) + + expect(screen.getByRole('button').className).toContain('btn-secondary') + }) + }) + + // ==================== Edge Cases ==================== + describe('Edge Cases', () => { + it('should handle undefined pluginPayload properties gracefully', () => { + const pluginPayload: PluginPayload = { + category: AuthCategory.tool, + provider: 'test-provider', + providerType: undefined, + detail: undefined, + } + + expect(() => { + render( + , + { wrapper: createWrapper() }, + ) + }).not.toThrow() + }) + + it('should handle all auth categories', () => { + const categories = [AuthCategory.tool, AuthCategory.datasource, AuthCategory.model, AuthCategory.trigger] + + categories.forEach((category) => { + const pluginPayload = createPluginPayload({ category }) + + const { unmount } = render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getAllByRole('button').length).toBe(2) + + unmount() + }) + }) + + it('should handle empty string provider', () => { + const pluginPayload = createPluginPayload({ provider: '' }) + + expect(() => { + render( + , + { wrapper: createWrapper() }, + ) + }).not.toThrow() + }) + + it('should handle both disabled and notAllowCustomCredential together', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + const buttons = screen.getAllByRole('button') + buttons.forEach((button) => { + expect(button).toBeDisabled() + }) + }) + }) + + // ==================== Component Memoization ==================== + describe('Component Memoization', () => { + it('should be a memoized component (exported with memo)', async () => { + const AuthorizeDefault = (await import('./index')).default + expect(AuthorizeDefault).toBeDefined() + // memo wrapped components are React elements with $$typeof + expect(typeof AuthorizeDefault).toBe('object') + }) + + it('should not re-render wrapper when notAllowCustomCredential stays the same', () => { + const pluginPayload = createPluginPayload() + const onUpdate = vi.fn() + + const { rerender, container } = render( + , + { wrapper: createWrapper() }, + ) + + const initialOpacityElements = container.querySelectorAll('.opacity-50').length + + rerender( + , + ) + + expect(container.querySelectorAll('.opacity-50').length).toBe(initialOpacityElements) + }) + + it('should update wrapper when notAllowCustomCredential changes', () => { + const pluginPayload = createPluginPayload() + + const { rerender, container } = render( + , + { wrapper: createWrapper() }, + ) + + expect(container.querySelectorAll('.opacity-50').length).toBe(0) + + rerender( + , + ) + + expect(container.querySelectorAll('.opacity-50').length).toBe(1) + }) + }) + + // ==================== Integration with pluginPayload ==================== + describe('pluginPayload Integration', () => { + it('should pass pluginPayload to OAuth button', () => { + const pluginPayload = createPluginPayload({ + provider: 'special-provider', + category: AuthCategory.model, + }) + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should pass pluginPayload to API Key button', () => { + const pluginPayload = createPluginPayload({ + provider: 'another-provider', + category: AuthCategory.datasource, + }) + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should handle pluginPayload with detail property', () => { + const pluginPayload = createPluginPayload({ + detail: { + plugin_id: 'test-plugin', + name: 'Test Plugin', + } as PluginPayload['detail'], + }) + + expect(() => { + render( + , + { wrapper: createWrapper() }, + ) + }).not.toThrow() + }) + }) + + // ==================== Conditional Rendering Scenarios ==================== + describe('Conditional Rendering Scenarios', () => { + it('should handle rapid prop changes', () => { + const pluginPayload = createPluginPayload() + + const { rerender } = render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getAllByRole('button').length).toBe(2) + + rerender() + expect(screen.getAllByRole('button').length).toBe(1) + + rerender() + expect(screen.getAllByRole('button').length).toBe(1) + + rerender() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('should correctly toggle divider visibility based on button combinations', () => { + const pluginPayload = createPluginPayload() + + const { rerender } = render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('or')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.queryByText('or')).not.toBeInTheDocument() + + rerender( + , + ) + + expect(screen.queryByText('or')).not.toBeInTheDocument() + }) + }) + + // ==================== Accessibility ==================== + describe('Accessibility', () => { + it('should have accessible button elements', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBe(2) + }) + + it('should indicate disabled state for accessibility', () => { + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + const buttons = screen.getAllByRole('button') + buttons.forEach((button) => { + expect(button).toBeDisabled() + }) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-auth/index.spec.tsx b/web/app/components/plugins/plugin-auth/index.spec.tsx new file mode 100644 index 0000000000..328de71e8d --- /dev/null +++ b/web/app/components/plugins/plugin-auth/index.spec.tsx @@ -0,0 +1,2035 @@ +import type { ReactNode } from 'react' +import type { Credential, PluginPayload } from './types' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthCategory, CredentialTypeEnum } from './types' + +// ==================== Mock Setup ==================== + +// Mock API hooks for credential operations +const mockGetPluginCredentialInfo = vi.fn() +const mockDeletePluginCredential = vi.fn() +const mockSetPluginDefaultCredential = vi.fn() +const mockUpdatePluginCredential = vi.fn() +const mockInvalidPluginCredentialInfo = vi.fn() +const mockGetPluginOAuthUrl = vi.fn() +const mockGetPluginOAuthClientSchema = vi.fn() +const mockSetPluginOAuthCustomClient = vi.fn() +const mockDeletePluginOAuthCustomClient = vi.fn() +const mockInvalidPluginOAuthClientSchema = vi.fn() +const mockAddPluginCredential = vi.fn() +const mockGetPluginCredentialSchema = vi.fn() +const mockInvalidToolsByType = vi.fn() + +vi.mock('@/service/use-plugins-auth', () => ({ + useGetPluginCredentialInfo: (url: string) => ({ + data: url ? mockGetPluginCredentialInfo() : undefined, + isLoading: false, + }), + useDeletePluginCredential: () => ({ + mutateAsync: mockDeletePluginCredential, + }), + useSetPluginDefaultCredential: () => ({ + mutateAsync: mockSetPluginDefaultCredential, + }), + useUpdatePluginCredential: () => ({ + mutateAsync: mockUpdatePluginCredential, + }), + useInvalidPluginCredentialInfo: () => mockInvalidPluginCredentialInfo, + useGetPluginOAuthUrl: () => ({ + mutateAsync: mockGetPluginOAuthUrl, + }), + useGetPluginOAuthClientSchema: () => ({ + data: mockGetPluginOAuthClientSchema(), + isLoading: false, + }), + useSetPluginOAuthCustomClient: () => ({ + mutateAsync: mockSetPluginOAuthCustomClient, + }), + useDeletePluginOAuthCustomClient: () => ({ + mutateAsync: mockDeletePluginOAuthCustomClient, + }), + useInvalidPluginOAuthClientSchema: () => mockInvalidPluginOAuthClientSchema, + useAddPluginCredential: () => ({ + mutateAsync: mockAddPluginCredential, + }), + useGetPluginCredentialSchema: () => ({ + data: mockGetPluginCredentialSchema(), + isLoading: false, + }), +})) + +vi.mock('@/service/use-tools', () => ({ + useInvalidToolsByType: () => mockInvalidToolsByType, +})) + +// Mock AppContext +const mockIsCurrentWorkspaceManager = vi.fn() +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(), + }), +})) + +// Mock toast context +const mockNotify = vi.fn() +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ + notify: mockNotify, + }), +})) + +// Mock openOAuthPopup +vi.mock('@/hooks/use-oauth', () => ({ + openOAuthPopup: vi.fn(), +})) + +// Mock service/use-triggers +vi.mock('@/service/use-triggers', () => ({ + useTriggerPluginDynamicOptions: () => ({ + data: { options: [] }, + isLoading: false, + }), + useTriggerPluginDynamicOptionsInfo: () => ({ + data: null, + isLoading: false, + }), + useInvalidTriggerDynamicOptions: () => vi.fn(), +})) + +// ==================== Test Utilities ==================== + +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }) + +const createWrapper = () => { + const testQueryClient = createTestQueryClient() + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +// Factory functions for test data +const createPluginPayload = (overrides: Partial = {}): PluginPayload => ({ + category: AuthCategory.tool, + provider: 'test-provider', + ...overrides, +}) + +const createCredential = (overrides: Partial = {}): Credential => ({ + id: 'test-credential-id', + name: 'Test Credential', + provider: 'test-provider', + credential_type: CredentialTypeEnum.API_KEY, + is_default: false, + credentials: { api_key: 'test-key' }, + ...overrides, +}) + +const createCredentialList = (count: number, overrides: Partial[] = []): Credential[] => { + return Array.from({ length: count }, (_, i) => createCredential({ + id: `credential-${i}`, + name: `Credential ${i}`, + is_default: i === 0, + ...overrides[i], + })) +} + +// ==================== Index Exports Tests ==================== +describe('Index Exports', () => { + it('should export all required components and hooks', async () => { + const exports = await import('./index') + + expect(exports.AddApiKeyButton).toBeDefined() + expect(exports.AddOAuthButton).toBeDefined() + expect(exports.ApiKeyModal).toBeDefined() + expect(exports.Authorized).toBeDefined() + expect(exports.AuthorizedInDataSourceNode).toBeDefined() + expect(exports.AuthorizedInNode).toBeDefined() + expect(exports.usePluginAuth).toBeDefined() + expect(exports.PluginAuth).toBeDefined() + expect(exports.PluginAuthInAgent).toBeDefined() + expect(exports.PluginAuthInDataSourceNode).toBeDefined() + }) + + it('should export AuthCategory enum', async () => { + const exports = await import('./index') + + expect(exports.AuthCategory).toBeDefined() + expect(exports.AuthCategory.tool).toBe('tool') + expect(exports.AuthCategory.datasource).toBe('datasource') + expect(exports.AuthCategory.model).toBe('model') + expect(exports.AuthCategory.trigger).toBe('trigger') + }) + + it('should export CredentialTypeEnum', async () => { + const exports = await import('./index') + + expect(exports.CredentialTypeEnum).toBeDefined() + expect(exports.CredentialTypeEnum.OAUTH2).toBe('oauth2') + expect(exports.CredentialTypeEnum.API_KEY).toBe('api-key') + }) +}) + +// ==================== Types Tests ==================== +describe('Types', () => { + describe('AuthCategory enum', () => { + it('should have correct values', () => { + expect(AuthCategory.tool).toBe('tool') + expect(AuthCategory.datasource).toBe('datasource') + expect(AuthCategory.model).toBe('model') + expect(AuthCategory.trigger).toBe('trigger') + }) + + it('should have exactly 4 categories', () => { + const values = Object.values(AuthCategory) + expect(values).toHaveLength(4) + }) + }) + + describe('CredentialTypeEnum', () => { + it('should have correct values', () => { + expect(CredentialTypeEnum.OAUTH2).toBe('oauth2') + expect(CredentialTypeEnum.API_KEY).toBe('api-key') + }) + + it('should have exactly 2 types', () => { + const values = Object.values(CredentialTypeEnum) + expect(values).toHaveLength(2) + }) + }) + + describe('Credential type', () => { + it('should allow creating valid credentials', () => { + const credential: Credential = { + id: 'test-id', + name: 'Test', + provider: 'test-provider', + is_default: true, + } + expect(credential.id).toBe('test-id') + expect(credential.is_default).toBe(true) + }) + + it('should allow optional fields', () => { + const credential: Credential = { + id: 'test-id', + name: 'Test', + provider: 'test-provider', + is_default: false, + credential_type: CredentialTypeEnum.API_KEY, + credentials: { key: 'value' }, + isWorkspaceDefault: true, + from_enterprise: false, + not_allowed_to_use: false, + } + expect(credential.credential_type).toBe(CredentialTypeEnum.API_KEY) + expect(credential.isWorkspaceDefault).toBe(true) + }) + }) + + describe('PluginPayload type', () => { + it('should allow creating valid plugin payload', () => { + const payload: PluginPayload = { + category: AuthCategory.tool, + provider: 'test-provider', + } + expect(payload.category).toBe(AuthCategory.tool) + }) + + it('should allow optional fields', () => { + const payload: PluginPayload = { + category: AuthCategory.datasource, + provider: 'test-provider', + providerType: 'builtin', + detail: undefined, + } + expect(payload.providerType).toBe('builtin') + }) + }) +}) + +// ==================== Utils Tests ==================== +describe('Utils', () => { + describe('transformFormSchemasSecretInput', () => { + it('should transform secret input values to hidden format', async () => { + const { transformFormSchemasSecretInput } = await import('./utils') + + const secretNames = ['api_key', 'secret_token'] + const values = { + api_key: 'actual-key', + secret_token: 'actual-token', + public_key: 'public-value', + } + + const result = transformFormSchemasSecretInput(secretNames, values) + + expect(result.api_key).toBe('[__HIDDEN__]') + expect(result.secret_token).toBe('[__HIDDEN__]') + expect(result.public_key).toBe('public-value') + }) + + it('should not transform empty secret values', async () => { + const { transformFormSchemasSecretInput } = await import('./utils') + + const secretNames = ['api_key'] + const values = { + api_key: '', + public_key: 'public-value', + } + + const result = transformFormSchemasSecretInput(secretNames, values) + + expect(result.api_key).toBe('') + expect(result.public_key).toBe('public-value') + }) + + it('should not transform undefined secret values', async () => { + const { transformFormSchemasSecretInput } = await import('./utils') + + const secretNames = ['api_key'] + const values = { + public_key: 'public-value', + } + + const result = transformFormSchemasSecretInput(secretNames, values) + + expect(result.api_key).toBeUndefined() + expect(result.public_key).toBe('public-value') + }) + + it('should handle empty secret names array', async () => { + const { transformFormSchemasSecretInput } = await import('./utils') + + const secretNames: string[] = [] + const values = { + api_key: 'actual-key', + public_key: 'public-value', + } + + const result = transformFormSchemasSecretInput(secretNames, values) + + expect(result.api_key).toBe('actual-key') + expect(result.public_key).toBe('public-value') + }) + + it('should handle empty values object', async () => { + const { transformFormSchemasSecretInput } = await import('./utils') + + const secretNames = ['api_key'] + const values = {} + + const result = transformFormSchemasSecretInput(secretNames, values) + + expect(Object.keys(result)).toHaveLength(0) + }) + + it('should preserve original values object immutably', async () => { + const { transformFormSchemasSecretInput } = await import('./utils') + + const secretNames = ['api_key'] + const values = { + api_key: 'actual-key', + public_key: 'public-value', + } + + transformFormSchemasSecretInput(secretNames, values) + + expect(values.api_key).toBe('actual-key') + }) + + it('should handle null-ish values correctly', async () => { + const { transformFormSchemasSecretInput } = await import('./utils') + + const secretNames = ['api_key', 'null_key'] + const values = { + api_key: null, + null_key: 0, + } + + const result = transformFormSchemasSecretInput(secretNames, values as Record) + + // null is preserved as-is to represent an explicitly unset secret, not masked as [__HIDDEN__] + expect(result.api_key).toBe(null) + // numeric values like 0 are also preserved; only non-empty string secrets are transformed + expect(result.null_key).toBe(0) + }) + }) +}) + +// ==================== useGetApi Hook Tests ==================== +describe('useGetApi Hook', () => { + describe('tool category', () => { + it('should return correct API endpoints for tool category', async () => { + const { useGetApi } = await import('./hooks/use-get-api') + + const pluginPayload = createPluginPayload({ + category: AuthCategory.tool, + provider: 'test-tool', + }) + + const apiMap = useGetApi(pluginPayload) + + expect(apiMap.getCredentialInfo).toBe('/workspaces/current/tool-provider/builtin/test-tool/credential/info') + expect(apiMap.setDefaultCredential).toBe('/workspaces/current/tool-provider/builtin/test-tool/default-credential') + expect(apiMap.getCredentials).toBe('/workspaces/current/tool-provider/builtin/test-tool/credentials') + expect(apiMap.addCredential).toBe('/workspaces/current/tool-provider/builtin/test-tool/add') + expect(apiMap.updateCredential).toBe('/workspaces/current/tool-provider/builtin/test-tool/update') + expect(apiMap.deleteCredential).toBe('/workspaces/current/tool-provider/builtin/test-tool/delete') + expect(apiMap.getOauthUrl).toBe('/oauth/plugin/test-tool/tool/authorization-url') + expect(apiMap.getOauthClientSchema).toBe('/workspaces/current/tool-provider/builtin/test-tool/oauth/client-schema') + expect(apiMap.setCustomOauthClient).toBe('/workspaces/current/tool-provider/builtin/test-tool/oauth/custom-client') + expect(apiMap.deleteCustomOAuthClient).toBe('/workspaces/current/tool-provider/builtin/test-tool/oauth/custom-client') + }) + + it('should return getCredentialSchema function for tool category', async () => { + const { useGetApi } = await import('./hooks/use-get-api') + + const pluginPayload = createPluginPayload({ + category: AuthCategory.tool, + provider: 'test-tool', + }) + + const apiMap = useGetApi(pluginPayload) + + expect(apiMap.getCredentialSchema(CredentialTypeEnum.API_KEY)).toBe( + '/workspaces/current/tool-provider/builtin/test-tool/credential/schema/api-key', + ) + expect(apiMap.getCredentialSchema(CredentialTypeEnum.OAUTH2)).toBe( + '/workspaces/current/tool-provider/builtin/test-tool/credential/schema/oauth2', + ) + }) + }) + + describe('datasource category', () => { + it('should return correct API endpoints for datasource category', async () => { + const { useGetApi } = await import('./hooks/use-get-api') + + const pluginPayload = createPluginPayload({ + category: AuthCategory.datasource, + provider: 'test-datasource', + }) + + const apiMap = useGetApi(pluginPayload) + + expect(apiMap.getCredentialInfo).toBe('') + expect(apiMap.setDefaultCredential).toBe('/auth/plugin/datasource/test-datasource/default') + expect(apiMap.getCredentials).toBe('/auth/plugin/datasource/test-datasource') + expect(apiMap.addCredential).toBe('/auth/plugin/datasource/test-datasource') + expect(apiMap.updateCredential).toBe('/auth/plugin/datasource/test-datasource/update') + expect(apiMap.deleteCredential).toBe('/auth/plugin/datasource/test-datasource/delete') + expect(apiMap.getOauthUrl).toBe('/oauth/plugin/test-datasource/datasource/get-authorization-url') + expect(apiMap.getOauthClientSchema).toBe('') + expect(apiMap.setCustomOauthClient).toBe('/auth/plugin/datasource/test-datasource/custom-client') + expect(apiMap.deleteCustomOAuthClient).toBe('/auth/plugin/datasource/test-datasource/custom-client') + }) + + it('should return empty string for getCredentialSchema in datasource', async () => { + const { useGetApi } = await import('./hooks/use-get-api') + + const pluginPayload = createPluginPayload({ + category: AuthCategory.datasource, + provider: 'test-datasource', + }) + + const apiMap = useGetApi(pluginPayload) + + expect(apiMap.getCredentialSchema(CredentialTypeEnum.API_KEY)).toBe('') + }) + }) + + describe('other categories', () => { + it('should return empty strings for model category', async () => { + const { useGetApi } = await import('./hooks/use-get-api') + + const pluginPayload = createPluginPayload({ + category: AuthCategory.model, + provider: 'test-model', + }) + + const apiMap = useGetApi(pluginPayload) + + expect(apiMap.getCredentialInfo).toBe('') + expect(apiMap.setDefaultCredential).toBe('') + expect(apiMap.getCredentials).toBe('') + expect(apiMap.addCredential).toBe('') + expect(apiMap.updateCredential).toBe('') + expect(apiMap.deleteCredential).toBe('') + expect(apiMap.getCredentialSchema(CredentialTypeEnum.API_KEY)).toBe('') + }) + + it('should return empty strings for trigger category', async () => { + const { useGetApi } = await import('./hooks/use-get-api') + + const pluginPayload = createPluginPayload({ + category: AuthCategory.trigger, + provider: 'test-trigger', + }) + + const apiMap = useGetApi(pluginPayload) + + expect(apiMap.getCredentialInfo).toBe('') + expect(apiMap.setDefaultCredential).toBe('') + }) + }) + + describe('edge cases', () => { + it('should handle empty provider', async () => { + const { useGetApi } = await import('./hooks/use-get-api') + + const pluginPayload = createPluginPayload({ + category: AuthCategory.tool, + provider: '', + }) + + const apiMap = useGetApi(pluginPayload) + + expect(apiMap.getCredentialInfo).toBe('/workspaces/current/tool-provider/builtin//credential/info') + }) + + it('should handle special characters in provider name', async () => { + const { useGetApi } = await import('./hooks/use-get-api') + + const pluginPayload = createPluginPayload({ + category: AuthCategory.tool, + provider: 'test-provider_v2', + }) + + const apiMap = useGetApi(pluginPayload) + + expect(apiMap.getCredentialInfo).toContain('test-provider_v2') + }) + }) +}) + +// ==================== usePluginAuth Hook Tests ==================== +describe('usePluginAuth Hook', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsCurrentWorkspaceManager.mockReturnValue(true) + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [], + supported_credential_types: [], + allow_custom_token: true, + }) + }) + + it('should return isAuthorized false when no credentials', async () => { + const { usePluginAuth } = await import('./hooks/use-plugin-auth') + + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { + wrapper: createWrapper(), + }) + + expect(result.current.isAuthorized).toBe(false) + expect(result.current.credentials).toHaveLength(0) + }) + + it('should return isAuthorized true when credentials exist', async () => { + const { usePluginAuth } = await import('./hooks/use-plugin-auth') + + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [createCredential()], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { + wrapper: createWrapper(), + }) + + expect(result.current.isAuthorized).toBe(true) + expect(result.current.credentials).toHaveLength(1) + }) + + it('should return canOAuth true when oauth2 is supported', async () => { + const { usePluginAuth } = await import('./hooks/use-plugin-auth') + + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [], + supported_credential_types: [CredentialTypeEnum.OAUTH2], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { + wrapper: createWrapper(), + }) + + expect(result.current.canOAuth).toBe(true) + expect(result.current.canApiKey).toBe(false) + }) + + it('should return canApiKey true when api-key is supported', async () => { + const { usePluginAuth } = await import('./hooks/use-plugin-auth') + + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { + wrapper: createWrapper(), + }) + + expect(result.current.canOAuth).toBe(false) + expect(result.current.canApiKey).toBe(true) + }) + + it('should return both canOAuth and canApiKey when both supported', async () => { + const { usePluginAuth } = await import('./hooks/use-plugin-auth') + + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [], + supported_credential_types: [CredentialTypeEnum.OAUTH2, CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { + wrapper: createWrapper(), + }) + + expect(result.current.canOAuth).toBe(true) + expect(result.current.canApiKey).toBe(true) + }) + + it('should return disabled true when user is not workspace manager', async () => { + const { usePluginAuth } = await import('./hooks/use-plugin-auth') + + mockIsCurrentWorkspaceManager.mockReturnValue(false) + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { + wrapper: createWrapper(), + }) + + expect(result.current.disabled).toBe(true) + }) + + it('should return disabled false when user is workspace manager', async () => { + const { usePluginAuth } = await import('./hooks/use-plugin-auth') + + mockIsCurrentWorkspaceManager.mockReturnValue(true) + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { + wrapper: createWrapper(), + }) + + expect(result.current.disabled).toBe(false) + }) + + it('should return notAllowCustomCredential based on allow_custom_token', async () => { + const { usePluginAuth } = await import('./hooks/use-plugin-auth') + + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [], + supported_credential_types: [], + allow_custom_token: false, + }) + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { + wrapper: createWrapper(), + }) + + expect(result.current.notAllowCustomCredential).toBe(true) + }) + + it('should return invalidPluginCredentialInfo function', async () => { + const { usePluginAuth } = await import('./hooks/use-plugin-auth') + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { + wrapper: createWrapper(), + }) + + expect(typeof result.current.invalidPluginCredentialInfo).toBe('function') + }) + + it('should not fetch when enable is false', async () => { + const { usePluginAuth } = await import('./hooks/use-plugin-auth') + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuth(pluginPayload, false), { + wrapper: createWrapper(), + }) + + expect(result.current.isAuthorized).toBe(false) + expect(result.current.credentials).toHaveLength(0) + }) +}) + +// ==================== usePluginAuthAction Hook Tests ==================== +describe('usePluginAuthAction Hook', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDeletePluginCredential.mockResolvedValue({}) + mockSetPluginDefaultCredential.mockResolvedValue({}) + mockUpdatePluginCredential.mockResolvedValue({}) + }) + + it('should return all action handlers', async () => { + const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { + wrapper: createWrapper(), + }) + + expect(result.current.doingAction).toBe(false) + expect(typeof result.current.handleSetDoingAction).toBe('function') + expect(typeof result.current.openConfirm).toBe('function') + expect(typeof result.current.closeConfirm).toBe('function') + expect(result.current.deleteCredentialId).toBe(null) + expect(typeof result.current.setDeleteCredentialId).toBe('function') + expect(typeof result.current.handleConfirm).toBe('function') + expect(result.current.editValues).toBe(null) + expect(typeof result.current.setEditValues).toBe('function') + expect(typeof result.current.handleEdit).toBe('function') + expect(typeof result.current.handleRemove).toBe('function') + expect(typeof result.current.handleSetDefault).toBe('function') + expect(typeof result.current.handleRename).toBe('function') + }) + + it('should open and close confirm dialog', async () => { + const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.openConfirm('test-credential-id') + }) + + expect(result.current.deleteCredentialId).toBe('test-credential-id') + + act(() => { + result.current.closeConfirm() + }) + + expect(result.current.deleteCredentialId).toBe(null) + }) + + it('should handle edit with values', async () => { + const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { + wrapper: createWrapper(), + }) + + const editValues = { key: 'value' } + + act(() => { + result.current.handleEdit('test-id', editValues) + }) + + expect(result.current.editValues).toEqual(editValues) + }) + + it('should handle confirm delete', async () => { + const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') + + const onUpdate = vi.fn() + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuthAction(pluginPayload, onUpdate), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.openConfirm('test-credential-id') + }) + + await act(async () => { + await result.current.handleConfirm() + }) + + expect(mockDeletePluginCredential).toHaveBeenCalledWith({ credential_id: 'test-credential-id' }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'common.api.actionSuccess', + }) + expect(onUpdate).toHaveBeenCalled() + expect(result.current.deleteCredentialId).toBe(null) + }) + + it('should not confirm delete when no credential id', async () => { + const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { + wrapper: createWrapper(), + }) + + await act(async () => { + await result.current.handleConfirm() + }) + + expect(mockDeletePluginCredential).not.toHaveBeenCalled() + }) + + it('should handle set default', async () => { + const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') + + const onUpdate = vi.fn() + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuthAction(pluginPayload, onUpdate), { + wrapper: createWrapper(), + }) + + await act(async () => { + await result.current.handleSetDefault('test-credential-id') + }) + + expect(mockSetPluginDefaultCredential).toHaveBeenCalledWith('test-credential-id') + expect(mockNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'common.api.actionSuccess', + }) + expect(onUpdate).toHaveBeenCalled() + }) + + it('should handle rename', async () => { + const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') + + const onUpdate = vi.fn() + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuthAction(pluginPayload, onUpdate), { + wrapper: createWrapper(), + }) + + await act(async () => { + await result.current.handleRename({ + credential_id: 'test-credential-id', + name: 'New Name', + }) + }) + + expect(mockUpdatePluginCredential).toHaveBeenCalledWith({ + credential_id: 'test-credential-id', + name: 'New Name', + }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'common.api.actionSuccess', + }) + expect(onUpdate).toHaveBeenCalled() + }) + + it('should prevent concurrent actions', async () => { + const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.handleSetDoingAction(true) + }) + + act(() => { + result.current.openConfirm('test-credential-id') + }) + + await act(async () => { + await result.current.handleConfirm() + }) + + // Should not call delete when already doing action + expect(mockDeletePluginCredential).not.toHaveBeenCalled() + }) + + it('should handle remove after edit', async () => { + const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.handleEdit('test-credential-id', { key: 'value' }) + }) + + act(() => { + result.current.handleRemove() + }) + + expect(result.current.deleteCredentialId).toBe('test-credential-id') + }) +}) + +// ==================== PluginAuth Component Tests ==================== +describe('PluginAuth Component', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsCurrentWorkspaceManager.mockReturnValue(true) + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + }) + }) + + it('should render Authorize when not authorized', async () => { + const PluginAuth = (await import('./plugin-auth')).default + + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + // Should render authorize button + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should render Authorized when authorized and no children', async () => { + const PluginAuth = (await import('./plugin-auth')).default + + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [createCredential()], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + // Should render authorized content + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should render children when authorized and children provided', async () => { + const PluginAuth = (await import('./plugin-auth')).default + + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [createCredential()], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + render( + +
Custom Content
+
, + { wrapper: createWrapper() }, + ) + + expect(screen.getByTestId('custom-children')).toBeInTheDocument() + expect(screen.getByText('Custom Content')).toBeInTheDocument() + }) + + it('should apply className when not authorized', async () => { + const PluginAuth = (await import('./plugin-auth')).default + + const pluginPayload = createPluginPayload() + + const { container } = render( + , + { wrapper: createWrapper() }, + ) + + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('should not apply className when authorized', async () => { + const PluginAuth = (await import('./plugin-auth')).default + + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [createCredential()], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + const { container } = render( + , + { wrapper: createWrapper() }, + ) + + expect(container.firstChild).not.toHaveClass('custom-class') + }) + + it('should be memoized', async () => { + const PluginAuthModule = await import('./plugin-auth') + expect(typeof PluginAuthModule.default).toBe('object') + }) +}) + +// ==================== PluginAuthInAgent Component Tests ==================== +describe('PluginAuthInAgent Component', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsCurrentWorkspaceManager.mockReturnValue(true) + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [createCredential()], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + }) + }) + + it('should render Authorize when not authorized', async () => { + const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default + + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should render Authorized with workspace default when authorized', async () => { + const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default + + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button')).toBeInTheDocument() + expect(screen.getByText('plugin.auth.workspaceDefault')).toBeInTheDocument() + }) + + it('should show credential name when credentialId is provided', async () => { + const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default + + const credential = createCredential({ id: 'selected-id', name: 'Selected Credential' }) + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [credential], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('Selected Credential')).toBeInTheDocument() + }) + + it('should show auth removed when credential not found', async () => { + const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default + + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [createCredential()], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('plugin.auth.authRemoved')).toBeInTheDocument() + }) + + it('should show unavailable when credential is not allowed to use', async () => { + const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default + + const credential = createCredential({ + id: 'unavailable-id', + name: 'Unavailable Credential', + not_allowed_to_use: true, + from_enterprise: false, + }) + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [credential], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + // Check that button text contains unavailable + const button = screen.getByRole('button') + expect(button.textContent).toContain('plugin.auth.unavailable') + }) + + it('should call onAuthorizationItemClick when item is clicked', async () => { + const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default + + const onAuthorizationItemClick = vi.fn() + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + // Click to open popup + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[0]) + + // Verify popup is opened (there will be multiple buttons after opening) + expect(screen.getAllByRole('button').length).toBeGreaterThan(0) + }) + + it('should trigger handleAuthorizationItemClick and close popup when authorization item is clicked', async () => { + const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default + + const onAuthorizationItemClick = vi.fn() + const credential = createCredential({ id: 'test-cred-id', name: 'Test Credential' }) + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [credential], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + // Click trigger button to open popup + const triggerButton = screen.getByRole('button') + fireEvent.click(triggerButton) + + // Find and click the workspace default item in the dropdown + // There will be multiple elements with this text, we need the one in the popup (not the trigger) + const workspaceDefaultItems = screen.getAllByText('plugin.auth.workspaceDefault') + // The second one is in the popup list (first one is the trigger button) + const popupItem = workspaceDefaultItems.length > 1 ? workspaceDefaultItems[1] : workspaceDefaultItems[0] + fireEvent.click(popupItem) + + // Verify onAuthorizationItemClick was called with empty string for workspace default + expect(onAuthorizationItemClick).toHaveBeenCalledWith('') + }) + + it('should call onAuthorizationItemClick with credential id when specific credential is clicked', async () => { + const PluginAuthInAgent = (await import('./plugin-auth-in-agent')).default + + const onAuthorizationItemClick = vi.fn() + const credential = createCredential({ + id: 'specific-cred-id', + name: 'Specific Credential', + credential_type: CredentialTypeEnum.API_KEY, + }) + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [credential], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + // Click trigger button to open popup + const triggerButton = screen.getByRole('button') + fireEvent.click(triggerButton) + + // Find and click the specific credential item - there might be multiple "Specific Credential" texts + const credentialItems = screen.getAllByText('Specific Credential') + // Click the one in the popup (usually the last one if trigger shows different text) + const popupItem = credentialItems[credentialItems.length - 1] + fireEvent.click(popupItem) + + // Verify onAuthorizationItemClick was called with the credential id + expect(onAuthorizationItemClick).toHaveBeenCalledWith('specific-cred-id') + }) + + it('should be memoized', async () => { + const PluginAuthInAgentModule = await import('./plugin-auth-in-agent') + expect(typeof PluginAuthInAgentModule.default).toBe('object') + }) +}) + +// ==================== PluginAuthInDataSourceNode Component Tests ==================== +describe('PluginAuthInDataSourceNode Component', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render connect button when not authorized', async () => { + const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default + + const onJumpToDataSourcePage = vi.fn() + + render( + , + ) + + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + expect(screen.getByText('common.integrations.connect')).toBeInTheDocument() + }) + + it('should call onJumpToDataSourcePage when connect button is clicked', async () => { + const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default + + const onJumpToDataSourcePage = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button')) + expect(onJumpToDataSourcePage).toHaveBeenCalledTimes(1) + }) + + it('should render children when authorized', async () => { + const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default + + const onJumpToDataSourcePage = vi.fn() + + render( + +
Authorized Content
+
, + ) + + expect(screen.getByTestId('children-content')).toBeInTheDocument() + expect(screen.getByText('Authorized Content')).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('should not render connect button when authorized', async () => { + const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default + + const onJumpToDataSourcePage = vi.fn() + + render( + , + ) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('should not render children when not authorized', async () => { + const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default + + const onJumpToDataSourcePage = vi.fn() + + render( + +
Authorized Content
+
, + ) + + expect(screen.queryByTestId('children-content')).not.toBeInTheDocument() + }) + + it('should handle undefined isAuthorized (falsy)', async () => { + const PluginAuthInDataSourceNode = (await import('./plugin-auth-in-datasource-node')).default + + const onJumpToDataSourcePage = vi.fn() + + render( + +
Content
+
, + ) + + // isAuthorized is undefined, which is falsy, so connect button should be shown + expect(screen.getByRole('button')).toBeInTheDocument() + expect(screen.queryByTestId('children-content')).not.toBeInTheDocument() + }) + + it('should be memoized', async () => { + const PluginAuthInDataSourceNodeModule = await import('./plugin-auth-in-datasource-node') + expect(typeof PluginAuthInDataSourceNodeModule.default).toBe('object') + }) +}) + +// ==================== AuthorizedInDataSourceNode Component Tests ==================== +describe('AuthorizedInDataSourceNode Component', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render with singular authorization text when authorizationsNum is 1', async () => { + const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default + + const onJumpToDataSourcePage = vi.fn() + + render( + , + ) + + expect(screen.getByRole('button')).toBeInTheDocument() + expect(screen.getByText('plugin.auth.authorization')).toBeInTheDocument() + }) + + it('should render with plural authorizations text when authorizationsNum > 1', async () => { + const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default + + const onJumpToDataSourcePage = vi.fn() + + render( + , + ) + + expect(screen.getByText('plugin.auth.authorizations')).toBeInTheDocument() + }) + + it('should call onJumpToDataSourcePage when button is clicked', async () => { + const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default + + const onJumpToDataSourcePage = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button')) + expect(onJumpToDataSourcePage).toHaveBeenCalledTimes(1) + }) + + it('should render with green indicator', async () => { + const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default + + const { container } = render( + , + ) + + // Check that indicator component is rendered + expect(container.querySelector('.mr-1\\.5')).toBeInTheDocument() + }) + + it('should handle authorizationsNum of 0', async () => { + const AuthorizedInDataSourceNode = (await import('./authorized-in-data-source-node')).default + + render( + , + ) + + // 0 is not > 1, so should show singular + expect(screen.getByText('plugin.auth.authorization')).toBeInTheDocument() + }) + + it('should be memoized', async () => { + const AuthorizedInDataSourceNodeModule = await import('./authorized-in-data-source-node') + expect(typeof AuthorizedInDataSourceNodeModule.default).toBe('object') + }) +}) + +// ==================== AuthorizedInNode Component Tests ==================== +describe('AuthorizedInNode Component', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsCurrentWorkspaceManager.mockReturnValue(true) + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [createCredential({ is_default: true })], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + }) + }) + + it('should render with workspace default when no credentialId', async () => { + const AuthorizedInNode = (await import('./authorized-in-node')).default + + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('plugin.auth.workspaceDefault')).toBeInTheDocument() + }) + + it('should render credential name when credentialId matches', async () => { + const AuthorizedInNode = (await import('./authorized-in-node')).default + + const credential = createCredential({ id: 'selected-id', name: 'My Credential' }) + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [credential], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('My Credential')).toBeInTheDocument() + }) + + it('should show auth removed when credentialId not found', async () => { + const AuthorizedInNode = (await import('./authorized-in-node')).default + + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [createCredential()], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByText('plugin.auth.authRemoved')).toBeInTheDocument() + }) + + it('should show unavailable when credential is not allowed', async () => { + const AuthorizedInNode = (await import('./authorized-in-node')).default + + const credential = createCredential({ + id: 'unavailable-id', + not_allowed_to_use: true, + from_enterprise: false, + }) + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [credential], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + // Check that button text contains unavailable + const button = screen.getByRole('button') + expect(button.textContent).toContain('plugin.auth.unavailable') + }) + + it('should show unavailable when default credential is not allowed', async () => { + const AuthorizedInNode = (await import('./authorized-in-node')).default + + const credential = createCredential({ + is_default: true, + not_allowed_to_use: true, + }) + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [credential], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + // Check that button text contains unavailable + const button = screen.getByRole('button') + expect(button.textContent).toContain('plugin.auth.unavailable') + }) + + it('should call onAuthorizationItemClick when clicking', async () => { + const AuthorizedInNode = (await import('./authorized-in-node')).default + + const onAuthorizationItemClick = vi.fn() + const pluginPayload = createPluginPayload() + + render( + , + { wrapper: createWrapper() }, + ) + + // Click to open the popup + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[0]) + + // The popup should be open now - there will be multiple buttons after opening + expect(screen.getAllByRole('button').length).toBeGreaterThan(0) + }) + + it('should be memoized', async () => { + const AuthorizedInNodeModule = await import('./authorized-in-node') + expect(typeof AuthorizedInNodeModule.default).toBe('object') + }) +}) + +// ==================== useCredential Hooks Tests ==================== +describe('useCredential Hooks', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [], + supported_credential_types: [], + allow_custom_token: true, + }) + }) + + describe('useGetPluginCredentialInfoHook', () => { + it('should return credential info when enabled', async () => { + const { useGetPluginCredentialInfoHook } = await import('./hooks/use-credential') + + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [createCredential()], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => useGetPluginCredentialInfoHook(pluginPayload, true), { + wrapper: createWrapper(), + }) + + expect(result.current.data).toBeDefined() + expect(result.current.data?.credentials).toHaveLength(1) + }) + + it('should not fetch when disabled', async () => { + const { useGetPluginCredentialInfoHook } = await import('./hooks/use-credential') + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => useGetPluginCredentialInfoHook(pluginPayload, false), { + wrapper: createWrapper(), + }) + + expect(result.current.data).toBeUndefined() + }) + }) + + describe('useDeletePluginCredentialHook', () => { + it('should return mutateAsync function', async () => { + const { useDeletePluginCredentialHook } = await import('./hooks/use-credential') + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => useDeletePluginCredentialHook(pluginPayload), { + wrapper: createWrapper(), + }) + + expect(typeof result.current.mutateAsync).toBe('function') + }) + }) + + describe('useInvalidPluginCredentialInfoHook', () => { + it('should return invalidation function that calls both invalidators', async () => { + const { useInvalidPluginCredentialInfoHook } = await import('./hooks/use-credential') + + const pluginPayload = createPluginPayload({ providerType: 'builtin' }) + + const { result } = renderHook(() => useInvalidPluginCredentialInfoHook(pluginPayload), { + wrapper: createWrapper(), + }) + + expect(typeof result.current).toBe('function') + + result.current() + + expect(mockInvalidPluginCredentialInfo).toHaveBeenCalled() + expect(mockInvalidToolsByType).toHaveBeenCalled() + }) + }) + + describe('useSetPluginDefaultCredentialHook', () => { + it('should return mutateAsync function', async () => { + const { useSetPluginDefaultCredentialHook } = await import('./hooks/use-credential') + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => useSetPluginDefaultCredentialHook(pluginPayload), { + wrapper: createWrapper(), + }) + + expect(typeof result.current.mutateAsync).toBe('function') + }) + }) + + describe('useGetPluginCredentialSchemaHook', () => { + it('should return schema data', async () => { + const { useGetPluginCredentialSchemaHook } = await import('./hooks/use-credential') + + mockGetPluginCredentialSchema.mockReturnValue([{ name: 'api_key', type: 'string' }]) + + const pluginPayload = createPluginPayload() + + const { result } = renderHook( + () => useGetPluginCredentialSchemaHook(pluginPayload, CredentialTypeEnum.API_KEY), + { wrapper: createWrapper() }, + ) + + expect(result.current.data).toBeDefined() + }) + }) + + describe('useAddPluginCredentialHook', () => { + it('should return mutateAsync function', async () => { + const { useAddPluginCredentialHook } = await import('./hooks/use-credential') + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => useAddPluginCredentialHook(pluginPayload), { + wrapper: createWrapper(), + }) + + expect(typeof result.current.mutateAsync).toBe('function') + }) + }) + + describe('useUpdatePluginCredentialHook', () => { + it('should return mutateAsync function', async () => { + const { useUpdatePluginCredentialHook } = await import('./hooks/use-credential') + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => useUpdatePluginCredentialHook(pluginPayload), { + wrapper: createWrapper(), + }) + + expect(typeof result.current.mutateAsync).toBe('function') + }) + }) + + describe('useGetPluginOAuthUrlHook', () => { + it('should return mutateAsync function', async () => { + const { useGetPluginOAuthUrlHook } = await import('./hooks/use-credential') + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => useGetPluginOAuthUrlHook(pluginPayload), { + wrapper: createWrapper(), + }) + + expect(typeof result.current.mutateAsync).toBe('function') + }) + }) + + describe('useGetPluginOAuthClientSchemaHook', () => { + it('should return schema data', async () => { + const { useGetPluginOAuthClientSchemaHook } = await import('./hooks/use-credential') + + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: true, + }) + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => useGetPluginOAuthClientSchemaHook(pluginPayload), { + wrapper: createWrapper(), + }) + + expect(result.current.data).toBeDefined() + }) + }) + + describe('useSetPluginOAuthCustomClientHook', () => { + it('should return mutateAsync function', async () => { + const { useSetPluginOAuthCustomClientHook } = await import('./hooks/use-credential') + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => useSetPluginOAuthCustomClientHook(pluginPayload), { + wrapper: createWrapper(), + }) + + expect(typeof result.current.mutateAsync).toBe('function') + }) + }) + + describe('useDeletePluginOAuthCustomClientHook', () => { + it('should return mutateAsync function', async () => { + const { useDeletePluginOAuthCustomClientHook } = await import('./hooks/use-credential') + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => useDeletePluginOAuthCustomClientHook(pluginPayload), { + wrapper: createWrapper(), + }) + + expect(typeof result.current.mutateAsync).toBe('function') + }) + }) +}) + +// ==================== Edge Cases and Error Handling ==================== +describe('Edge Cases and Error Handling', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsCurrentWorkspaceManager.mockReturnValue(true) + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: [], + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + mockGetPluginOAuthClientSchema.mockReturnValue({ + schema: [], + is_oauth_custom_client_enabled: false, + is_system_oauth_params_exists: false, + }) + }) + + describe('PluginAuth edge cases', () => { + it('should handle empty provider gracefully', async () => { + const PluginAuth = (await import('./plugin-auth')).default + + const pluginPayload = createPluginPayload({ provider: '' }) + + expect(() => { + render( + , + { wrapper: createWrapper() }, + ) + }).not.toThrow() + }) + + it('should handle tool and datasource auth categories with button', async () => { + const PluginAuth = (await import('./plugin-auth')).default + + // Tool and datasource categories should render with API support + const categoriesWithApi = [AuthCategory.tool] + + for (const category of categoriesWithApi) { + const pluginPayload = createPluginPayload({ category }) + + const { unmount } = render( + , + { wrapper: createWrapper() }, + ) + + expect(screen.getByRole('button')).toBeInTheDocument() + + unmount() + } + }) + + it('should handle model and trigger categories without throwing', async () => { + const PluginAuth = (await import('./plugin-auth')).default + + // Model and trigger categories have empty API endpoints, so they render without buttons + const categoriesWithoutApi = [AuthCategory.model, AuthCategory.trigger] + + for (const category of categoriesWithoutApi) { + const pluginPayload = createPluginPayload({ category }) + + expect(() => { + const { unmount } = render( + , + { wrapper: createWrapper() }, + ) + unmount() + }).not.toThrow() + } + }) + + it('should handle undefined detail', async () => { + const PluginAuth = (await import('./plugin-auth')).default + + const pluginPayload = createPluginPayload({ detail: undefined }) + + expect(() => { + render( + , + { wrapper: createWrapper() }, + ) + }).not.toThrow() + }) + }) + + describe('usePluginAuthAction error handling', () => { + it('should handle delete error gracefully', async () => { + const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') + + mockDeletePluginCredential.mockRejectedValue(new Error('Delete failed')) + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.openConfirm('test-id') + }) + + // Should not throw, error is caught + await expect( + act(async () => { + await result.current.handleConfirm() + }), + ).rejects.toThrow('Delete failed') + + // Action state should be reset + expect(result.current.doingAction).toBe(false) + }) + + it('should handle set default error gracefully', async () => { + const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') + + mockSetPluginDefaultCredential.mockRejectedValue(new Error('Set default failed')) + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { + wrapper: createWrapper(), + }) + + await expect( + act(async () => { + await result.current.handleSetDefault('test-id') + }), + ).rejects.toThrow('Set default failed') + + expect(result.current.doingAction).toBe(false) + }) + + it('should handle rename error gracefully', async () => { + const { usePluginAuthAction } = await import('./hooks/use-plugin-auth-action') + + mockUpdatePluginCredential.mockRejectedValue(new Error('Rename failed')) + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuthAction(pluginPayload), { + wrapper: createWrapper(), + }) + + await expect( + act(async () => { + await result.current.handleRename({ credential_id: 'test-id', name: 'New Name' }) + }), + ).rejects.toThrow('Rename failed') + + expect(result.current.doingAction).toBe(false) + }) + }) + + describe('Credential list edge cases', () => { + it('should handle large credential lists', async () => { + const { usePluginAuth } = await import('./hooks/use-plugin-auth') + + const largeCredentialList = createCredentialList(100) + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: largeCredentialList, + supported_credential_types: [CredentialTypeEnum.API_KEY], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { + wrapper: createWrapper(), + }) + + expect(result.current.isAuthorized).toBe(true) + expect(result.current.credentials).toHaveLength(100) + }) + + it('should handle mixed credential types', async () => { + const { usePluginAuth } = await import('./hooks/use-plugin-auth') + + const mixedCredentials = [ + createCredential({ id: '1', credential_type: CredentialTypeEnum.API_KEY }), + createCredential({ id: '2', credential_type: CredentialTypeEnum.OAUTH2 }), + createCredential({ id: '3', credential_type: undefined }), + ] + mockGetPluginCredentialInfo.mockReturnValue({ + credentials: mixedCredentials, + supported_credential_types: [CredentialTypeEnum.API_KEY, CredentialTypeEnum.OAUTH2], + allow_custom_token: true, + }) + + const pluginPayload = createPluginPayload() + + const { result } = renderHook(() => usePluginAuth(pluginPayload, true), { + wrapper: createWrapper(), + }) + + expect(result.current.credentials).toHaveLength(3) + expect(result.current.canOAuth).toBe(true) + expect(result.current.canApiKey).toBe(true) + }) + }) + + describe('Boundary conditions', () => { + it('should handle special characters in provider name', async () => { + const { useGetApi } = await import('./hooks/use-get-api') + + const pluginPayload = createPluginPayload({ + provider: 'test-provider_v2.0', + }) + + const apiMap = useGetApi(pluginPayload) + + expect(apiMap.getCredentialInfo).toContain('test-provider_v2.0') + }) + + it('should handle very long provider names', async () => { + const { useGetApi } = await import('./hooks/use-get-api') + + const longProvider = 'a'.repeat(200) + const pluginPayload = createPluginPayload({ + provider: longProvider, + }) + + const apiMap = useGetApi(pluginPayload) + + expect(apiMap.getCredentialInfo).toContain(longProvider) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-item/action.spec.tsx b/web/app/components/plugins/plugin-item/action.spec.tsx new file mode 100644 index 0000000000..9969357bb6 --- /dev/null +++ b/web/app/components/plugins/plugin-item/action.spec.tsx @@ -0,0 +1,937 @@ +import type { MetaData, PluginCategoryEnum } from '../types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Toast from '@/app/components/base/toast' + +// ==================== Imports (after mocks) ==================== + +import { PluginSource } from '../types' +import Action from './action' + +// ==================== Mock Setup ==================== + +// Use vi.hoisted to define mock functions that can be referenced in vi.mock +const { + mockUninstallPlugin, + mockFetchReleases, + mockCheckForUpdates, + mockSetShowUpdatePluginModal, + mockInvalidateInstalledPluginList, +} = vi.hoisted(() => ({ + mockUninstallPlugin: vi.fn(), + mockFetchReleases: vi.fn(), + mockCheckForUpdates: vi.fn(), + mockSetShowUpdatePluginModal: vi.fn(), + mockInvalidateInstalledPluginList: vi.fn(), +})) + +// Mock uninstall plugin service +vi.mock('@/service/plugins', () => ({ + uninstallPlugin: (id: string) => mockUninstallPlugin(id), +})) + +// Mock GitHub releases hook +vi.mock('../install-plugin/hooks', () => ({ + useGitHubReleases: () => ({ + fetchReleases: mockFetchReleases, + checkForUpdates: mockCheckForUpdates, + }), +})) + +// Mock modal context +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowUpdatePluginModal: mockSetShowUpdatePluginModal, + }), +})) + +// Mock invalidate installed plugin list +vi.mock('@/service/use-plugins', () => ({ + useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, +})) + +// Mock PluginInfo component - has complex dependencies (Modal, KeyValueItem) +vi.mock('../plugin-page/plugin-info', () => ({ + default: ({ repository, release, packageName, onHide }: { + repository: string + release: string + packageName: string + onHide: () => void + }) => ( +
+ +
+ ), +})) + +// Mock Tooltip - uses PortalToFollowElem which requires complex floating UI setup +// Simplified mock that just renders children with tooltip content accessible +vi.mock('../../base/tooltip', () => ({ + default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( +
+ {children} +
+ ), +})) + +// Mock Confirm - uses createPortal which has issues in test environment +vi.mock('../../base/confirm', () => ({ + default: ({ isShow, title, content, onCancel, onConfirm, isLoading, isDisabled }: { + isShow: boolean + title: string + content: React.ReactNode + onCancel: () => void + onConfirm: () => void + isLoading: boolean + isDisabled: boolean + }) => { + if (!isShow) + return null + return ( +
+
{title}
+
{content}
+ + +
+ ) + }, +})) + +// ==================== Test Utilities ==================== + +type ActionProps = { + author: string + installationId: string + pluginUniqueIdentifier: string + pluginName: string + category: PluginCategoryEnum + usedInApps: number + isShowFetchNewVersion: boolean + isShowInfo: boolean + isShowDelete: boolean + onDelete: () => void + meta?: MetaData +} + +const createActionProps = (overrides: Partial = {}): ActionProps => ({ + author: 'test-author', + installationId: 'install-123', + pluginUniqueIdentifier: 'test-author/test-plugin@1.0.0', + pluginName: 'test-plugin', + category: 'tool' as PluginCategoryEnum, + usedInApps: 5, + isShowFetchNewVersion: false, + isShowInfo: false, + isShowDelete: true, + onDelete: vi.fn(), + meta: { + repo: 'test-author/test-plugin', + version: '1.0.0', + package: 'test-plugin.difypkg', + }, + ...overrides, +}) + +// ==================== Tests ==================== + +// Helper to find action buttons (real ActionButton component uses type="button") +const getActionButtons = () => screen.getAllByRole('button') +const queryActionButtons = () => screen.queryAllByRole('button') + +describe('Action Component', () => { + // Spy on Toast.notify - real component but we track calls + let toastNotifySpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + // Spy on Toast.notify and mock implementation to avoid DOM side effects + toastNotifySpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + mockUninstallPlugin.mockResolvedValue({ success: true }) + mockFetchReleases.mockResolvedValue([]) + mockCheckForUpdates.mockReturnValue({ + needUpdate: false, + toastProps: { type: 'info', message: 'Up to date' }, + }) + }) + + afterEach(() => { + toastNotifySpy.mockRestore() + }) + + // ==================== Rendering Tests ==================== + describe('Rendering', () => { + it('should render delete button when isShowDelete is true', () => { + // Arrange + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + }) + + // Act + render() + + // Assert + expect(getActionButtons()).toHaveLength(1) + }) + + it('should render fetch new version button when isShowFetchNewVersion is true', () => { + // Arrange + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowInfo: false, + isShowDelete: false, + }) + + // Act + render() + + // Assert + expect(getActionButtons()).toHaveLength(1) + }) + + it('should render info button when isShowInfo is true', () => { + // Arrange + const props = createActionProps({ + isShowFetchNewVersion: false, + isShowInfo: true, + isShowDelete: false, + }) + + // Act + render() + + // Assert + expect(getActionButtons()).toHaveLength(1) + }) + + it('should render all buttons when all flags are true', () => { + // Arrange + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowInfo: true, + isShowDelete: true, + }) + + // Act + render() + + // Assert + expect(getActionButtons()).toHaveLength(3) + }) + + it('should render no buttons when all flags are false', () => { + // Arrange + const props = createActionProps({ + isShowFetchNewVersion: false, + isShowInfo: false, + isShowDelete: false, + }) + + // Act + render() + + // Assert + expect(queryActionButtons()).toHaveLength(0) + }) + + it('should render tooltips for each button', () => { + // Arrange + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowInfo: true, + isShowDelete: true, + }) + + // Act + render() + + // Assert + const tooltips = screen.getAllByTestId('tooltip') + expect(tooltips).toHaveLength(3) + }) + }) + + // ==================== Delete Functionality Tests ==================== + describe('Delete Functionality', () => { + it('should show delete confirm modal when delete button is clicked', () => { + // Arrange + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + expect(screen.getByTestId('confirm-title')).toHaveTextContent('plugin.action.delete') + }) + + it('should display plugin name in delete confirm content', () => { + // Arrange + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + pluginName: 'my-awesome-plugin', + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + expect(screen.getByText('my-awesome-plugin')).toBeInTheDocument() + }) + + it('should hide confirm modal when cancel is clicked', () => { + // Arrange + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('confirm-cancel')) + + // Assert + expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + }) + + it('should call uninstallPlugin when confirm is clicked', async () => { + // Arrange + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + installationId: 'install-456', + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + // Assert + await waitFor(() => { + expect(mockUninstallPlugin).toHaveBeenCalledWith('install-456') + }) + }) + + it('should call onDelete callback after successful uninstall', async () => { + // Arrange + mockUninstallPlugin.mockResolvedValue({ success: true }) + const onDelete = vi.fn() + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + onDelete, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + // Assert + await waitFor(() => { + expect(onDelete).toHaveBeenCalled() + }) + }) + + it('should not call onDelete if uninstall fails', async () => { + // Arrange + mockUninstallPlugin.mockResolvedValue({ success: false }) + const onDelete = vi.fn() + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + onDelete, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + // Assert + await waitFor(() => { + expect(mockUninstallPlugin).toHaveBeenCalled() + }) + expect(onDelete).not.toHaveBeenCalled() + }) + + it('should handle uninstall error gracefully', async () => { + // Arrange + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + mockUninstallPlugin.mockRejectedValue(new Error('Network error')) + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + // Assert + await waitFor(() => { + expect(consoleError).toHaveBeenCalledWith('uninstallPlugin error', expect.any(Error)) + }) + + consoleError.mockRestore() + }) + + it('should show loading state during deletion', async () => { + // Arrange + let resolveUninstall: (value: { success: boolean }) => void + mockUninstallPlugin.mockReturnValue( + new Promise((resolve) => { + resolveUninstall = resolve + }), + ) + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + // Assert - Loading state + await waitFor(() => { + expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-loading', 'true') + }) + + // Resolve and check modal closes + resolveUninstall!({ success: true }) + await waitFor(() => { + expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + }) + }) + }) + + // ==================== Plugin Info Tests ==================== + describe('Plugin Info', () => { + it('should show plugin info modal when info button is clicked', () => { + // Arrange + const props = createActionProps({ + isShowInfo: true, + isShowDelete: false, + isShowFetchNewVersion: false, + meta: { + repo: 'owner/repo-name', + version: '2.0.0', + package: 'my-package.difypkg', + }, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + expect(screen.getByTestId('plugin-info-modal')).toBeInTheDocument() + expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-repo', 'owner/repo-name') + expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-release', '2.0.0') + expect(screen.getByTestId('plugin-info-modal')).toHaveAttribute('data-package', 'my-package.difypkg') + }) + + it('should hide plugin info modal when close is clicked', () => { + // Arrange + const props = createActionProps({ + isShowInfo: true, + isShowDelete: false, + isShowFetchNewVersion: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + expect(screen.getByTestId('plugin-info-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('close-plugin-info')) + + // Assert + expect(screen.queryByTestId('plugin-info-modal')).not.toBeInTheDocument() + }) + }) + + // ==================== Check for Updates Tests ==================== + describe('Check for Updates', () => { + it('should fetch releases when check for updates button is clicked', async () => { + // Arrange + mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }]) + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowDelete: false, + isShowInfo: false, + meta: { + repo: 'owner/repo', + version: '1.0.0', + package: 'pkg.difypkg', + }, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + await waitFor(() => { + expect(mockFetchReleases).toHaveBeenCalledWith('owner', 'repo') + }) + }) + + it('should use author and pluginName as fallback for empty repo parts', async () => { + // Arrange + mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }]) + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowDelete: false, + isShowInfo: false, + author: 'fallback-author', + pluginName: 'fallback-plugin', + meta: { + repo: '/', // Results in empty parts after split + version: '1.0.0', + package: 'pkg.difypkg', + }, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + await waitFor(() => { + expect(mockFetchReleases).toHaveBeenCalledWith('fallback-author', 'fallback-plugin') + }) + }) + + it('should not proceed if no releases are fetched', async () => { + // Arrange + mockFetchReleases.mockResolvedValue([]) + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowDelete: false, + isShowInfo: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + await waitFor(() => { + expect(mockFetchReleases).toHaveBeenCalled() + }) + expect(mockCheckForUpdates).not.toHaveBeenCalled() + }) + + it('should show toast notification after checking for updates', async () => { + // Arrange + mockFetchReleases.mockResolvedValue([{ version: '2.0.0' }]) + mockCheckForUpdates.mockReturnValue({ + needUpdate: false, + toastProps: { type: 'success', message: 'Already up to date' }, + }) + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowDelete: false, + isShowInfo: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert - Toast.notify is called with the toast props + await waitFor(() => { + expect(toastNotifySpy).toHaveBeenCalledWith({ type: 'success', message: 'Already up to date' }) + }) + }) + + it('should show update modal when update is available', async () => { + // Arrange + const releases = [{ version: '2.0.0' }] + mockFetchReleases.mockResolvedValue(releases) + mockCheckForUpdates.mockReturnValue({ + needUpdate: true, + toastProps: { type: 'info', message: 'Update available' }, + }) + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowDelete: false, + isShowInfo: false, + pluginUniqueIdentifier: 'test-id', + category: 'model' as PluginCategoryEnum, + meta: { + repo: 'owner/repo', + version: '1.0.0', + package: 'pkg.difypkg', + }, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + await waitFor(() => { + expect(mockSetShowUpdatePluginModal).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + type: PluginSource.github, + category: 'model', + github: expect.objectContaining({ + originalPackageInfo: expect.objectContaining({ + id: 'test-id', + repo: 'owner/repo', + version: '1.0.0', + package: 'pkg.difypkg', + releases, + }), + }), + }), + }), + ) + }) + }) + + it('should call invalidateInstalledPluginList on save callback', async () => { + // Arrange + const releases = [{ version: '2.0.0' }] + mockFetchReleases.mockResolvedValue(releases) + mockCheckForUpdates.mockReturnValue({ + needUpdate: true, + toastProps: { type: 'info', message: 'Update available' }, + }) + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowDelete: false, + isShowInfo: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Wait for modal to be called + await waitFor(() => { + expect(mockSetShowUpdatePluginModal).toHaveBeenCalled() + }) + + // Invoke the callback + const call = mockSetShowUpdatePluginModal.mock.calls[0][0] + call.onSaveCallback() + + // Assert + expect(mockInvalidateInstalledPluginList).toHaveBeenCalled() + }) + + it('should check updates with current version', async () => { + // Arrange + const releases = [{ version: '2.0.0' }, { version: '1.5.0' }] + mockFetchReleases.mockResolvedValue(releases) + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowDelete: false, + isShowInfo: false, + meta: { + repo: 'owner/repo', + version: '1.0.0', + package: 'pkg.difypkg', + }, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + await waitFor(() => { + expect(mockCheckForUpdates).toHaveBeenCalledWith(releases, '1.0.0') + }) + }) + }) + + // ==================== Callback Stability Tests ==================== + describe('Callback Stability (useCallback)', () => { + it('should have stable handleDelete callback with same dependencies', async () => { + // Arrange + mockUninstallPlugin.mockResolvedValue({ success: true }) + const onDelete = vi.fn() + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + onDelete, + installationId: 'stable-install-id', + }) + + // Act - First render and delete + const { rerender } = render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + await waitFor(() => { + expect(mockUninstallPlugin).toHaveBeenCalledWith('stable-install-id') + }) + + // Re-render with same props + mockUninstallPlugin.mockClear() + rerender() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + await waitFor(() => { + expect(mockUninstallPlugin).toHaveBeenCalledWith('stable-install-id') + }) + }) + + it('should update handleDelete when installationId changes', async () => { + // Arrange + mockUninstallPlugin.mockResolvedValue({ success: true }) + const props1 = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + installationId: 'install-1', + }) + const props2 = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + installationId: 'install-2', + }) + + // Act + const { rerender } = render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + await waitFor(() => { + expect(mockUninstallPlugin).toHaveBeenCalledWith('install-1') + }) + + mockUninstallPlugin.mockClear() + rerender() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + await waitFor(() => { + expect(mockUninstallPlugin).toHaveBeenCalledWith('install-2') + }) + }) + + it('should update handleDelete when onDelete changes', async () => { + // Arrange + mockUninstallPlugin.mockResolvedValue({ success: true }) + const onDelete1 = vi.fn() + const onDelete2 = vi.fn() + const props1 = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + onDelete: onDelete1, + }) + const props2 = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + onDelete: onDelete2, + }) + + // Act + const { rerender } = render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + await waitFor(() => { + expect(onDelete1).toHaveBeenCalled() + }) + expect(onDelete2).not.toHaveBeenCalled() + + rerender() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + await waitFor(() => { + expect(onDelete2).toHaveBeenCalled() + }) + }) + }) + + // ==================== Edge Cases ==================== + describe('Edge Cases', () => { + it('should handle undefined meta for info display', () => { + // Arrange - meta is required for info, but test defensive behavior + const props = createActionProps({ + isShowInfo: false, + isShowDelete: true, + isShowFetchNewVersion: false, + meta: undefined, + }) + + // Act & Assert - Should not crash + expect(() => render()).not.toThrow() + }) + + it('should handle empty repo string', async () => { + // Arrange + mockFetchReleases.mockResolvedValue([{ version: '1.0.0' }]) + const props = createActionProps({ + isShowFetchNewVersion: true, + isShowDelete: false, + isShowInfo: false, + author: 'fallback-owner', + pluginName: 'fallback-repo', + meta: { + repo: '', + version: '1.0.0', + package: 'pkg.difypkg', + }, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert - Should use author and pluginName as fallback + await waitFor(() => { + expect(mockFetchReleases).toHaveBeenCalledWith('fallback-owner', 'fallback-repo') + }) + }) + + it('should handle concurrent delete requests gracefully', async () => { + // Arrange + let resolveFirst: (value: { success: boolean }) => void + const firstPromise = new Promise<{ success: boolean }>((resolve) => { + resolveFirst = resolve + }) + mockUninstallPlugin.mockReturnValueOnce(firstPromise) + + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + fireEvent.click(screen.getByTestId('confirm-ok')) + + // The confirm button should be disabled during deletion + expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-loading', 'true') + expect(screen.getByTestId('confirm-modal')).toHaveAttribute('data-disabled', 'true') + + // Resolve the deletion + resolveFirst!({ success: true }) + + await waitFor(() => { + expect(screen.queryByTestId('confirm-modal')).not.toBeInTheDocument() + }) + }) + + it('should handle special characters in plugin name', () => { + // Arrange + const props = createActionProps({ + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + pluginName: 'plugin-with-special@chars#123', + }) + + // Act + render() + fireEvent.click(getActionButtons()[0]) + + // Assert + expect(screen.getByText('plugin-with-special@chars#123')).toBeInTheDocument() + }) + }) + + // ==================== React.memo Tests ==================== + describe('React.memo Behavior', () => { + it('should be wrapped with React.memo', () => { + // Assert + expect(Action).toBeDefined() + expect((Action as any).$$typeof?.toString()).toContain('Symbol') + }) + }) + + // ==================== Prop Variations ==================== + describe('Prop Variations', () => { + it('should handle all category types', () => { + // Arrange + const categories = ['tool', 'model', 'extension', 'agent-strategy', 'datasource'] as PluginCategoryEnum[] + + categories.forEach((category) => { + const props = createActionProps({ + category, + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + }) + expect(() => render()).not.toThrow() + }) + }) + + it('should handle different usedInApps values', () => { + // Arrange + const values = [0, 1, 5, 100] + + values.forEach((usedInApps) => { + const props = createActionProps({ + usedInApps, + isShowDelete: true, + isShowInfo: false, + isShowFetchNewVersion: false, + }) + expect(() => render()).not.toThrow() + }) + }) + + it('should handle combination of multiple action buttons', () => { + // Arrange - Test various combinations + const combinations = [ + { isShowFetchNewVersion: true, isShowInfo: false, isShowDelete: false }, + { isShowFetchNewVersion: false, isShowInfo: true, isShowDelete: false }, + { isShowFetchNewVersion: false, isShowInfo: false, isShowDelete: true }, + { isShowFetchNewVersion: true, isShowInfo: true, isShowDelete: false }, + { isShowFetchNewVersion: true, isShowInfo: false, isShowDelete: true }, + { isShowFetchNewVersion: false, isShowInfo: true, isShowDelete: true }, + { isShowFetchNewVersion: true, isShowInfo: true, isShowDelete: true }, + ] + + combinations.forEach((flags) => { + const props = createActionProps(flags) + const expectedCount = [flags.isShowFetchNewVersion, flags.isShowInfo, flags.isShowDelete].filter(Boolean).length + + const { unmount } = render() + const buttons = queryActionButtons() + expect(buttons).toHaveLength(expectedCount) + unmount() + }) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-item/index.spec.tsx b/web/app/components/plugins/plugin-item/index.spec.tsx new file mode 100644 index 0000000000..ae76e64c46 --- /dev/null +++ b/web/app/components/plugins/plugin-item/index.spec.tsx @@ -0,0 +1,1016 @@ +import type { PluginDeclaration, PluginDetail } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum, PluginSource } from '../types' + +// ==================== Imports (after mocks) ==================== + +import PluginItem from './index' + +// ==================== Mock Setup ==================== + +// Mock theme hook +const mockTheme = vi.fn(() => 'light') +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: mockTheme() }), +})) + +// Mock i18n render hook +const mockGetValueFromI18nObject = vi.fn((obj: Record) => obj?.en_US || '') +vi.mock('@/hooks/use-i18n', () => ({ + useRenderI18nObject: () => mockGetValueFromI18nObject, +})) + +// Mock categories hook +const mockCategoriesMap: Record = { + 'tool': { name: 'tool', label: 'Tools' }, + 'model': { name: 'model', label: 'Models' }, + 'extension': { name: 'extension', label: 'Extensions' }, + 'agent-strategy': { name: 'agent-strategy', label: 'Agents' }, + 'datasource': { name: 'datasource', label: 'Data Sources' }, +} +vi.mock('../hooks', () => ({ + useCategories: () => ({ + categories: Object.values(mockCategoriesMap), + categoriesMap: mockCategoriesMap, + }), +})) + +// Mock plugin page context +const mockCurrentPluginID = vi.fn((): string | undefined => undefined) +const mockSetCurrentPluginID = vi.fn() +vi.mock('../plugin-page/context', () => ({ + usePluginPageContext: (selector: (v: any) => any) => { + const context = { + currentPluginID: mockCurrentPluginID(), + setCurrentPluginID: mockSetCurrentPluginID, + } + return selector(context) + }, +})) + +// Mock refresh plugin list hook +const mockRefreshPluginList = vi.fn() +vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({ + default: () => ({ refreshPluginList: mockRefreshPluginList }), +})) + +// Mock app context +const mockLangGeniusVersionInfo = vi.fn(() => ({ + current_version: '1.0.0', +})) +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + langGeniusVersionInfo: mockLangGeniusVersionInfo(), + }), +})) + +// Mock global public store +const mockEnableMarketplace = vi.fn(() => true) +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (s: any) => any) => + selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace() } }), +})) + +// Mock Action component +vi.mock('./action', () => ({ + default: ({ onDelete, pluginName }: { onDelete: () => void, pluginName: string }) => ( +
+ +
+ ), +})) + +// Mock child components +vi.mock('../card/base/corner-mark', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +vi.mock('../card/base/title', () => ({ + default: ({ title }: { title: string }) =>
{title}
, +})) + +vi.mock('../card/base/description', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +vi.mock('../card/base/org-info', () => ({ + default: ({ orgName, packageName }: { orgName: string, packageName: string }) => ( +
+ {orgName} + / + {packageName} +
+ ), +})) + +vi.mock('../base/badges/verified', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +vi.mock('../../base/badge', () => ({ + default: ({ text, hasRedCornerMark }: { text: string, hasRedCornerMark?: boolean }) => ( +
{text}
+ ), +})) + +// ==================== Test Utilities ==================== + +const createPluginDeclaration = (overrides: Partial = {}): PluginDeclaration => ({ + plugin_unique_identifier: 'test-plugin-id', + version: '1.0.0', + author: 'test-author', + icon: 'test-icon.png', + icon_dark: 'test-icon-dark.png', + name: 'test-plugin', + category: PluginCategoryEnum.tool, + label: { en_US: 'Test Plugin' } as any, + description: { en_US: 'Test plugin description' } as any, + created_at: '2024-01-01', + resource: null, + plugins: null, + verified: false, + endpoint: {} as any, + model: null, + tags: [], + agent_strategy: null, + meta: { + version: '1.0.0', + minimum_dify_version: '0.5.0', + }, + trigger: {} as any, + ...overrides, +}) + +const createPluginDetail = (overrides: Partial = {}): PluginDetail => ({ + id: 'plugin-1', + created_at: '2024-01-01', + updated_at: '2024-01-01', + name: 'test-plugin', + plugin_id: 'plugin-1', + plugin_unique_identifier: 'test-author/test-plugin@1.0.0', + declaration: createPluginDeclaration(), + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: 'test-author/test-plugin@1.0.0', + source: PluginSource.marketplace, + meta: { + repo: 'test-author/test-plugin', + version: '1.0.0', + package: 'test-plugin.difypkg', + }, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', + ...overrides, +}) + +// ==================== Tests ==================== + +describe('PluginItem', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTheme.mockReturnValue('light') + mockCurrentPluginID.mockReturnValue(undefined) + mockEnableMarketplace.mockReturnValue(true) + mockLangGeniusVersionInfo.mockReturnValue({ current_version: '1.0.0' }) + mockGetValueFromI18nObject.mockImplementation((obj: Record) => obj?.en_US || '') + }) + + // ==================== Rendering Tests ==================== + describe('Rendering', () => { + it('should render plugin item with basic info', () => { + // Arrange + const plugin = createPluginDetail() + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-title')).toBeInTheDocument() + expect(screen.getByTestId('plugin-description')).toBeInTheDocument() + expect(screen.getByTestId('corner-mark')).toBeInTheDocument() + expect(screen.getByTestId('version-badge')).toBeInTheDocument() + }) + + it('should render plugin icon', () => { + // Arrange + const plugin = createPluginDetail() + + // Act + render() + + // Assert + const img = screen.getByRole('img') + expect(img).toHaveAttribute('alt', `plugin-${plugin.plugin_unique_identifier}-logo`) + }) + + it('should render category label in corner mark', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }), + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('corner-mark')).toHaveTextContent('Models') + }) + + it('should apply custom className', () => { + // Arrange + const plugin = createPluginDetail() + + // Act + const { container } = render() + + // Assert + const innerDiv = container.querySelector('.custom-class') + expect(innerDiv).toBeInTheDocument() + }) + }) + + // ==================== Plugin Sources Tests ==================== + describe('Plugin Sources', () => { + it('should render GitHub source with repo link', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.github, + meta: { repo: 'owner/repo', version: '1.0.0', package: 'pkg.difypkg' }, + }) + + // Act + render() + + // Assert + const githubLink = screen.getByRole('link') + expect(githubLink).toHaveAttribute('href', 'https://github.com/owner/repo') + expect(screen.getByText('GitHub')).toBeInTheDocument() + }) + + it('should render marketplace source with link when enabled', () => { + // Arrange + mockEnableMarketplace.mockReturnValue(true) + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + declaration: createPluginDeclaration({ author: 'test-author', name: 'test-plugin' }), + }) + + // Act + render() + + // Assert + expect(screen.getByText('marketplace')).toBeInTheDocument() + }) + + it('should render local source indicator', () => { + // Arrange + const plugin = createPluginDetail({ source: PluginSource.local }) + + // Act + render() + + // Assert + expect(screen.getByText('Local Plugin')).toBeInTheDocument() + }) + + it('should render debugging source indicator', () => { + // Arrange + const plugin = createPluginDetail({ source: PluginSource.debugging }) + + // Act + render() + + // Assert + expect(screen.getByText('Debugging Plugin')).toBeInTheDocument() + }) + + it('should show org info for GitHub source', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.github, + declaration: createPluginDeclaration({ author: 'github-author' }), + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', 'github-author') + }) + + it('should show org info for marketplace source', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + declaration: createPluginDeclaration({ author: 'marketplace-author' }), + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', 'marketplace-author') + }) + + it('should not show org info for local source', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.local, + declaration: createPluginDeclaration({ author: 'local-author' }), + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', '') + }) + }) + + // ==================== Extension Category Tests ==================== + describe('Extension Category', () => { + it('should show endpoints info for extension category', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.extension }), + endpoints_active: 3, + }) + + // Act + render() + + // Assert - The translation includes interpolation + expect(screen.getByText(/plugin\.endpointsEnabled/)).toBeInTheDocument() + }) + + it('should not show endpoints info for non-extension category', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }), + endpoints_active: 3, + }) + + // Act + render() + + // Assert + expect(screen.queryByText(/plugin\.endpointsEnabled/)).not.toBeInTheDocument() + }) + }) + + // ==================== Version Compatibility Tests ==================== + describe('Version Compatibility', () => { + it('should show warning icon when Dify version is not compatible', () => { + // Arrange + mockLangGeniusVersionInfo.mockReturnValue({ current_version: '0.3.0' }) + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ + meta: { version: '1.0.0', minimum_dify_version: '0.5.0' }, + }), + }) + + // Act + const { container } = render() + + // Assert - Warning icon should be rendered + const warningIcon = container.querySelector('.text-text-accent') + expect(warningIcon).toBeInTheDocument() + }) + + it('should not show warning when Dify version is compatible', () => { + // Arrange + mockLangGeniusVersionInfo.mockReturnValue({ current_version: '1.0.0' }) + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ + meta: { version: '1.0.0', minimum_dify_version: '0.5.0' }, + }), + }) + + // Act + const { container } = render() + + // Assert + const warningIcon = container.querySelector('.text-text-accent') + expect(warningIcon).not.toBeInTheDocument() + }) + + it('should handle missing current_version gracefully', () => { + // Arrange + mockLangGeniusVersionInfo.mockReturnValue({ current_version: '' }) + const plugin = createPluginDetail() + + // Act + const { container } = render() + + // Assert - Should not crash and not show warning + const warningIcon = container.querySelector('.text-text-accent') + expect(warningIcon).not.toBeInTheDocument() + }) + + it('should handle missing minimum_dify_version gracefully', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ + meta: { version: '1.0.0' }, + }), + }) + + // Act + const { container } = render() + + // Assert - Should not crash and not show warning + const warningIcon = container.querySelector('.text-text-accent') + expect(warningIcon).not.toBeInTheDocument() + }) + }) + + // ==================== Deprecated Plugin Tests ==================== + describe('Deprecated Plugin', () => { + it('should show deprecated indicator for deprecated marketplace plugin', () => { + // Arrange + mockEnableMarketplace.mockReturnValue(true) + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + status: 'deleted', + deprecated_reason: 'Plugin is no longer maintained', + }) + + // Act + render() + + // Assert + expect(screen.getByText('plugin.deprecated')).toBeInTheDocument() + }) + + it('should show background effect for deprecated plugin', () => { + // Arrange + mockEnableMarketplace.mockReturnValue(true) + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + status: 'deleted', + deprecated_reason: 'Plugin is deprecated', + }) + + // Act + const { container } = render() + + // Assert + const bgEffect = container.querySelector('.blur-\\[120px\\]') + expect(bgEffect).toBeInTheDocument() + }) + + it('should not show deprecated indicator for active plugin', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + status: 'active', + deprecated_reason: '', + }) + + // Act + render() + + // Assert + expect(screen.queryByText('plugin.deprecated')).not.toBeInTheDocument() + }) + + it('should not show deprecated indicator for non-marketplace source', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.github, + status: 'deleted', + deprecated_reason: 'Some reason', + }) + + // Act + render() + + // Assert + expect(screen.queryByText('plugin.deprecated')).not.toBeInTheDocument() + }) + + it('should not show deprecated when marketplace is disabled', () => { + // Arrange + mockEnableMarketplace.mockReturnValue(false) + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + status: 'deleted', + deprecated_reason: 'Some reason', + }) + + // Act + render() + + // Assert + expect(screen.queryByText('plugin.deprecated')).not.toBeInTheDocument() + }) + }) + + // ==================== Verified Badge Tests ==================== + describe('Verified Badge', () => { + it('should show verified badge for verified plugin', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ verified: true }), + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('verified-badge')).toBeInTheDocument() + }) + + it('should not show verified badge for unverified plugin', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ verified: false }), + }) + + // Act + render() + + // Assert + expect(screen.queryByTestId('verified-badge')).not.toBeInTheDocument() + }) + }) + + // ==================== Version Badge Tests ==================== + describe('Version Badge', () => { + it('should show version from meta for GitHub source', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.github, + version: '2.0.0', + meta: { repo: 'owner/repo', version: '1.5.0', package: 'pkg' }, + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('version-badge')).toHaveTextContent('1.5.0') + }) + + it('should show version from plugin for marketplace source', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + version: '2.0.0', + meta: { repo: 'owner/repo', version: '1.5.0', package: 'pkg' }, + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('version-badge')).toHaveTextContent('2.0.0') + }) + + it('should show update indicator when new version available', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + version: '1.0.0', + latest_version: '2.0.0', + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('version-badge')).toHaveAttribute('data-has-update', 'true') + }) + + it('should not show update indicator when version is latest', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + version: '1.0.0', + latest_version: '1.0.0', + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('version-badge')).toHaveAttribute('data-has-update', 'false') + }) + + it('should not show update indicator for non-marketplace source', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.github, + version: '1.0.0', + latest_version: '2.0.0', + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('version-badge')).toHaveAttribute('data-has-update', 'false') + }) + }) + + // ==================== User Interactions Tests ==================== + describe('User Interactions', () => { + it('should call setCurrentPluginID when plugin is clicked', () => { + // Arrange + const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' }) + + // Act + const { container } = render() + const pluginContainer = container.firstChild as HTMLElement + fireEvent.click(pluginContainer) + + // Assert + expect(mockSetCurrentPluginID).toHaveBeenCalledWith('test-plugin-id') + }) + + it('should highlight selected plugin', () => { + // Arrange + mockCurrentPluginID.mockReturnValue('test-plugin-id') + const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' }) + + // Act + const { container } = render() + + // Assert + const pluginContainer = container.firstChild as HTMLElement + expect(pluginContainer).toHaveClass('border-components-option-card-option-selected-border') + }) + + it('should not highlight unselected plugin', () => { + // Arrange + mockCurrentPluginID.mockReturnValue('other-plugin-id') + const plugin = createPluginDetail({ plugin_id: 'test-plugin-id' }) + + // Act + const { container } = render() + + // Assert + const pluginContainer = container.firstChild as HTMLElement + expect(pluginContainer).not.toHaveClass('border-components-option-card-option-selected-border') + }) + + it('should stop propagation when action area is clicked', () => { + // Arrange + const plugin = createPluginDetail() + + // Act + render() + const actionArea = screen.getByTestId('plugin-action').parentElement + fireEvent.click(actionArea!) + + // Assert - setCurrentPluginID should not be called + expect(mockSetCurrentPluginID).not.toHaveBeenCalled() + }) + }) + + // ==================== Delete Callback Tests ==================== + describe('Delete Callback', () => { + it('should call refreshPluginList when delete is triggered', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }), + }) + + // Act + render() + fireEvent.click(screen.getByTestId('delete-button')) + + // Assert + expect(mockRefreshPluginList).toHaveBeenCalledWith({ category: PluginCategoryEnum.tool }) + }) + + it('should pass correct category to refreshPluginList', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }), + }) + + // Act + render() + fireEvent.click(screen.getByTestId('delete-button')) + + // Assert + expect(mockRefreshPluginList).toHaveBeenCalledWith({ category: PluginCategoryEnum.model }) + }) + }) + + // ==================== Theme Tests ==================== + describe('Theme Support', () => { + it('should use dark icon when theme is dark and dark icon exists', () => { + // Arrange + mockTheme.mockReturnValue('dark') + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ + icon: 'light-icon.png', + icon_dark: 'dark-icon.png', + }), + }) + + // Act + render() + + // Assert + const img = screen.getByRole('img') + expect(img.getAttribute('src')).toContain('dark-icon.png') + }) + + it('should use light icon when theme is light', () => { + // Arrange + mockTheme.mockReturnValue('light') + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ + icon: 'light-icon.png', + icon_dark: 'dark-icon.png', + }), + }) + + // Act + render() + + // Assert + const img = screen.getByRole('img') + expect(img.getAttribute('src')).toContain('light-icon.png') + }) + + it('should use light icon when dark icon is not available', () => { + // Arrange + mockTheme.mockReturnValue('dark') + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ + icon: 'light-icon.png', + icon_dark: undefined, + }), + }) + + // Act + render() + + // Assert + const img = screen.getByRole('img') + expect(img.getAttribute('src')).toContain('light-icon.png') + }) + + it('should use external URL directly for icon', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ + icon: 'https://example.com/icon.png', + }), + }) + + // Act + render() + + // Assert + const img = screen.getByRole('img') + expect(img).toHaveAttribute('src', 'https://example.com/icon.png') + }) + }) + + // ==================== Memoization Tests ==================== + describe('Memoization', () => { + it('should memoize orgName based on source and author', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.github, + declaration: createPluginDeclaration({ author: 'test-author' }), + }) + + // Act + const { rerender } = render() + + // First render should show author + expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', 'test-author') + + // Re-render with same plugin + rerender() + + // Should still show same author + expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', 'test-author') + }) + + it('should update orgName when source changes', () => { + // Arrange + const githubPlugin = createPluginDetail({ + source: PluginSource.github, + declaration: createPluginDeclaration({ author: 'github-author' }), + }) + const localPlugin = createPluginDetail({ + source: PluginSource.local, + declaration: createPluginDeclaration({ author: 'local-author' }), + }) + + // Act + const { rerender } = render() + expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', 'github-author') + + rerender() + expect(screen.getByTestId('org-info')).toHaveAttribute('data-org', '') + }) + + it('should memoize isDeprecated based on status and deprecated_reason', () => { + // Arrange + mockEnableMarketplace.mockReturnValue(true) + const activePlugin = createPluginDetail({ + source: PluginSource.marketplace, + status: 'active', + deprecated_reason: '', + }) + const deprecatedPlugin = createPluginDetail({ + source: PluginSource.marketplace, + status: 'deleted', + deprecated_reason: 'Deprecated', + }) + + // Act + const { rerender } = render() + expect(screen.queryByText('plugin.deprecated')).not.toBeInTheDocument() + + rerender() + expect(screen.getByText('plugin.deprecated')).toBeInTheDocument() + }) + }) + + // ==================== Edge Cases ==================== + describe('Edge Cases', () => { + it('should handle empty icon gracefully', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ icon: '' }), + }) + + // Act & Assert - Should not throw when icon is empty + expect(() => render()).not.toThrow() + + // The img element should still be rendered + const img = screen.getByRole('img') + expect(img).toBeInTheDocument() + }) + + it('should handle missing meta for non-GitHub source', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.local, + meta: undefined, + }) + + // Act & Assert - Should not throw + expect(() => render()).not.toThrow() + }) + + it('should handle empty label gracefully', () => { + // Arrange + mockGetValueFromI18nObject.mockReturnValue('') + const plugin = createPluginDetail() + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-title')).toHaveTextContent('') + }) + + it('should handle zero endpoints_active', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.extension }), + endpoints_active: 0, + }) + + // Act + render() + + // Assert - Should still render endpoints info with zero + expect(screen.getByText(/plugin\.endpointsEnabled/)).toBeInTheDocument() + }) + + it('should handle null latest_version', () => { + // Arrange + const plugin = createPluginDetail({ + source: PluginSource.marketplace, + version: '1.0.0', + latest_version: null as any, + }) + + // Act + render() + + // Assert - Should not show update indicator + expect(screen.getByTestId('version-badge')).toHaveAttribute('data-has-update', 'false') + }) + }) + + // ==================== Prop Variations ==================== + describe('Prop Variations', () => { + it('should render correctly with minimal required props', () => { + // Arrange + const plugin = createPluginDetail() + + // Act & Assert + expect(() => render()).not.toThrow() + }) + + it('should handle different category types', () => { + // Arrange + const categories = [ + PluginCategoryEnum.tool, + PluginCategoryEnum.model, + PluginCategoryEnum.extension, + PluginCategoryEnum.agent, + PluginCategoryEnum.datasource, + ] + + categories.forEach((category) => { + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ category }), + }) + + // Act & Assert + expect(() => render()).not.toThrow() + }) + }) + + it('should handle all source types', () => { + // Arrange + const sources = [ + PluginSource.marketplace, + PluginSource.github, + PluginSource.local, + PluginSource.debugging, + ] + + sources.forEach((source) => { + const plugin = createPluginDetail({ source }) + + // Act & Assert + expect(() => render()).not.toThrow() + }) + }) + }) + + // ==================== Callback Stability Tests ==================== + describe('Callback Stability', () => { + it('should have stable handleDelete callback', () => { + // Arrange + const plugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }), + }) + + // Act + const { rerender } = render() + fireEvent.click(screen.getByTestId('delete-button')) + const firstCallArgs = mockRefreshPluginList.mock.calls[0] + + mockRefreshPluginList.mockClear() + rerender() + fireEvent.click(screen.getByTestId('delete-button')) + const secondCallArgs = mockRefreshPluginList.mock.calls[0] + + // Assert - Both calls should have same arguments + expect(firstCallArgs).toEqual(secondCallArgs) + }) + + it('should update handleDelete when category changes', () => { + // Arrange + const toolPlugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }), + }) + const modelPlugin = createPluginDetail({ + declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }), + }) + + // Act + const { rerender } = render() + fireEvent.click(screen.getByTestId('delete-button')) + expect(mockRefreshPluginList).toHaveBeenCalledWith({ category: PluginCategoryEnum.tool }) + + mockRefreshPluginList.mockClear() + rerender() + fireEvent.click(screen.getByTestId('delete-button')) + expect(mockRefreshPluginList).toHaveBeenCalledWith({ category: PluginCategoryEnum.model }) + }) + }) + + // ==================== React.memo Tests ==================== + describe('React.memo Behavior', () => { + it('should be wrapped with React.memo', () => { + // Arrange & Assert + // The component is exported as React.memo(PluginItem) + // We can verify by checking the displayName or type + expect(PluginItem).toBeDefined() + // React.memo components have a $$typeof property + expect((PluginItem as any).$$typeof?.toString()).toContain('Symbol') + }) + }) +}) diff --git a/web/app/components/plugins/plugin-page/empty/index.spec.tsx b/web/app/components/plugins/plugin-page/empty/index.spec.tsx new file mode 100644 index 0000000000..51d4af919d --- /dev/null +++ b/web/app/components/plugins/plugin-page/empty/index.spec.tsx @@ -0,0 +1,583 @@ +import type { FilterState } from '../filter-management' +import type { SystemFeatures } from '@/types/feature' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defaultSystemFeatures, InstallationScope } from '@/types/feature' + +// ==================== Imports (after mocks) ==================== + +import Empty from './index' + +// ==================== Mock Setup ==================== + +// Use vi.hoisted to define ALL mock state and functions +const { + mockSetActiveTab, + mockUseInstalledPluginList, + mockState, + stableT, +} = vi.hoisted(() => { + const state = { + filters: { + categories: [] as string[], + tags: [] as string[], + searchQuery: '', + } as FilterState, + systemFeatures: { + enable_marketplace: true, + plugin_installation_permission: { + plugin_installation_scope: 'all' as const, + restrict_to_marketplace_only: false, + }, + } as Partial, + pluginList: { plugins: [] as Array<{ id: string }> } as { plugins: Array<{ id: string }> } | undefined, + } + // Stable t function to prevent infinite re-renders + // The component's useEffect and useMemo depend on t + const t = (key: string) => key + return { + mockSetActiveTab: vi.fn(), + mockUseInstalledPluginList: vi.fn(() => ({ data: state.pluginList })), + mockState: state, + stableT: t, + } +}) + +// Mock plugin page context +vi.mock('../context', () => ({ + usePluginPageContext: (selector: (value: any) => any) => { + const contextValue = { + filters: mockState.filters, + setActiveTab: mockSetActiveTab, + } + return selector(contextValue) + }, +})) + +// Mock global public store (Zustand store) +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: any) => any) => { + return selector({ + systemFeatures: { + ...defaultSystemFeatures, + ...mockState.systemFeatures, + }, + }) + }, +})) + +// Mock useInstalledPluginList hook +vi.mock('@/service/use-plugins', () => ({ + useInstalledPluginList: () => mockUseInstalledPluginList(), +})) + +// Mock InstallFromGitHub component +vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({ + default: ({ onClose }: { onSuccess: () => void, onClose: () => void }) => ( +
+ + +
+ ), +})) + +// Mock InstallFromLocalPackage component +vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () => ({ + default: ({ file, onClose }: { file: File, onSuccess: () => void, onClose: () => void }) => ( +
+ + +
+ ), +})) + +// Mock Line component +vi.mock('../../marketplace/empty/line', () => ({ + default: ({ className }: { className?: string }) =>
, +})) + +// Override react-i18next with stable t function reference to prevent infinite re-renders +// The component's useEffect and useMemo depend on t, so it MUST be stable +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: stableT, + i18n: { + language: 'en', + changeLanguage: vi.fn(), + }, + }), +})) + +// ==================== Test Utilities ==================== + +const resetMockState = () => { + mockState.filters = { categories: [], tags: [], searchQuery: '' } + mockState.systemFeatures = { + enable_marketplace: true, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: false, + }, + } + mockState.pluginList = { plugins: [] } + mockUseInstalledPluginList.mockReturnValue({ data: mockState.pluginList }) +} + +const setMockFilters = (filters: Partial) => { + mockState.filters = { ...mockState.filters, ...filters } +} + +const setMockSystemFeatures = (features: Partial) => { + mockState.systemFeatures = { ...mockState.systemFeatures, ...features } +} + +const setMockPluginList = (list: { plugins: Array<{ id: string }> } | undefined) => { + mockState.pluginList = list + mockUseInstalledPluginList.mockReturnValue({ data: list }) +} + +const createMockFile = (name: string, type = 'application/octet-stream'): File => { + return new File(['test'], name, { type }) +} + +// Helper to wait for useEffect to complete (single tick) +const flushEffects = async () => { + await act(async () => {}) +} + +// ==================== Tests ==================== + +describe('Empty Component', () => { + beforeEach(() => { + vi.clearAllMocks() + resetMockState() + }) + + // ==================== Rendering Tests ==================== + describe('Rendering', () => { + it('should render basic structure correctly', async () => { + // Arrange & Act + const { container } = render() + await flushEffects() + + // Assert - file input + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + expect(fileInput).toBeInTheDocument() + expect(fileInput.style.display).toBe('none') + expect(fileInput.accept).toBe('.difypkg,.difybndl') + + // Assert - skeleton cards (20 in the grid + 1 icon container) + const skeletonCards = container.querySelectorAll('.rounded-xl.bg-components-card-bg') + expect(skeletonCards.length).toBeGreaterThanOrEqual(20) + + // Assert - group icon container + const iconContainer = document.querySelector('.size-14') + expect(iconContainer).toBeInTheDocument() + + // Assert - line components + const lines = screen.getAllByTestId('line-component') + expect(lines).toHaveLength(4) + }) + }) + + // ==================== Text Display Tests (useMemo) ==================== + describe('Text Display (useMemo)', () => { + it('should display "noInstalled" text when plugin list is empty', async () => { + // Arrange + setMockPluginList({ plugins: [] }) + + // Act + render() + await flushEffects() + + // Assert + expect(screen.getByText('list.noInstalled')).toBeInTheDocument() + }) + + it('should display "notFound" text when filters are active with plugins', async () => { + // Arrange + setMockPluginList({ plugins: [{ id: 'plugin-1' }] }) + + // Test categories filter + setMockFilters({ categories: ['model'] }) + const { rerender } = render() + await flushEffects() + expect(screen.getByText('list.notFound')).toBeInTheDocument() + + // Test tags filter + setMockFilters({ categories: [], tags: ['tag1'] }) + rerender() + await flushEffects() + expect(screen.getByText('list.notFound')).toBeInTheDocument() + + // Test searchQuery filter + setMockFilters({ tags: [], searchQuery: 'test query' }) + rerender() + await flushEffects() + expect(screen.getByText('list.notFound')).toBeInTheDocument() + }) + + it('should prioritize "noInstalled" over "notFound" when no plugins exist', async () => { + // Arrange + setMockFilters({ categories: ['model'], searchQuery: 'test' }) + setMockPluginList({ plugins: [] }) + + // Act + render() + await flushEffects() + + // Assert + expect(screen.getByText('list.noInstalled')).toBeInTheDocument() + }) + }) + + // ==================== Install Methods Tests (useEffect) ==================== + describe('Install Methods (useEffect)', () => { + it('should render all three install methods when marketplace enabled and not restricted', async () => { + // Arrange + setMockSystemFeatures({ + enable_marketplace: true, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: false, + }, + }) + + // Act + render() + await flushEffects() + + // Assert + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(3) + expect(screen.getByText('source.marketplace')).toBeInTheDocument() + expect(screen.getByText('source.github')).toBeInTheDocument() + expect(screen.getByText('source.local')).toBeInTheDocument() + + // Verify button order + const buttonTexts = buttons.map(btn => btn.textContent) + expect(buttonTexts[0]).toContain('source.marketplace') + expect(buttonTexts[1]).toContain('source.github') + expect(buttonTexts[2]).toContain('source.local') + }) + + it('should render only marketplace method when restricted to marketplace only', async () => { + // Arrange + setMockSystemFeatures({ + enable_marketplace: true, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: true, + }, + }) + + // Act + render() + await flushEffects() + + // Assert + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(1) + expect(screen.getByText('source.marketplace')).toBeInTheDocument() + expect(screen.queryByText('source.github')).not.toBeInTheDocument() + expect(screen.queryByText('source.local')).not.toBeInTheDocument() + }) + + it('should render github and local methods when marketplace is disabled', async () => { + // Arrange + setMockSystemFeatures({ + enable_marketplace: false, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: false, + }, + }) + + // Act + render() + await flushEffects() + + // Assert + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(2) + expect(screen.queryByText('source.marketplace')).not.toBeInTheDocument() + expect(screen.getByText('source.github')).toBeInTheDocument() + expect(screen.getByText('source.local')).toBeInTheDocument() + }) + + it('should render no methods when marketplace disabled and restricted', async () => { + // Arrange + setMockSystemFeatures({ + enable_marketplace: false, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: true, + }, + }) + + // Act + render() + await flushEffects() + + // Assert + const buttons = screen.queryAllByRole('button') + expect(buttons).toHaveLength(0) + }) + }) + + // ==================== User Interactions Tests ==================== + describe('User Interactions', () => { + it('should call setActiveTab with "discover" when marketplace button is clicked', async () => { + // Arrange + render() + await flushEffects() + + // Act + fireEvent.click(screen.getByText('source.marketplace')) + + // Assert + expect(mockSetActiveTab).toHaveBeenCalledWith('discover') + }) + + it('should open and close GitHub modal correctly', async () => { + // Arrange + render() + await flushEffects() + + // Assert - initially no modal + expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument() + + // Act - open modal + fireEvent.click(screen.getByText('source.github')) + + // Assert - modal is open + expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() + + // Act - close modal + fireEvent.click(screen.getByTestId('github-modal-close')) + + // Assert - modal is closed + expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument() + }) + + it('should trigger file input click when local button is clicked', async () => { + // Arrange + render() + await flushEffects() + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + const clickSpy = vi.spyOn(fileInput, 'click') + + // Act + fireEvent.click(screen.getByText('source.local')) + + // Assert + expect(clickSpy).toHaveBeenCalled() + }) + + it('should open and close local modal when file is selected', async () => { + // Arrange + render() + await flushEffects() + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + const mockFile = createMockFile('test-plugin.difypkg') + + // Assert - initially no modal + expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + + // Act - select file + Object.defineProperty(fileInput, 'files', { value: [mockFile], writable: true }) + fireEvent.change(fileInput) + + // Assert - modal is open with correct file + expect(screen.getByTestId('install-from-local-modal')).toBeInTheDocument() + expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'test-plugin.difypkg') + + // Act - close modal + fireEvent.click(screen.getByTestId('local-modal-close')) + + // Assert - modal is closed + expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + }) + + it('should not open local modal when no file is selected', async () => { + // Arrange + render() + await flushEffects() + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + + // Act - trigger change with empty files + Object.defineProperty(fileInput, 'files', { value: [], writable: true }) + fireEvent.change(fileInput) + + // Assert + expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + }) + }) + + // ==================== State Management Tests ==================== + describe('State Management', () => { + it('should maintain modal state correctly and allow reopening', async () => { + // Arrange + render() + await flushEffects() + + // Act - Open, close, and reopen GitHub modal + fireEvent.click(screen.getByText('source.github')) + expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('github-modal-close')) + expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('source.github')) + expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() + }) + + it('should update selectedFile state when file is selected', async () => { + // Arrange + render() + await flushEffects() + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + + // Act - select .difypkg file + Object.defineProperty(fileInput, 'files', { value: [createMockFile('my-plugin.difypkg')], writable: true }) + fireEvent.change(fileInput) + expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'my-plugin.difypkg') + + // Close and select .difybndl file + fireEvent.click(screen.getByTestId('local-modal-close')) + Object.defineProperty(fileInput, 'files', { value: [createMockFile('test-bundle.difybndl')], writable: true }) + fireEvent.change(fileInput) + expect(screen.getByTestId('install-from-local-modal')).toHaveAttribute('data-file-name', 'test-bundle.difybndl') + }) + }) + + // ==================== Side Effects Tests ==================== + describe('Side Effects', () => { + it('should render correct install methods based on system features', async () => { + // Test 1: All methods when marketplace enabled and not restricted + setMockSystemFeatures({ + enable_marketplace: true, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: false, + }, + }) + + const { unmount: unmount1 } = render() + await flushEffects() + expect(screen.getAllByRole('button')).toHaveLength(3) + unmount1() + + // Test 2: Only marketplace when restricted + setMockSystemFeatures({ + enable_marketplace: true, + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: true, + }, + }) + + render() + await flushEffects() + expect(screen.getAllByRole('button')).toHaveLength(1) + expect(screen.getByText('source.marketplace')).toBeInTheDocument() + }) + + it('should render correct text based on plugin list and filters', async () => { + // Test 1: noInstalled when plugin list is empty + setMockPluginList({ plugins: [] }) + setMockFilters({ categories: [], tags: [], searchQuery: '' }) + + const { unmount: unmount1 } = render() + await flushEffects() + expect(screen.getByText('list.noInstalled')).toBeInTheDocument() + unmount1() + + // Test 2: notFound when filters are active with plugins + setMockFilters({ categories: ['tool'] }) + setMockPluginList({ plugins: [{ id: 'plugin-1' }] }) + + render() + await flushEffects() + expect(screen.getByText('list.notFound')).toBeInTheDocument() + }) + }) + + // ==================== Edge Cases ==================== + describe('Edge Cases', () => { + it('should handle undefined plugin data gracefully', () => { + // Test undefined plugin list - component should render without error + setMockPluginList(undefined) + expect(() => render()).not.toThrow() + }) + + it('should handle file input edge cases', async () => { + // Arrange + render() + await flushEffects() + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + + // Test undefined files + Object.defineProperty(fileInput, 'files', { value: undefined, writable: true }) + fireEvent.change(fileInput) + expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + }) + }) + + // ==================== React.memo Tests ==================== + describe('React.memo Behavior', () => { + it('should be wrapped with React.memo and have displayName', () => { + // Assert + expect(Empty).toBeDefined() + expect((Empty as any).$$typeof?.toString()).toContain('Symbol') + expect((Empty as any).displayName || (Empty as any).type?.displayName).toBeDefined() + }) + }) + + // ==================== Modal Callbacks Tests ==================== + describe('Modal Callbacks', () => { + it('should handle modal onSuccess callbacks (noop)', async () => { + // Arrange + render() + await flushEffects() + + // Test GitHub modal onSuccess + fireEvent.click(screen.getByText('source.github')) + fireEvent.click(screen.getByTestId('github-modal-success')) + expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() + + // Close GitHub modal and test Local modal onSuccess + fireEvent.click(screen.getByTestId('github-modal-close')) + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement + Object.defineProperty(fileInput, 'files', { value: [createMockFile('test-plugin.difypkg')], writable: true }) + fireEvent.change(fileInput) + + fireEvent.click(screen.getByTestId('local-modal-success')) + expect(screen.getByTestId('install-from-local-modal')).toBeInTheDocument() + }) + }) + + // ==================== Conditional Modal Rendering ==================== + describe('Conditional Modal Rendering', () => { + it('should only render one modal at a time and require file for local modal', async () => { + // Arrange + render() + await flushEffects() + + // Assert - no modals initially + expect(screen.queryByTestId('install-from-github-modal')).not.toBeInTheDocument() + expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + + // Open GitHub modal - only GitHub modal visible + fireEvent.click(screen.getByText('source.github')) + expect(screen.getByTestId('install-from-github-modal')).toBeInTheDocument() + expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + + // Click local button - triggers file input, no modal yet (no file selected) + fireEvent.click(screen.getByText('source.local')) + // GitHub modal should still be visible, local modal requires file selection + expect(screen.queryByTestId('install-from-local-modal')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/plugins/plugin-page/filter-management/index.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/index.spec.tsx new file mode 100644 index 0000000000..58474b4723 --- /dev/null +++ b/web/app/components/plugins/plugin-page/filter-management/index.spec.tsx @@ -0,0 +1,1175 @@ +import type { Category, Tag } from './constant' +import type { FilterState } from './index' +import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// ==================== Imports (after mocks) ==================== + +import CategoriesFilter from './category-filter' +// Import real components +import FilterManagement from './index' +import SearchBox from './search-box' +import { useStore } from './store' +import TagFilter from './tag-filter' + +// ==================== Mock Setup ==================== + +// Mock initial filters from context +let mockInitFilters: FilterState = { + categories: [], + tags: [], + searchQuery: '', +} + +vi.mock('../context', () => ({ + usePluginPageContext: (selector: (v: { filters: FilterState }) => FilterState) => + selector({ filters: mockInitFilters }), +})) + +// Mock categories data +const mockCategories = [ + { name: 'model', label: 'Models' }, + { name: 'tool', label: 'Tools' }, + { name: 'extension', label: 'Extensions' }, + { name: 'agent', label: 'Agents' }, +] + +const mockCategoriesMap: Record = { + model: { name: 'model', label: 'Models' }, + tool: { name: 'tool', label: 'Tools' }, + extension: { name: 'extension', label: 'Extensions' }, + agent: { name: 'agent', label: 'Agents' }, +} + +// Mock tags data +const mockTags = [ + { name: 'agent', label: 'Agent' }, + { name: 'rag', label: 'RAG' }, + { name: 'search', label: 'Search' }, + { name: 'image', label: 'Image' }, +] + +const mockTagsMap: Record = { + agent: { name: 'agent', label: 'Agent' }, + rag: { name: 'rag', label: 'RAG' }, + search: { name: 'search', label: 'Search' }, + image: { name: 'image', label: 'Image' }, +} + +vi.mock('../../hooks', () => ({ + useCategories: () => ({ + categories: mockCategories, + categoriesMap: mockCategoriesMap, + }), + useTags: () => ({ + tags: mockTags, + tagsMap: mockTagsMap, + getTagLabel: (name: string) => mockTagsMap[name]?.label || name, + }), +})) + +// Track portal open state for testing +let mockPortalOpenState = false + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => { + mockPortalOpenState = open + return
{children}
+ }, + PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( +
{children}
+ ), + PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => { + if (!mockPortalOpenState) + return null + return
{children}
+ }, +})) + +// ==================== Test Utilities ==================== + +const createFilterState = (overrides: Partial = {}): FilterState => ({ + categories: [], + tags: [], + searchQuery: '', + ...overrides, +}) + +const renderFilterManagement = (onFilterChange = vi.fn()) => { + const result = render() + return { ...result, onFilterChange } +} + +// ==================== constant.ts Tests ==================== +describe('constant.ts - Type Definitions', () => { + it('should define Tag type correctly', () => { + // Arrange + const tag: Tag = { + id: 'test-id', + name: 'test-tag', + type: 'custom', + binding_count: 5, + } + + // Assert + expect(tag.id).toBe('test-id') + expect(tag.name).toBe('test-tag') + expect(tag.type).toBe('custom') + expect(tag.binding_count).toBe(5) + }) + + it('should define Category type correctly', () => { + // Arrange + const category: Category = { + name: 'model', + binding_count: 10, + } + + // Assert + expect(category.name).toBe('model') + expect(category.binding_count).toBe(10) + }) + + it('should enforce Category name as specific union type', () => { + // Arrange - Valid category names + const validNames: Array = ['model', 'tool', 'extension', 'bundle'] + + // Assert + validNames.forEach((name) => { + const category: Category = { name, binding_count: 0 } + expect(['model', 'tool', 'extension', 'bundle']).toContain(category.name) + }) + }) +}) + +// ==================== store.ts Tests ==================== +describe('store.ts - Zustand Store', () => { + beforeEach(() => { + // Reset store to initial state + const { setState } = useStore + setState({ + tagList: [], + categoryList: [], + showTagManagementModal: false, + showCategoryManagementModal: false, + }) + }) + + describe('Initial State', () => { + it('should have empty tagList initially', () => { + const { result } = renderHook(() => useStore(state => state.tagList)) + expect(result.current).toEqual([]) + }) + + it('should have empty categoryList initially', () => { + const { result } = renderHook(() => useStore(state => state.categoryList)) + expect(result.current).toEqual([]) + }) + + it('should have showTagManagementModal false initially', () => { + const { result } = renderHook(() => useStore(state => state.showTagManagementModal)) + expect(result.current).toBe(false) + }) + + it('should have showCategoryManagementModal false initially', () => { + const { result } = renderHook(() => useStore(state => state.showCategoryManagementModal)) + expect(result.current).toBe(false) + }) + }) + + describe('setTagList', () => { + it('should update tagList', () => { + // Arrange + const mockTagList: Tag[] = [ + { id: '1', name: 'tag1', type: 'custom', binding_count: 1 }, + { id: '2', name: 'tag2', type: 'custom', binding_count: 2 }, + ] + + // Act + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setTagList(mockTagList) + }) + + // Assert + expect(result.current.tagList).toEqual(mockTagList) + }) + + it('should handle undefined tagList', () => { + // Arrange & Act + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setTagList(undefined) + }) + + // Assert + expect(result.current.tagList).toBeUndefined() + }) + + it('should handle empty tagList', () => { + // Arrange + const { result } = renderHook(() => useStore()) + + // First set some tags + act(() => { + result.current.setTagList([{ id: '1', name: 'tag1', type: 'custom', binding_count: 1 }]) + }) + + // Act - Clear the list + act(() => { + result.current.setTagList([]) + }) + + // Assert + expect(result.current.tagList).toEqual([]) + }) + }) + + describe('setCategoryList', () => { + it('should update categoryList', () => { + // Arrange + const mockCategoryList: Category[] = [ + { name: 'model', binding_count: 5 }, + { name: 'tool', binding_count: 10 }, + ] + + // Act + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setCategoryList(mockCategoryList) + }) + + // Assert + expect(result.current.categoryList).toEqual(mockCategoryList) + }) + + it('should handle undefined categoryList', () => { + // Arrange & Act + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setCategoryList(undefined) + }) + + // Assert + expect(result.current.categoryList).toBeUndefined() + }) + }) + + describe('setShowTagManagementModal', () => { + it('should set showTagManagementModal to true', () => { + // Arrange & Act + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setShowTagManagementModal(true) + }) + + // Assert + expect(result.current.showTagManagementModal).toBe(true) + }) + + it('should set showTagManagementModal to false', () => { + // Arrange + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setShowTagManagementModal(true) + }) + + // Act + act(() => { + result.current.setShowTagManagementModal(false) + }) + + // Assert + expect(result.current.showTagManagementModal).toBe(false) + }) + }) + + describe('setShowCategoryManagementModal', () => { + it('should set showCategoryManagementModal to true', () => { + // Arrange & Act + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setShowCategoryManagementModal(true) + }) + + // Assert + expect(result.current.showCategoryManagementModal).toBe(true) + }) + + it('should set showCategoryManagementModal to false', () => { + // Arrange + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setShowCategoryManagementModal(true) + }) + + // Act + act(() => { + result.current.setShowCategoryManagementModal(false) + }) + + // Assert + expect(result.current.showCategoryManagementModal).toBe(false) + }) + }) + + describe('Store Isolation', () => { + it('should maintain separate state for each property', () => { + // Arrange + const mockTagList: Tag[] = [{ id: '1', name: 'tag1', type: 'custom', binding_count: 1 }] + const mockCategoryList: Category[] = [{ name: 'model', binding_count: 5 }] + + // Act + const { result } = renderHook(() => useStore()) + act(() => { + result.current.setTagList(mockTagList) + result.current.setCategoryList(mockCategoryList) + result.current.setShowTagManagementModal(true) + result.current.setShowCategoryManagementModal(false) + }) + + // Assert - All states are independent + expect(result.current.tagList).toEqual(mockTagList) + expect(result.current.categoryList).toEqual(mockCategoryList) + expect(result.current.showTagManagementModal).toBe(true) + expect(result.current.showCategoryManagementModal).toBe(false) + }) + }) +}) + +// ==================== search-box.tsx Tests ==================== +describe('SearchBox Component', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render input with correct placeholder', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByPlaceholderText('plugin.search')).toBeInTheDocument() + }) + + it('should render with provided searchQuery value', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByDisplayValue('test query')).toBeInTheDocument() + }) + + it('should render search icon', () => { + // Arrange & Act + const { container } = render() + + // Assert - Input should have showLeftIcon which renders search icon + const wrapper = container.querySelector('.w-\\[200px\\]') + expect(wrapper).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onChange when input value changes', () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.change(screen.getByPlaceholderText('plugin.search'), { + target: { value: 'new search' }, + }) + + // Assert + expect(handleChange).toHaveBeenCalledWith('new search') + }) + + it('should call onChange with empty string when cleared', () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.change(screen.getByDisplayValue('existing'), { + target: { value: '' }, + }) + + // Assert + expect(handleChange).toHaveBeenCalledWith('') + }) + + it('should handle rapid typing', () => { + // Arrange + const handleChange = vi.fn() + render() + const input = screen.getByPlaceholderText('plugin.search') + + // Act + fireEvent.change(input, { target: { value: 'a' } }) + fireEvent.change(input, { target: { value: 'ab' } }) + fireEvent.change(input, { target: { value: 'abc' } }) + + // Assert + expect(handleChange).toHaveBeenCalledTimes(3) + expect(handleChange).toHaveBeenLastCalledWith('abc') + }) + }) + + describe('Edge Cases', () => { + it('should handle special characters', () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.change(screen.getByPlaceholderText('plugin.search'), { + target: { value: '!@#$%^&*()' }, + }) + + // Assert + expect(handleChange).toHaveBeenCalledWith('!@#$%^&*()') + }) + + it('should handle unicode characters', () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.change(screen.getByPlaceholderText('plugin.search'), { + target: { value: 'δΈ­ζ–‡ζœη΄’ πŸ”' }, + }) + + // Assert + expect(handleChange).toHaveBeenCalledWith('δΈ­ζ–‡ζœη΄’ πŸ”') + }) + + it('should handle very long input', () => { + // Arrange + const handleChange = vi.fn() + const longText = 'a'.repeat(500) + render() + + // Act + fireEvent.change(screen.getByPlaceholderText('plugin.search'), { + target: { value: longText }, + }) + + // Assert + expect(handleChange).toHaveBeenCalledWith(longText) + }) + }) +}) + +// ==================== category-filter.tsx Tests ==================== +describe('CategoriesFilter Component', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPortalOpenState = false + }) + + describe('Rendering', () => { + it('should render with "All Categories" text when no selection', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('plugin.allCategories')).toBeInTheDocument() + }) + + it('should render dropdown arrow when no selection', () => { + // Arrange & Act + const { container } = render() + + // Assert - Arrow icon should be visible + const arrowIcon = container.querySelector('svg') + expect(arrowIcon).toBeInTheDocument() + }) + + it('should render selected category labels', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('Models')).toBeInTheDocument() + }) + + it('should show clear button when categories are selected', () => { + // Arrange & Act + const { container } = render() + + // Assert - Close icon should be visible + const closeIcon = container.querySelector('[class*="cursor-pointer"]') + expect(closeIcon).toBeInTheDocument() + }) + + it('should show count badge for more than 2 selections', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('+1')).toBeInTheDocument() + }) + }) + + describe('Dropdown Behavior', () => { + it('should open dropdown on trigger click', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + }) + + it('should display category options in dropdown', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + expect(screen.getByText('Models')).toBeInTheDocument() + expect(screen.getByText('Tools')).toBeInTheDocument() + expect(screen.getByText('Extensions')).toBeInTheDocument() + expect(screen.getByText('Agents')).toBeInTheDocument() + }) + }) + + it('should have search input in dropdown', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + expect(screen.getByPlaceholderText('plugin.searchCategories')).toBeInTheDocument() + }) + }) + }) + + describe('Selection Behavior', () => { + it('should call onChange when category is selected', async () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act - Open dropdown and click category + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + expect(screen.getByText('Models')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Models')) + + // Assert + expect(handleChange).toHaveBeenCalledWith(['model']) + }) + + it('should deselect when clicking selected category', async () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + // Multiple "Models" texts exist - one in trigger, one in dropdown + const allModels = screen.getAllByText('Models') + expect(allModels.length).toBeGreaterThan(1) + }) + // Click the one in the dropdown (inside portal-content) + const portalContent = screen.getByTestId('portal-content') + const modelsInDropdown = portalContent.querySelector('.system-sm-medium')! + fireEvent.click(modelsInDropdown.parentElement!) + + // Assert + expect(handleChange).toHaveBeenCalledWith([]) + }) + + it('should add to selection when clicking unselected category', async () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + expect(screen.getByText('Tools')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Tools')) + + // Assert + expect(handleChange).toHaveBeenCalledWith(['model', 'tool']) + }) + + it('should clear all selections when clear button is clicked', () => { + // Arrange + const handleChange = vi.fn() + const { container } = render() + + // Act - Find and click the close icon + const closeIcon = container.querySelector('.text-text-quaternary') + expect(closeIcon).toBeInTheDocument() + fireEvent.click(closeIcon!) + + // Assert + expect(handleChange).toHaveBeenCalledWith([]) + }) + }) + + describe('Search Functionality', () => { + it('should filter categories based on search text', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + expect(screen.getByPlaceholderText('plugin.searchCategories')).toBeInTheDocument() + }) + fireEvent.change(screen.getByPlaceholderText('plugin.searchCategories'), { + target: { value: 'mod' }, + }) + + // Assert + expect(screen.getByText('Models')).toBeInTheDocument() + expect(screen.queryByText('Extensions')).not.toBeInTheDocument() + }) + + it('should be case insensitive', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + expect(screen.getByPlaceholderText('plugin.searchCategories')).toBeInTheDocument() + }) + fireEvent.change(screen.getByPlaceholderText('plugin.searchCategories'), { + target: { value: 'MOD' }, + }) + + // Assert + expect(screen.getByText('Models')).toBeInTheDocument() + }) + }) + + describe('Checkbox State', () => { + it('should show checked checkbox for selected categories', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert - Check icon appears for checked state + await waitFor(() => { + const checkIcons = screen.getAllByTestId(/check-icon/) + expect(checkIcons.length).toBeGreaterThan(0) + }) + }) + + it('should show unchecked checkbox for unselected categories', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert - No check icon for unchecked state + await waitFor(() => { + const checkIcons = screen.queryAllByTestId(/check-icon/) + expect(checkIcons.length).toBe(0) + }) + }) + }) +}) + +// ==================== tag-filter.tsx Tests ==================== +describe('TagFilter Component', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPortalOpenState = false + }) + + describe('Rendering', () => { + it('should render with "All Tags" text when no selection', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument() + }) + + it('should render selected tag labels', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + + it('should show count badge for more than 2 selections', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('+1')).toBeInTheDocument() + }) + + it('should show clear button when tags are selected', () => { + // Arrange & Act + const { container } = render() + + // Assert + const closeIcon = container.querySelector('.text-text-quaternary') + expect(closeIcon).toBeInTheDocument() + }) + }) + + describe('Dropdown Behavior', () => { + it('should open dropdown on trigger click', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + }) + + it('should display tag options in dropdown', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + + // Assert + await waitFor(() => { + expect(screen.getByText('Agent')).toBeInTheDocument() + expect(screen.getByText('RAG')).toBeInTheDocument() + expect(screen.getByText('Search')).toBeInTheDocument() + expect(screen.getByText('Image')).toBeInTheDocument() + }) + }) + }) + + describe('Selection Behavior', () => { + it('should call onChange when tag is selected', async () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Agent')) + + // Assert + expect(handleChange).toHaveBeenCalledWith(['agent']) + }) + + it('should deselect when clicking selected tag', async () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + // Find the Agent option in dropdown + const agentOptions = screen.getAllByText('Agent') + fireEvent.click(agentOptions[agentOptions.length - 1]) + }) + + // Assert + expect(handleChange).toHaveBeenCalledWith([]) + }) + + it('should add to selection when clicking unselected tag', async () => { + // Arrange + const handleChange = vi.fn() + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + expect(screen.getByText('RAG')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('RAG')) + + // Assert + expect(handleChange).toHaveBeenCalledWith(['agent', 'rag']) + }) + + it('should clear all selections when clear button is clicked', () => { + // Arrange + const handleChange = vi.fn() + const { container } = render() + + // Act + const closeIcon = container.querySelector('.text-text-quaternary') + fireEvent.click(closeIcon!) + + // Assert + expect(handleChange).toHaveBeenCalledWith([]) + }) + }) + + describe('Search Functionality', () => { + it('should filter tags based on search text', async () => { + // Arrange + render() + + // Act + fireEvent.click(screen.getByTestId('portal-trigger')) + await waitFor(() => { + expect(screen.getByPlaceholderText('pluginTags.searchTags')).toBeInTheDocument() + }) + fireEvent.change(screen.getByPlaceholderText('pluginTags.searchTags'), { + target: { value: 'rag' }, + }) + + // Assert + expect(screen.getByText('RAG')).toBeInTheDocument() + expect(screen.queryByText('Image')).not.toBeInTheDocument() + }) + }) +}) + +// ==================== index.tsx (FilterManagement) Tests ==================== +describe('FilterManagement Component', () => { + beforeEach(() => { + vi.clearAllMocks() + mockInitFilters = createFilterState() + mockPortalOpenState = false + }) + + describe('Rendering', () => { + it('should render all filter components', () => { + // Arrange & Act + renderFilterManagement() + + // Assert - All three filters should be present + expect(screen.getByText('plugin.allCategories')).toBeInTheDocument() + expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument() + expect(screen.getByPlaceholderText('plugin.search')).toBeInTheDocument() + }) + + it('should render with correct container classes', () => { + // Arrange & Act + const { container } = renderFilterManagement() + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('flex', 'items-center', 'gap-2', 'self-stretch') + }) + }) + + describe('Initial State from Context', () => { + it('should initialize with empty filters', () => { + // Arrange + mockInitFilters = createFilterState() + + // Act + renderFilterManagement() + + // Assert + expect(screen.getByText('plugin.allCategories')).toBeInTheDocument() + expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument() + expect(screen.getByPlaceholderText('plugin.search')).toHaveValue('') + }) + + it('should initialize with pre-selected categories', () => { + // Arrange + mockInitFilters = createFilterState({ categories: ['model'] }) + + // Act + renderFilterManagement() + + // Assert + expect(screen.getByText('Models')).toBeInTheDocument() + }) + + it('should initialize with pre-selected tags', () => { + // Arrange + mockInitFilters = createFilterState({ tags: ['agent'] }) + + // Act + renderFilterManagement() + + // Assert + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + + it('should initialize with search query', () => { + // Arrange + mockInitFilters = createFilterState({ searchQuery: 'initial search' }) + + // Act + renderFilterManagement() + + // Assert + expect(screen.getByDisplayValue('initial search')).toBeInTheDocument() + }) + }) + + describe('Filter Interactions', () => { + it('should call onFilterChange when category is selected', async () => { + // Arrange + const onFilterChange = vi.fn() + render() + + // Act - Open categories dropdown and select + const triggers = screen.getAllByTestId('portal-trigger') + fireEvent.click(triggers[0]) // Categories filter trigger + + await waitFor(() => { + expect(screen.getByText('Models')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Models')) + + // Assert + expect(onFilterChange).toHaveBeenCalledWith({ + categories: ['model'], + tags: [], + searchQuery: '', + }) + }) + + it('should call onFilterChange when tag is selected', async () => { + // Arrange + const onFilterChange = vi.fn() + render() + + // Act - Open tags dropdown and select + const triggers = screen.getAllByTestId('portal-trigger') + fireEvent.click(triggers[1]) // Tags filter trigger + + await waitFor(() => { + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Agent')) + + // Assert + expect(onFilterChange).toHaveBeenCalledWith({ + categories: [], + tags: ['agent'], + searchQuery: '', + }) + }) + + it('should call onFilterChange when search query changes', () => { + // Arrange + const onFilterChange = vi.fn() + render() + + // Act + fireEvent.change(screen.getByPlaceholderText('plugin.search'), { + target: { value: 'test query' }, + }) + + // Assert + expect(onFilterChange).toHaveBeenCalledWith({ + categories: [], + tags: [], + searchQuery: 'test query', + }) + }) + }) + + describe('State Management', () => { + it('should accumulate filter changes', async () => { + // Arrange + const onFilterChange = vi.fn() + render() + + // Act 1 - Select a category + const triggers = screen.getAllByTestId('portal-trigger') + fireEvent.click(triggers[0]) + await waitFor(() => { + expect(screen.getByText('Models')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Models')) + + expect(onFilterChange).toHaveBeenLastCalledWith({ + categories: ['model'], + tags: [], + searchQuery: '', + }) + + // Close dropdown by clicking trigger again + fireEvent.click(triggers[0]) + + // Act 2 - Select a tag (state should include previous category) + fireEvent.click(triggers[1]) + await waitFor(() => { + expect(screen.getByText('Agent')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Agent')) + + // Assert - Both category and tag should be in the state + expect(onFilterChange).toHaveBeenLastCalledWith({ + categories: ['model'], + tags: ['agent'], + searchQuery: '', + }) + }) + + it('should preserve other filters when updating one', () => { + // Arrange + mockInitFilters = createFilterState({ + categories: ['model'], + tags: ['agent'], + }) + const onFilterChange = vi.fn() + render() + + // Act - Change only search query + fireEvent.change(screen.getByPlaceholderText('plugin.search'), { + target: { value: 'new search' }, + }) + + // Assert - Other filters should be preserved + expect(onFilterChange).toHaveBeenCalledWith({ + categories: ['model'], + tags: ['agent'], + searchQuery: 'new search', + }) + }) + }) + + describe('Integration Tests', () => { + it('should handle complete filter workflow', async () => { + // Arrange + const onFilterChange = vi.fn() + render() + + // Act 1 - Select categories + const triggers = screen.getAllByTestId('portal-trigger') + fireEvent.click(triggers[0]) + await waitFor(() => { + expect(screen.getByText('Models')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('Models')) + fireEvent.click(triggers[0]) // Close + + // Act 2 - Select tags + fireEvent.click(triggers[1]) + await waitFor(() => { + expect(screen.getByText('RAG')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('RAG')) + fireEvent.click(triggers[1]) // Close + + // Act 3 - Enter search + fireEvent.change(screen.getByPlaceholderText('plugin.search'), { + target: { value: 'gpt' }, + }) + + // Assert - Final state should include all filters + expect(onFilterChange).toHaveBeenLastCalledWith({ + categories: ['model'], + tags: ['rag'], + searchQuery: 'gpt', + }) + }) + + it('should handle filter clearing', async () => { + // Arrange + mockInitFilters = createFilterState({ + categories: ['model'], + tags: ['agent'], + searchQuery: 'test', + }) + const onFilterChange = vi.fn() + const { container } = render() + + // Act - Clear search + fireEvent.change(screen.getByDisplayValue('test'), { + target: { value: '' }, + }) + + // Assert + expect(onFilterChange).toHaveBeenLastCalledWith({ + categories: ['model'], + tags: ['agent'], + searchQuery: '', + }) + + // Act - Clear categories (click clear button) + const closeIcons = container.querySelectorAll('.text-text-quaternary') + fireEvent.click(closeIcons[0]) // First close icon is for categories + + // Assert + expect(onFilterChange).toHaveBeenLastCalledWith({ + categories: [], + tags: ['agent'], + searchQuery: '', + }) + }) + }) + + describe('Edge Cases', () => { + it('should handle empty initial state', () => { + // Arrange + mockInitFilters = createFilterState() + const onFilterChange = vi.fn() + + // Act + render() + + // Assert - Should render without errors + expect(screen.getByText('plugin.allCategories')).toBeInTheDocument() + }) + + it('should handle multiple rapid filter changes', () => { + // Arrange + const onFilterChange = vi.fn() + render() + + // Act - Rapid search input changes + const searchInput = screen.getByPlaceholderText('plugin.search') + fireEvent.change(searchInput, { target: { value: 'a' } }) + fireEvent.change(searchInput, { target: { value: 'ab' } }) + fireEvent.change(searchInput, { target: { value: 'abc' } }) + + // Assert + expect(onFilterChange).toHaveBeenCalledTimes(3) + expect(onFilterChange).toHaveBeenLastCalledWith( + expect.objectContaining({ searchQuery: 'abc' }), + ) + }) + + it('should handle special characters in search', () => { + // Arrange + const onFilterChange = vi.fn() + render() + + // Act + fireEvent.change(screen.getByPlaceholderText('plugin.search'), { + target: { value: '!@#$%^&*()' }, + }) + + // Assert + expect(onFilterChange).toHaveBeenCalledWith( + expect.objectContaining({ searchQuery: '!@#$%^&*()' }), + ) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-page/list/index.spec.tsx b/web/app/components/plugins/plugin-page/list/index.spec.tsx new file mode 100644 index 0000000000..7709585e8e --- /dev/null +++ b/web/app/components/plugins/plugin-page/list/index.spec.tsx @@ -0,0 +1,702 @@ +import type { PluginDeclaration, PluginDetail } from '../../types' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum, PluginSource } from '../../types' + +// ==================== Imports (after mocks) ==================== + +import PluginList from './index' + +// ==================== Mock Setup ==================== + +// Mock PluginItem component to avoid complex dependency chain +vi.mock('../../plugin-item', () => ({ + default: ({ plugin }: { plugin: PluginDetail }) => ( +
+ {plugin.name} +
+ ), +})) + +// ==================== Test Utilities ==================== + +/** + * Factory function to create a PluginDeclaration with defaults + */ +const createPluginDeclaration = (overrides: Partial = {}): PluginDeclaration => ({ + plugin_unique_identifier: 'test-plugin-id', + version: '1.0.0', + author: 'test-author', + icon: 'test-icon.png', + icon_dark: 'test-icon-dark.png', + name: 'test-plugin', + category: PluginCategoryEnum.tool, + label: { en_US: 'Test Plugin' } as any, + description: { en_US: 'Test plugin description' } as any, + created_at: '2024-01-01', + resource: null, + plugins: null, + verified: false, + endpoint: {} as any, + model: null, + tags: [], + agent_strategy: null, + meta: { + version: '1.0.0', + minimum_dify_version: '0.5.0', + }, + trigger: {} as any, + ...overrides, +}) + +/** + * Factory function to create a PluginDetail with defaults + */ +const createPluginDetail = (overrides: Partial = {}): PluginDetail => ({ + id: 'plugin-1', + created_at: '2024-01-01', + updated_at: '2024-01-01', + name: 'test-plugin', + plugin_id: 'plugin-1', + plugin_unique_identifier: 'test-author/test-plugin@1.0.0', + declaration: createPluginDeclaration(), + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: 'test-author/test-plugin@1.0.0', + source: PluginSource.marketplace, + meta: { + repo: 'test-author/test-plugin', + version: '1.0.0', + package: 'test-plugin.difypkg', + }, + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', + ...overrides, +}) + +/** + * Factory function to create a list of plugins + */ +const createPluginList = (count: number, baseOverrides: Partial = {}): PluginDetail[] => { + return Array.from({ length: count }, (_, index) => createPluginDetail({ + id: `plugin-${index + 1}`, + plugin_id: `plugin-${index + 1}`, + name: `plugin-${index + 1}`, + plugin_unique_identifier: `test-author/plugin-${index + 1}@1.0.0`, + ...baseOverrides, + })) +} + +// ==================== Tests ==================== + +describe('PluginList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // ==================== Rendering Tests ==================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const pluginList: PluginDetail[] = [] + + // Act + const { container } = render() + + // Assert + expect(container).toBeInTheDocument() + }) + + it('should render container with correct structure', () => { + // Arrange + const pluginList: PluginDetail[] = [] + + // Act + const { container } = render() + + // Assert + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('pb-3') + + const gridDiv = outerDiv.firstChild as HTMLElement + expect(gridDiv).toHaveClass('grid', 'grid-cols-2', 'gap-3') + }) + + it('should render single plugin correctly', () => { + // Arrange + const pluginList = [createPluginDetail({ name: 'single-plugin' })] + + // Act + render() + + // Assert + const pluginItems = screen.getAllByTestId('plugin-item') + expect(pluginItems).toHaveLength(1) + expect(pluginItems[0]).toHaveAttribute('data-plugin-name', 'single-plugin') + }) + + it('should render multiple plugins correctly', () => { + // Arrange + const pluginList = createPluginList(5) + + // Act + render() + + // Assert + const pluginItems = screen.getAllByTestId('plugin-item') + expect(pluginItems).toHaveLength(5) + }) + + it('should render plugins in correct order', () => { + // Arrange + const pluginList = [ + createPluginDetail({ plugin_id: 'first', name: 'First Plugin' }), + createPluginDetail({ plugin_id: 'second', name: 'Second Plugin' }), + createPluginDetail({ plugin_id: 'third', name: 'Third Plugin' }), + ] + + // Act + render() + + // Assert + const pluginItems = screen.getAllByTestId('plugin-item') + expect(pluginItems[0]).toHaveAttribute('data-plugin-id', 'first') + expect(pluginItems[1]).toHaveAttribute('data-plugin-id', 'second') + expect(pluginItems[2]).toHaveAttribute('data-plugin-id', 'third') + }) + + it('should pass plugin prop to each PluginItem', () => { + // Arrange + const pluginList = [ + createPluginDetail({ plugin_id: 'plugin-a', name: 'Plugin A' }), + createPluginDetail({ plugin_id: 'plugin-b', name: 'Plugin B' }), + ] + + // Act + render() + + // Assert + expect(screen.getByText('Plugin A')).toBeInTheDocument() + expect(screen.getByText('Plugin B')).toBeInTheDocument() + }) + }) + + // ==================== Props Testing ==================== + describe('Props', () => { + it('should accept empty pluginList array', () => { + // Arrange & Act + const { container } = render() + + // Assert + const gridDiv = container.querySelector('.grid') + expect(gridDiv).toBeEmptyDOMElement() + }) + + it('should handle pluginList with various categories', () => { + // Arrange + const pluginList = [ + createPluginDetail({ + plugin_id: 'tool-plugin', + declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }), + }), + createPluginDetail({ + plugin_id: 'model-plugin', + declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }), + }), + createPluginDetail({ + plugin_id: 'extension-plugin', + declaration: createPluginDeclaration({ category: PluginCategoryEnum.extension }), + }), + ] + + // Act + render() + + // Assert + const pluginItems = screen.getAllByTestId('plugin-item') + expect(pluginItems).toHaveLength(3) + }) + + it('should handle pluginList with various sources', () => { + // Arrange + const pluginList = [ + createPluginDetail({ plugin_id: 'marketplace-plugin', source: PluginSource.marketplace }), + createPluginDetail({ plugin_id: 'github-plugin', source: PluginSource.github }), + createPluginDetail({ plugin_id: 'local-plugin', source: PluginSource.local }), + createPluginDetail({ plugin_id: 'debugging-plugin', source: PluginSource.debugging }), + ] + + // Act + render() + + // Assert + const pluginItems = screen.getAllByTestId('plugin-item') + expect(pluginItems).toHaveLength(4) + }) + }) + + // ==================== Edge Cases ==================== + describe('Edge Cases', () => { + it('should handle empty array', () => { + // Arrange & Act + render() + + // Assert + expect(screen.queryByTestId('plugin-item')).not.toBeInTheDocument() + }) + + it('should handle large number of plugins', () => { + // Arrange + const pluginList = createPluginList(100) + + // Act + render() + + // Assert + const pluginItems = screen.getAllByTestId('plugin-item') + expect(pluginItems).toHaveLength(100) + }) + + it('should handle plugins with duplicate plugin_ids (key warning scenario)', () => { + // Arrange - Testing that the component uses plugin_id as key + const pluginList = [ + createPluginDetail({ plugin_id: 'unique-1', name: 'Plugin 1' }), + createPluginDetail({ plugin_id: 'unique-2', name: 'Plugin 2' }), + ] + + // Act & Assert - Should render without issues + expect(() => render()).not.toThrow() + expect(screen.getAllByTestId('plugin-item')).toHaveLength(2) + }) + + it('should handle plugins with special characters in names', () => { + // Arrange + const pluginList = [ + createPluginDetail({ plugin_id: 'special-1', name: 'Plugin "special" & chars' }), + createPluginDetail({ plugin_id: 'special-2', name: 'ζ—₯本θͺžγƒ—ラグむン' }), + createPluginDetail({ plugin_id: 'special-3', name: 'Emoji Plugin πŸ”Œ' }), + ] + + // Act + render() + + // Assert + const pluginItems = screen.getAllByTestId('plugin-item') + expect(pluginItems).toHaveLength(3) + }) + + it('should handle plugins with very long names', () => { + // Arrange + const longName = 'A'.repeat(500) + const pluginList = [createPluginDetail({ name: longName })] + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-item')).toBeInTheDocument() + }) + + it('should handle plugin with minimal data', () => { + // Arrange + const minimalPlugin = createPluginDetail({ + name: '', + plugin_id: 'minimal', + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-item')).toBeInTheDocument() + }) + + it('should handle plugins with undefined optional fields', () => { + // Arrange + const pluginList = [ + createPluginDetail({ + plugin_id: 'no-meta', + meta: undefined, + }), + ] + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-item')).toBeInTheDocument() + }) + }) + + // ==================== Grid Layout Tests ==================== + describe('Grid Layout', () => { + it('should render with 2-column grid', () => { + // Arrange + const pluginList = createPluginList(4) + + // Act + const { container } = render() + + // Assert + const gridDiv = container.querySelector('.grid') + expect(gridDiv).toHaveClass('grid-cols-2') + }) + + it('should have proper gap between items', () => { + // Arrange + const pluginList = createPluginList(4) + + // Act + const { container } = render() + + // Assert + const gridDiv = container.querySelector('.grid') + expect(gridDiv).toHaveClass('gap-3') + }) + + it('should have bottom padding on container', () => { + // Arrange + const pluginList = createPluginList(2) + + // Act + const { container } = render() + + // Assert + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv).toHaveClass('pb-3') + }) + }) + + // ==================== Re-render Tests ==================== + describe('Re-render Behavior', () => { + it('should update when pluginList changes', () => { + // Arrange + const initialList = createPluginList(2) + const updatedList = createPluginList(4) + + // Act + const { rerender } = render() + expect(screen.getAllByTestId('plugin-item')).toHaveLength(2) + + rerender() + + // Assert + expect(screen.getAllByTestId('plugin-item')).toHaveLength(4) + }) + + it('should handle pluginList update from non-empty to empty', () => { + // Arrange + const initialList = createPluginList(3) + const emptyList: PluginDetail[] = [] + + // Act + const { rerender } = render() + expect(screen.getAllByTestId('plugin-item')).toHaveLength(3) + + rerender() + + // Assert + expect(screen.queryByTestId('plugin-item')).not.toBeInTheDocument() + }) + + it('should handle pluginList update from empty to non-empty', () => { + // Arrange + const emptyList: PluginDetail[] = [] + const filledList = createPluginList(3) + + // Act + const { rerender } = render() + expect(screen.queryByTestId('plugin-item')).not.toBeInTheDocument() + + rerender() + + // Assert + expect(screen.getAllByTestId('plugin-item')).toHaveLength(3) + }) + + it('should update individual plugin data on re-render', () => { + // Arrange + const initialList = [createPluginDetail({ plugin_id: 'plugin-1', name: 'Original Name' })] + const updatedList = [createPluginDetail({ plugin_id: 'plugin-1', name: 'Updated Name' })] + + // Act + const { rerender } = render() + expect(screen.getByText('Original Name')).toBeInTheDocument() + + rerender() + + // Assert + expect(screen.getByText('Updated Name')).toBeInTheDocument() + expect(screen.queryByText('Original Name')).not.toBeInTheDocument() + }) + }) + + // ==================== Key Prop Tests ==================== + describe('Key Prop Behavior', () => { + it('should use plugin_id as key for efficient re-renders', () => { + // Arrange - Create plugins with unique plugin_ids + const pluginList = [ + createPluginDetail({ plugin_id: 'stable-key-1', name: 'Plugin 1' }), + createPluginDetail({ plugin_id: 'stable-key-2', name: 'Plugin 2' }), + createPluginDetail({ plugin_id: 'stable-key-3', name: 'Plugin 3' }), + ] + + // Act + const { rerender } = render() + + // Reorder the list + const reorderedList = [pluginList[2], pluginList[0], pluginList[1]] + rerender() + + // Assert - All items should still be present + const items = screen.getAllByTestId('plugin-item') + expect(items).toHaveLength(3) + expect(items[0]).toHaveAttribute('data-plugin-id', 'stable-key-3') + expect(items[1]).toHaveAttribute('data-plugin-id', 'stable-key-1') + expect(items[2]).toHaveAttribute('data-plugin-id', 'stable-key-2') + }) + }) + + // ==================== Plugin Status Variations ==================== + describe('Plugin Status Variations', () => { + it('should render active plugins', () => { + // Arrange + const pluginList = [createPluginDetail({ status: 'active' })] + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-item')).toBeInTheDocument() + }) + + it('should render deleted/deprecated plugins', () => { + // Arrange + const pluginList = [ + createPluginDetail({ + status: 'deleted', + deprecated_reason: 'No longer maintained', + }), + ] + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-item')).toBeInTheDocument() + }) + + it('should render mixed status plugins', () => { + // Arrange + const pluginList = [ + createPluginDetail({ plugin_id: 'active-plugin', status: 'active' }), + createPluginDetail({ + plugin_id: 'deprecated-plugin', + status: 'deleted', + deprecated_reason: 'Deprecated', + }), + ] + + // Act + render() + + // Assert + expect(screen.getAllByTestId('plugin-item')).toHaveLength(2) + }) + }) + + // ==================== Version Variations ==================== + describe('Version Variations', () => { + it('should render plugins with same version as latest', () => { + // Arrange + const pluginList = [ + createPluginDetail({ + version: '1.0.0', + latest_version: '1.0.0', + }), + ] + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-item')).toBeInTheDocument() + }) + + it('should render plugins with outdated version', () => { + // Arrange + const pluginList = [ + createPluginDetail({ + version: '1.0.0', + latest_version: '2.0.0', + }), + ] + + // Act + render() + + // Assert + expect(screen.getByTestId('plugin-item')).toBeInTheDocument() + }) + }) + + // ==================== Accessibility ==================== + describe('Accessibility', () => { + it('should render as a semantic container', () => { + // Arrange + const pluginList = createPluginList(2) + + // Act + const { container } = render() + + // Assert - The list is rendered as divs which is appropriate for a grid layout + const outerDiv = container.firstChild as HTMLElement + expect(outerDiv.tagName).toBe('DIV') + }) + }) + + // ==================== Component Type ==================== + describe('Component Type', () => { + it('should be a functional component', () => { + // Assert + expect(typeof PluginList).toBe('function') + }) + + it('should accept pluginList as required prop', () => { + // Arrange & Act - TypeScript ensures this at compile time + // but we verify runtime behavior + const pluginList = createPluginList(1) + + // Assert + expect(() => render()).not.toThrow() + }) + }) + + // ==================== Mixed Content Tests ==================== + describe('Mixed Content', () => { + it('should render plugins from different sources together', () => { + // Arrange + const pluginList = [ + createPluginDetail({ + plugin_id: 'marketplace-1', + name: 'Marketplace Plugin', + source: PluginSource.marketplace, + }), + createPluginDetail({ + plugin_id: 'github-1', + name: 'GitHub Plugin', + source: PluginSource.github, + }), + createPluginDetail({ + plugin_id: 'local-1', + name: 'Local Plugin', + source: PluginSource.local, + }), + ] + + // Act + render() + + // Assert + expect(screen.getByText('Marketplace Plugin')).toBeInTheDocument() + expect(screen.getByText('GitHub Plugin')).toBeInTheDocument() + expect(screen.getByText('Local Plugin')).toBeInTheDocument() + }) + + it('should render plugins of different categories together', () => { + // Arrange + const pluginList = [ + createPluginDetail({ + plugin_id: 'tool-1', + name: 'Tool Plugin', + declaration: createPluginDeclaration({ category: PluginCategoryEnum.tool }), + }), + createPluginDetail({ + plugin_id: 'model-1', + name: 'Model Plugin', + declaration: createPluginDeclaration({ category: PluginCategoryEnum.model }), + }), + createPluginDetail({ + plugin_id: 'agent-1', + name: 'Agent Plugin', + declaration: createPluginDeclaration({ category: PluginCategoryEnum.agent }), + }), + ] + + // Act + render() + + // Assert + expect(screen.getByText('Tool Plugin')).toBeInTheDocument() + expect(screen.getByText('Model Plugin')).toBeInTheDocument() + expect(screen.getByText('Agent Plugin')).toBeInTheDocument() + }) + }) + + // ==================== Boundary Tests ==================== + describe('Boundary Tests', () => { + it('should handle single item list', () => { + // Arrange + const pluginList = createPluginList(1) + + // Act + render() + + // Assert + expect(screen.getAllByTestId('plugin-item')).toHaveLength(1) + }) + + it('should handle two items (fills one row)', () => { + // Arrange + const pluginList = createPluginList(2) + + // Act + render() + + // Assert + expect(screen.getAllByTestId('plugin-item')).toHaveLength(2) + }) + + it('should handle three items (partial second row)', () => { + // Arrange + const pluginList = createPluginList(3) + + // Act + render() + + // Assert + expect(screen.getAllByTestId('plugin-item')).toHaveLength(3) + }) + + it('should handle odd number of items', () => { + // Arrange + const pluginList = createPluginList(7) + + // Act + render() + + // Assert + expect(screen.getAllByTestId('plugin-item')).toHaveLength(7) + }) + + it('should handle even number of items', () => { + // Arrange + const pluginList = createPluginList(8) + + // Act + render() + + // Assert + expect(screen.getAllByTestId('plugin-item')).toHaveLength(8) + }) + }) +})