From e85cb76d9384c447fa8eb61ff3915629570549f8 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Wed, 24 Dec 2025 16:39:15 +0800 Subject: [PATCH] test: add comprehensive tests for plugin authentication components - Introduced unit tests for the components, including , , and . - Implemented tests for rendering, props handling, user interactions, and edge cases. - Ensured proper integration with mocked API hooks and context providers. - Enhanced coverage for credential handling and OAuth flows. --- .../authorize/authorize-components.spec.tsx | 2264 +++++++++++++++++ .../plugin-auth/authorize/index.spec.tsx | 786 ++++++ .../plugins/plugin-auth/index.spec.tsx | 2035 +++++++++++++++ 3 files changed, 5085 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 diff --git a/web/app/components/plugins/plugin-auth/authorize/authorize-components.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/authorize-components.spec.tsx new file mode 100644 index 0000000000..3cb4bb9603 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/authorize-components.spec.tsx @@ -0,0 +1,2264 @@ +import type { ReactNode } from 'react' +import type { PluginPayload } from '../types' +import type { FormSchema } from '@/app/components/base/form/types' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AuthCategory } from '../types' + +// Create a wrapper with QueryClientProvider +const createTestQueryClient = () => + 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() }) + + 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() }) + + // Find and click the settings icon (right side of split button) + const settingsIcon = screen.getByRole('button').querySelector('[class*="shrink-0"][class*="w-8"]') + if (settingsIcon) { + fireEvent.click(settingsIcon) + } + else { + // Alternative: click by finding the RiEqualizer2Line icon's parent + const icons = screen.getByRole('button').querySelectorAll('svg') + const settingsButton = icons[icons.length - 1]?.parentElement + if (settingsButton) + 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) + + // Wait a bit and verify API was not called + await new Promise(resolve => setTimeout(resolve, 100)) + 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 a tick to ensure state updates + await new Promise(resolve => setTimeout(resolve, 10)) + + // Second click while first request is still pending + fireEvent.click(confirmButton) + + // Wait a bit more + await new Promise(resolve => setTimeout(resolve, 10)) + + // Only one API call should have been made + 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 a tick to ensure state updates + await new Promise(resolve => setTimeout(resolve, 10)) + + // Second click while first request is pending + fireEvent.click(saveButton) + + // Wait a bit more + await new Promise(resolve => setTimeout(resolve, 10)) + + // Only one API call should have been made + 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 a tick to ensure state updates + await new Promise(resolve => setTimeout(resolve, 10)) + + // Second click while first request is pending + fireEvent.click(removeButton) + + // Wait a bit more + await new Promise(resolve => setTimeout(resolve, 10)) + + // Only one delete call should have been made + 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..97b5094e99 --- /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 falsy, so it won't be transformed + expect(result.api_key).toBe(null) + // 0 is falsy, so it won't be 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) + }) + }) +})