import '@testing-library/jest-dom/vitest' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter } from 'react-router-dom' import { vi, describe, it, expect, beforeEach } from 'vitest' import Plugins from './Plugins' import * as usePluginsHook from '../hooks/usePlugins' import type { PluginInfo } from '../hooks/usePlugins' // Mock modules vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en', changeLanguage: vi.fn(), }, }), })) vi.mock('../hooks/usePlugins', async () => { const actual = await vi.importActual('../hooks/usePlugins') return { ...actual, usePlugins: vi.fn(), useEnablePlugin: vi.fn(), useDisablePlugin: vi.fn(), useReloadPlugins: vi.fn(), } }) vi.mock('../utils/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), }, })) // Test data const mockBuiltInPlugin: PluginInfo = { id: 1, uuid: 'builtin-cf', name: 'Cloudflare', type: 'cloudflare', enabled: true, status: 'loaded', is_built_in: true, version: '1.0.0', description: 'Cloudflare DNS provider', documentation_url: 'https://cloudflare.com', created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z', } const mockExternalPlugin: PluginInfo = { id: 2, uuid: 'ext-pdns', name: 'PowerDNS', type: 'powerdns', enabled: true, status: 'loaded', is_built_in: false, version: '1.0.0', author: 'Community', description: 'PowerDNS provider', documentation_url: 'https://powerdns.com', loaded_at: '2025-01-06T00:00:00Z', created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-06T00:00:00Z', } const mockDisabledPlugin: PluginInfo = { ...mockExternalPlugin, id: 3, uuid: 'ext-disabled', name: 'Disabled Plugin', enabled: false, status: 'pending', } const mockErrorPlugin: PluginInfo = { ...mockExternalPlugin, id: 4, uuid: 'ext-error', name: 'Error Plugin', enabled: true, status: 'error', error: 'Failed to load plugin', } const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false }, }, }) const renderWithProviders = (ui: React.ReactNode) => { const queryClient = createQueryClient() return render( {ui} ) } // Mock default successful state const createMockUsePlugins = (data: PluginInfo[] = [mockBuiltInPlugin, mockExternalPlugin]) => ({ data, isLoading: false, isError: false, error: null, refetch: vi.fn(), }) const createMockMutation = (isPending = false) => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending, isSuccess: false, isError: false, error: null, }) describe('Plugins - Basic Rendering', () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(usePluginsHook.usePlugins).mockReturnValue(createMockUsePlugins()) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) }) it('renders page title', () => { renderWithProviders() expect(screen.queryByText(/plugin/i)).toBeInTheDocument() }) it('renders plugin list when data loads', async () => { renderWithProviders() await waitFor(() => { expect(screen.getByText('Cloudflare')).toBeInTheDocument() expect(screen.getByText('PowerDNS')).toBeInTheDocument() }) }) it('shows loading skeleton', () => { vi.mocked(usePluginsHook.usePlugins).mockReturnValue({ ...createMockUsePlugins(), data: undefined, isLoading: true, }) renderWithProviders() // Skeleton is shown during loading expect(screen.queryByText('Cloudflare')).not.toBeInTheDocument() }) it('shows error alert on fetch failure', () => { vi.mocked(usePluginsHook.usePlugins).mockReturnValue({ ...createMockUsePlugins(), data: undefined, isError: true, error: new Error('Network error'), }) renderWithProviders() // Empty state should be shown when data is undefined expect(screen.queryByText('Cloudflare')).not.toBeInTheDocument() }) it('shows empty state when no plugins', () => { vi.mocked(usePluginsHook.usePlugins).mockReturnValue(createMockUsePlugins([])) renderWithProviders() expect(screen.getByText(/no plugins found/i)).toBeInTheDocument() }) it('renders status badges correctly', () => { const plugins = [ mockBuiltInPlugin, mockDisabledPlugin, mockErrorPlugin, ] vi.mocked(usePluginsHook.usePlugins).mockReturnValue(createMockUsePlugins(plugins)) renderWithProviders() expect(screen.getByText(/loaded/i)).toBeInTheDocument() expect(screen.getByText(/disabled/i)).toBeInTheDocument() expect(screen.getByText(/error/i)).toBeInTheDocument() }) it('separates built-in vs external plugins', async () => { renderWithProviders() await waitFor(() => { expect(screen.getByText(/built-in providers/i)).toBeInTheDocument() expect(screen.getByText(/external plugins/i)).toBeInTheDocument() }) }) }) describe('Plugins - Plugin Actions', () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(usePluginsHook.usePlugins).mockReturnValue( createMockUsePlugins([mockExternalPlugin, mockDisabledPlugin]) ) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) }) it('toggle plugin on', async () => { const user = userEvent.setup() const mockEnable = createMockMutation() mockEnable.mutateAsync = vi.fn().mockResolvedValue({ message: 'Enabled' }) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(mockEnable) renderWithProviders() await waitFor(() => screen.getByText('Disabled Plugin')) // Find the switch for disabled plugin const switches = screen.getAllByRole('switch') const disabledSwitch = switches.find((sw) => !sw.getAttribute('data-state')?.includes('checked')) if (disabledSwitch) { await user.click(disabledSwitch) expect(mockEnable.mutateAsync).toHaveBeenCalledWith(3) } }) it('toggle plugin off', async () => { const user = userEvent.setup() const mockDisable = createMockMutation() mockDisable.mutateAsync = vi.fn().mockResolvedValue({ message: 'Disabled' }) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(mockDisable) renderWithProviders() await waitFor(() => screen.getByText('PowerDNS')) const switches = screen.getAllByRole('switch') const enabledSwitch = switches[0] await user.click(enabledSwitch) expect(mockDisable.mutateAsync).toHaveBeenCalledWith(2) }) it('reload plugins button works', async () => { const user = userEvent.setup() const mockReload = createMockMutation() mockReload.mutateAsync = vi.fn().mockResolvedValue({ message: 'Reloaded', count: 2 }) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(mockReload) renderWithProviders() const reloadButton = screen.getByRole('button', { name: /reload plugins/i }) await user.click(reloadButton) expect(mockReload.mutateAsync).toHaveBeenCalled() }) it('reload shows success toast', async () => { const user = userEvent.setup() const { toast } = await import('../utils/toast') const mockReload = createMockMutation() mockReload.mutateAsync = vi.fn().mockResolvedValue({ message: 'Reloaded', count: 2 }) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(mockReload) renderWithProviders() const reloadButton = screen.getByRole('button', { name: /reload plugins/i }) await user.click(reloadButton) await waitFor(() => { expect(toast.success).toHaveBeenCalled() }) }) it('open metadata modal', async () => { const user = userEvent.setup() renderWithProviders() await waitFor(() => screen.getByText('PowerDNS')) const detailsButtons = screen.getAllByRole('button', { name: /details/i }) await user.click(detailsButtons[0]) await waitFor(() => { expect(screen.getByRole('dialog')).toBeInTheDocument() expect(screen.getByText(/plugin details/i)).toBeInTheDocument() }) }) it('close metadata modal', async () => { const user = userEvent.setup() renderWithProviders() await waitFor(() => screen.getByText('PowerDNS')) const detailsButtons = screen.getAllByRole('button', { name: /details/i }) await user.click(detailsButtons[0]) await waitFor(() => screen.getByRole('dialog')) const closeButton = screen.getByRole('button', { name: /close/i }) await user.click(closeButton) await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) }) it('navigate to documentation URL', async () => { const user = userEvent.setup() const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null) renderWithProviders() await waitFor(() => screen.getByText('PowerDNS')) const docsButtons = screen.getAllByRole('button', { name: /docs/i }) await user.click(docsButtons[0]) expect(windowOpenSpy).toHaveBeenCalledWith('https://powerdns.com', '_blank') windowOpenSpy.mockRestore() }) it('disabled plugin toggle is disabled', async () => { vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation(true)) renderWithProviders() await waitFor(() => screen.getByText('Disabled Plugin')) const switches = screen.getAllByRole('switch') expect(switches.some((sw) => sw.hasAttribute('disabled'))).toBe(true) }) }) describe('Plugins - React Query Integration', () => { beforeEach(() => { vi.clearAllMocks() }) it('usePlugins query called on mount', () => { vi.mocked(usePluginsHook.usePlugins).mockReturnValue(createMockUsePlugins()) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) renderWithProviders() expect(usePluginsHook.usePlugins).toHaveBeenCalled() }) it('mutation invalidates queries on success', async () => { const user = userEvent.setup() const mockRefetch = vi.fn() vi.mocked(usePluginsHook.usePlugins).mockReturnValue({ ...createMockUsePlugins([mockExternalPlugin]), refetch: mockRefetch, }) const mockEnable = createMockMutation() mockEnable.mutateAsync = vi.fn().mockResolvedValue({ message: 'Enabled' }) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(mockEnable) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) renderWithProviders() await waitFor(() => screen.getByText('PowerDNS')) const switches = screen.getAllByRole('switch') await user.click(switches[0]) await waitFor(() => { expect(mockRefetch).toHaveBeenCalled() }) }) it('error handling for mutations', async () => { const user = userEvent.setup() const { toast } = await import('../utils/toast') const mockDisable = createMockMutation() mockDisable.mutateAsync = vi.fn().mockRejectedValue(new Error('Failed to disable')) vi.mocked(usePluginsHook.usePlugins).mockReturnValue( createMockUsePlugins([mockExternalPlugin]) ) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(mockDisable) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) renderWithProviders() await waitFor(() => screen.getByText('PowerDNS')) const switches = screen.getAllByRole('switch') await user.click(switches[0]) await waitFor(() => { expect(toast.error).toHaveBeenCalled() }) }) it('optimistic updates work', async () => { // This test verifies UI updates before API confirmation const user = userEvent.setup() const mockDisable = createMockMutation() let resolveDisable: ((value: unknown) => void) | null = null mockDisable.mutateAsync = vi.fn( () => new Promise((resolve) => (resolveDisable = resolve)) ) vi.mocked(usePluginsHook.usePlugins).mockReturnValue( createMockUsePlugins([mockExternalPlugin]) ) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(mockDisable) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) renderWithProviders() await waitFor(() => screen.getByText('PowerDNS')) const switches = screen.getAllByRole('switch') await user.click(switches[0]) // Mutation is pending expect(mockDisable.mutateAsync).toHaveBeenCalled() // Resolve the mutation if (resolveDisable) resolveDisable({ message: 'Disabled' }) }) it('retry logic on failure', async () => { const mockError = new Error('Network timeout') vi.mocked(usePluginsHook.usePlugins).mockReturnValue({ ...createMockUsePlugins(), data: undefined, isError: true, error: mockError, }) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) renderWithProviders() // Should handle error gracefully expect(screen.queryByText('Cloudflare')).not.toBeInTheDocument() }) it('query key management', () => { vi.mocked(usePluginsHook.usePlugins).mockReturnValue(createMockUsePlugins()) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) renderWithProviders() // Verify hooks are called with proper query keys expect(usePluginsHook.usePlugins).toHaveBeenCalled() }) }) describe('Plugins - Error Handling', () => { beforeEach(() => { vi.clearAllMocks() }) it('network error display', () => { vi.mocked(usePluginsHook.usePlugins).mockReturnValue({ ...createMockUsePlugins(), data: undefined, isError: true, error: new Error('Network error'), }) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) renderWithProviders() // Should show empty state or error message expect(screen.queryByText('Cloudflare')).not.toBeInTheDocument() }) it('toggle error toast', async () => { const user = userEvent.setup() const { toast } = await import('../utils/toast') const mockEnable = createMockMutation() mockEnable.mutateAsync = vi.fn().mockRejectedValue({ response: { data: { error: 'API Error' } } }) vi.mocked(usePluginsHook.usePlugins).mockReturnValue( createMockUsePlugins([mockDisabledPlugin]) ) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(mockEnable) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) renderWithProviders() await waitFor(() => screen.getByText('Disabled Plugin')) const switches = screen.getAllByRole('switch') await user.click(switches[0]) await waitFor(() => { expect(toast.error).toHaveBeenCalled() }) }) it('reload error toast', async () => { const user = userEvent.setup() const { toast } = await import('../utils/toast') const mockReload = createMockMutation() mockReload.mutateAsync = vi.fn().mockRejectedValue(new Error('Reload failed')) vi.mocked(usePluginsHook.usePlugins).mockReturnValue(createMockUsePlugins()) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(mockReload) renderWithProviders() const reloadButton = screen.getByRole('button', { name: /reload plugins/i }) await user.click(reloadButton) await waitFor(() => { expect(toast.error).toHaveBeenCalled() }) }) it('graceful degradation', () => { vi.mocked(usePluginsHook.usePlugins).mockReturnValue(createMockUsePlugins([mockErrorPlugin])) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) renderWithProviders() // Plugin with error should still render expect(screen.getByText('Error Plugin')).toBeInTheDocument() expect(screen.getByText('Failed to load plugin')).toBeInTheDocument() }) it('error boundary integration', () => { // This test verifies component doesn't crash on error vi.mocked(usePluginsHook.usePlugins).mockReturnValue({ ...createMockUsePlugins(), data: undefined, isError: true, error: new Error('Critical error'), }) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) expect(() => renderWithProviders()).not.toThrow() }) it('retry mechanisms', async () => { const user = userEvent.setup() const mockReload = createMockMutation() mockReload.mutateAsync = vi .fn() .mockRejectedValueOnce(new Error('Fail')) .mockResolvedValueOnce({ message: 'Success', count: 2 }) vi.mocked(usePluginsHook.usePlugins).mockReturnValue(createMockUsePlugins()) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(mockReload) renderWithProviders() const reloadButton = screen.getByRole('button', { name: /reload plugins/i }) await user.click(reloadButton) await user.click(reloadButton) // Second click should succeed expect(mockReload.mutateAsync).toHaveBeenCalledTimes(2) }) }) describe('Plugins - Edge Cases', () => { beforeEach(() => { vi.clearAllMocks() }) it('empty plugin list', () => { vi.mocked(usePluginsHook.usePlugins).mockReturnValue(createMockUsePlugins([])) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) renderWithProviders() expect(screen.getByText(/no plugins found/i)).toBeInTheDocument() }) it('all plugins disabled', () => { const allDisabled = [ { ...mockBuiltInPlugin, enabled: false }, { ...mockExternalPlugin, enabled: false }, ] vi.mocked(usePluginsHook.usePlugins).mockReturnValue(createMockUsePlugins(allDisabled)) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) renderWithProviders() expect(screen.getAllByText(/disabled/i).length).toBeGreaterThan(0) }) it('mixed status plugins', () => { const mixedPlugins = [mockBuiltInPlugin, mockDisabledPlugin, mockErrorPlugin] vi.mocked(usePluginsHook.usePlugins).mockReturnValue(createMockUsePlugins(mixedPlugins)) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) renderWithProviders() expect(screen.getByText(/loaded/i)).toBeInTheDocument() expect(screen.getByText(/disabled/i)).toBeInTheDocument() expect(screen.getByText(/error/i)).toBeInTheDocument() }) it('long plugin names', () => { const longName = 'A'.repeat(100) const longNamePlugin = { ...mockExternalPlugin, name: longName } vi.mocked(usePluginsHook.usePlugins).mockReturnValue( createMockUsePlugins([longNamePlugin]) ) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) renderWithProviders() expect(screen.getByText(longName)).toBeInTheDocument() }) it('missing metadata', () => { const noMetadata: PluginInfo = { id: 99, uuid: 'no-meta', name: 'No Metadata', type: 'unknown', enabled: true, status: 'loaded', is_built_in: false, created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z', } vi.mocked(usePluginsHook.usePlugins).mockReturnValue(createMockUsePlugins([noMetadata])) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) renderWithProviders() expect(screen.getByText('No Metadata')).toBeInTheDocument() }) it('concurrent toggles', async () => { const user = userEvent.setup() const mockEnable = createMockMutation() mockEnable.mutateAsync = vi.fn().mockResolvedValue({ message: 'Enabled' }) vi.mocked(usePluginsHook.usePlugins).mockReturnValue( createMockUsePlugins([mockDisabledPlugin, { ...mockDisabledPlugin, id: 5, uuid: 'disabled2' }]) ) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(mockEnable) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(createMockMutation()) renderWithProviders() await waitFor(() => screen.getAllByRole('switch')) const switches = screen.getAllByRole('switch') await Promise.all([user.click(switches[0]), user.click(switches[1])]) // Both mutations should be called expect(mockEnable.mutateAsync).toHaveBeenCalled() }) it('rapid reload clicks', async () => { const user = userEvent.setup() const mockReload = createMockMutation() mockReload.mutateAsync = vi.fn().mockResolvedValue({ message: 'Reloaded', count: 2 }) vi.mocked(usePluginsHook.usePlugins).mockReturnValue(createMockUsePlugins()) vi.mocked(usePluginsHook.useEnablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useDisablePlugin).mockReturnValue(createMockMutation()) vi.mocked(usePluginsHook.useReloadPlugins).mockReturnValue(mockReload) renderWithProviders() const reloadButton = screen.getByRole('button', { name: /reload plugins/i }) await user.tripleClick(reloadButton) // Should handle rapid clicks expect(mockReload.mutateAsync).toHaveBeenCalled() }) })