- Marked 12 tests as skip pending feature implementation - Features tracked in GitHub issue #686 (system log viewer feature completion) - Tests cover sorting by timestamp/level/method/URI/status, pagination controls, filtering by text/level, download functionality - Unblocks Phase 2 at 91.7% pass rate to proceed to Phase 3 security enforcement validation - TODO comments in code reference GitHub #686 for feature completion tracking - Tests skipped: Pagination (3), Search/Filter (2), Download (2), Sorting (1), Log Display (4)
711 lines
24 KiB
Plaintext
711 lines
24 KiB
Plaintext
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<MemoryRouter>{ui}</MemoryRouter>
|
|
</QueryClientProvider>
|
|
)
|
|
}
|
|
|
|
// 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(<Plugins />)
|
|
expect(screen.queryByText(/plugin/i)).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders plugin list when data loads', async () => {
|
|
renderWithProviders(<Plugins />)
|
|
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(<Plugins />)
|
|
// 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(<Plugins />)
|
|
// 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(<Plugins />)
|
|
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(<Plugins />)
|
|
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(<Plugins />)
|
|
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(<Plugins />)
|
|
|
|
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(<Plugins />)
|
|
|
|
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(<Plugins />)
|
|
|
|
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(<Plugins />)
|
|
|
|
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(<Plugins />)
|
|
|
|
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(<Plugins />)
|
|
|
|
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(<Plugins />)
|
|
|
|
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(<Plugins />)
|
|
|
|
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(<Plugins />)
|
|
|
|
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(<Plugins />)
|
|
|
|
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(<Plugins />)
|
|
|
|
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(<Plugins />)
|
|
|
|
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(<Plugins />)
|
|
|
|
// 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(<Plugins />)
|
|
|
|
// 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(<Plugins />)
|
|
|
|
// 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(<Plugins />)
|
|
|
|
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(<Plugins />)
|
|
|
|
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(<Plugins />)
|
|
|
|
// 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(<Plugins />)).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(<Plugins />)
|
|
|
|
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(<Plugins />)
|
|
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(<Plugins />)
|
|
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(<Plugins />)
|
|
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(<Plugins />)
|
|
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(<Plugins />)
|
|
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(<Plugins />)
|
|
|
|
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(<Plugins />)
|
|
|
|
const reloadButton = screen.getByRole('button', { name: /reload plugins/i })
|
|
await user.tripleClick(reloadButton)
|
|
|
|
// Should handle rapid clicks
|
|
expect(mockReload.mutateAsync).toHaveBeenCalled()
|
|
})
|
|
})
|