diff --git a/frontend/src/components/__tests__/AboutModal.test.jsx b/frontend/src/components/__tests__/AboutModal.test.jsx new file mode 100644 index 00000000..b98cd3c4 --- /dev/null +++ b/frontend/src/components/__tests__/AboutModal.test.jsx @@ -0,0 +1,318 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import AboutModal from '../AboutModal'; + +// ── Store mock ───────────────────────────────────────────────────────────────── +vi.mock('../../store/settings', () => ({ default: vi.fn() })); + +// ── Image mock ───────────────────────────────────────────────────────────────── +vi.mock('../../images/logo.png', () => ({ default: 'mocked-logo.png' })); + +// ── Custom icons mock ────────────────────────────────────────────────────────── +vi.mock('../icons.jsx', () => ({ + DiscordIcon: ({ size }) => ( + + ), + GitHubIcon: ({ size }) => , +})); + +// ── lucide-react mock ────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + BookOpen: ({ size }) => , + Heart: ({ size }) => , + Users: ({ size }) => , +})); + +// ── Mantine core mock ────────────────────────────────────────────────────────── +vi.mock('@mantine/core', async () => ({ + Box: ({ children, style }) =>
{children}
, + Button: ({ children, href, target, rel, variant, color, leftSection }) => ( + + {leftSection} + {children} + + ), + Divider: () =>
, + Group: ({ children, justify }) => ( +
{children}
+ ), + Modal: ({ children, opened, onClose, title, size }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + SimpleGrid: ({ children, cols }) =>
{children}
, + Stack: ({ children }) =>
{children}
, + Text: ({ children, fw, size, c, span }) => + span ? ( + + {children} + + ) : ( +

+ {children} +

+ ), + Tooltip: ({ children, label, position }) => ( +
+ {children} +
+ ), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useSettingsStore from '../../store/settings'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const setupStore = (version = { version: '1.2.3', timestamp: '20240601' }) => { + vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ version })); +}; + +describe('AboutModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Visibility ─────────────────────────────────────────────────────────────── + + describe('visibility', () => { + it('renders modal content when isOpen is true', () => { + setupStore(); + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('does not render modal content when isOpen is false', () => { + setupStore(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', () => { + setupStore(); + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('renders with the correct modal title', () => { + setupStore(); + render(); + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'About Dispatcharr' + ); + }); + }); + + // ── Version string ─────────────────────────────────────────────────────────── + + describe('version string', () => { + it('displays version with timestamp when both are present', () => { + setupStore({ version: '2.0.0', timestamp: '20240601' }); + render(); + expect(screen.getByText('v2.0.0-20240601')).toBeInTheDocument(); + }); + + it('displays version without timestamp when timestamp is null', () => { + setupStore({ version: '1.5.0', timestamp: null }); + render(); + expect(screen.getByText('v1.5.0')).toBeInTheDocument(); + }); + + it('displays version without timestamp when timestamp is undefined', () => { + setupStore({ version: '1.5.0' }); + render(); + expect(screen.getByText('v1.5.0')).toBeInTheDocument(); + }); + + it('falls back to v0.0.0 when version is undefined', () => { + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ version: undefined }) + ); + render(); + expect(screen.getByText('v0.0.0')).toBeInTheDocument(); + }); + + it('falls back to v0.0.0 when version object has no version field', () => { + setupStore({ version: '', timestamp: null }); + render(); + expect(screen.getByText('v0.0.0')).toBeInTheDocument(); + }); + }); + + // ── Logo & branding ────────────────────────────────────────────────────────── + + describe('logo and branding', () => { + it('renders the Dispatcharr logo', () => { + setupStore(); + render(); + const logo = screen.getByAltText('Dispatcharr'); + expect(logo).toBeInTheDocument(); + expect(logo).toHaveAttribute('src', 'mocked-logo.png'); + }); + + it('renders the app name "Dispatcharr"', () => { + setupStore(); + render(); + expect(screen.getByText('Dispatcharr')).toBeInTheDocument(); + }); + }); + + // ── Action buttons ─────────────────────────────────────────────────────────── + + describe('action buttons', () => { + it('renders the Documentation button with correct href', () => { + setupStore(); + render(); + expect(screen.getByText('Documentation').closest('a')).toHaveAttribute( + 'href', + 'https://dispatcharr.github.io/Dispatcharr-Docs/' + ); + }); + + it('renders the Discord button with correct href', () => { + setupStore(); + render(); + expect(screen.getByText('Discord').closest('a')).toHaveAttribute( + 'href', + 'https://discord.gg/Sp45V5BcxU' + ); + }); + + it('renders the GitHub button with correct href', () => { + setupStore(); + render(); + expect(screen.getByText('GitHub').closest('a')).toHaveAttribute( + 'href', + 'https://github.com/Dispatcharr/Dispatcharr' + ); + }); + + it('renders the Donate button with correct href', () => { + setupStore(); + render(); + expect(screen.getByText('Donate').closest('a')).toHaveAttribute( + 'href', + 'https://opencollective.com/dispatcharr/contribute' + ); + }); + + it('all external buttons open in a new tab', () => { + setupStore(); + render(); + const buttons = screen.getAllByTestId('button'); + buttons.forEach((btn) => { + expect(btn).toHaveAttribute('target', '_blank'); + }); + }); + + it('all external buttons have noopener noreferrer rel', () => { + setupStore(); + render(); + const buttons = screen.getAllByTestId('button'); + buttons.forEach((btn) => { + expect(btn).toHaveAttribute('rel', 'noopener noreferrer'); + }); + }); + + it('renders 4 action buttons', () => { + setupStore(); + render(); + expect(screen.getAllByTestId('button')).toHaveLength(4); + }); + }); + + // ── Icons ──────────────────────────────────────────────────────────────────── + + describe('icons', () => { + it('renders BookOpen icon for Documentation button', () => { + setupStore(); + render(); + expect(screen.getByTestId('icon-book-open')).toBeInTheDocument(); + }); + + it('renders DiscordIcon for Discord button', () => { + setupStore(); + render(); + expect(screen.getByTestId('discord-icon')).toBeInTheDocument(); + }); + + it('renders GitHubIcon for GitHub button', () => { + setupStore(); + render(); + expect(screen.getByTestId('github-icon')).toBeInTheDocument(); + }); + + it('renders Heart icon for Donate button', () => { + setupStore(); + render(); + expect(screen.getByTestId('icon-heart')).toBeInTheDocument(); + }); + + it('renders Users icon in Contributors section', () => { + setupStore(); + render(); + expect(screen.getByTestId('icon-users')).toBeInTheDocument(); + }); + }); + + // ── Contributors section ───────────────────────────────────────────────────── + + describe('contributors section', () => { + it('renders the Contributors heading', () => { + setupStore(); + render(); + expect(screen.getByText('Contributors')).toBeInTheDocument(); + }); + + it('renders the contributors description text', () => { + setupStore(); + render(); + expect( + screen.getByText( + /Dispatcharr is built by the community, for the community/i + ) + ).toBeInTheDocument(); + }); + }); + + // ── Memorial section ───────────────────────────────────────────────────────── + + describe('memorial section', () => { + it('renders the memorial text for Jesse Mann', () => { + setupStore(); + render(); + expect(screen.getByText(/In memory of/i)).toBeInTheDocument(); + }); + + it('renders Jesse Mann name in the memorial', () => { + setupStore(); + render(); + expect(screen.getByText('Jesse Mann')).toBeInTheDocument(); + }); + + it('renders the memorial tooltip with correct label', () => { + setupStore(); + render(); + const tooltip = screen + .getByText('Jesse Mann') + .closest('[data-tooltip="Remembering Jesse Mann"]'); + expect(tooltip).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/__tests__/PluginDetailPanel.test.jsx b/frontend/src/components/__tests__/PluginDetailPanel.test.jsx new file mode 100644 index 00000000..d92c0fb6 --- /dev/null +++ b/frontend/src/components/__tests__/PluginDetailPanel.test.jsx @@ -0,0 +1,863 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import PluginDetailPanel from '../PluginDetailPanel'; + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../pluginUtils.js', () => ({ + compareVersions: vi.fn(), + buildCompatibilityTooltip: vi.fn(), + buildVersionSelectItems: vi.fn(), +})); + +vi.mock('../../utils/networkUtils.js', () => ({ + formatKB: vi.fn((kb) => `${kb} KB`), +})); + +// ── Icon mocks ───────────────────────────────────────────────────────────────── +vi.mock('../icons.jsx', () => ({ + DiscordIcon: ({ size }) => ( + + ), + GitHubIcon: ({ size }) => , +})); + +vi.mock('lucide-react', () => ({ + AlertTriangle: ({ size, color }) => ( + + ), + Ban: ({ size }) => , + Download: ({ size }) => , + RefreshCw: ({ size }) => ( + + ), + ShieldAlert: ({ size }) => ( + + ), + ShieldCheck: ({ size }) => ( + + ), + Trash2: ({ size }) => , +})); + +// ── Mantine core mock ────────────────────────────────────────────────────────── +vi.mock('@mantine/core', async () => ({ + ActionIcon: ({ children, href, target, rel, color }) => ( + + {children} + + ), + Alert: ({ children, title, color, icon }) => ( +
+
{title}
+ {icon} + {children} +
+ ), + Badge: ({ + children, + component, + href, + target, + rel, + leftSection, + color, + style, + }) => + component === 'a' ? ( + + {leftSection} + {children} + + ) : ( + + {leftSection} + {children} + + ), + Button: ({ children, onClick, disabled, variant, color, leftSection }) => ( + + ), + Group: ({ children }) =>
{children}
, + Loader: ({ size }) => , + Select: ({ label, value, onChange, data, disabled }) => ( + + ), + Stack: ({ children }) =>
{children}
, + Table: ({ children }) => {children}
, + TableTbody: ({ children }) => {children}, + TableTd: ({ children, style }) => {children}, + TableTr: ({ children }) => {children}, + Text: ({ children, size, c, fw, component, href, target, rel }) => + component === 'a' ? ( + + {children} + + ) : ( + + {children} + + ), + Tooltip: ({ children, label }) =>
{children}
, +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import { + compareVersions, + buildCompatibilityTooltip, + buildVersionSelectItems, +} from '../../utils/components/pluginUtils.js'; + +// ────────────────────────────────────────────────────────────────────────────── +// Factories & helpers +// ────────────────────────────────────────────────────────────────────────────── + +const makeVersion = (version, overrides = {}) => ({ + version, + prerelease: false, + url: `https://example.com/plugin-${version}.zip`, + checksum_sha256: `sha256-${version}`, + size: 1024, + build_timestamp: '2024-01-15T10:00:00Z', + min_dispatcharr_version: null, + max_dispatcharr_version: null, + commit_sha: `abc${version}`, + commit_sha_short: `abc`, + ...overrides, +}); + +const makeManifest = (overrides = {}) => ({ + description: 'A useful plugin.', + author: 'Test Author', + license: 'MIT', + repo_url: 'https://github.com/example/plugin', + discord_thread: null, + deprecated: false, + registry_url: null, + versions: [makeVersion('2.0.0'), makeVersion('1.0.0')], + latest: { version: '2.0.0' }, + ...overrides, +}); + +const makeDetail = (manifestOverrides = {}, overrides = {}) => ({ + manifest: makeManifest(manifestOverrides), + signature_verified: true, + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + detail: makeDetail(), + detailLoading: false, + selectedVersion: '2.0.0', + onVersionChange: vi.fn(), + installedVersion: null, + installedVersionIsPrerelease: false, + appVersion: '1.5.0', + installing: false, + uninstalling: false, + onInstall: vi.fn(), + onUninstall: vi.fn(), + installStatus: 'not_installed', + installedSourceRepoName: null, + repoId: 1, + slug: 'my-plugin', + ...overrides, +}); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('PluginDetailPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + // default: every compareVersions call returns 0 (versions equal) unless + // individual tests override it + vi.mocked(compareVersions).mockReturnValue(0); + vi.mocked(buildCompatibilityTooltip).mockReturnValue('1.0.0 or newer'); + // default: buildVersionSelectItems returns two standard items so that + // any test rendering the version select has populated options + vi.mocked(buildVersionSelectItems).mockReturnValue([ + { value: '2.0.0', label: 'v2.0.0 (latest)', disabled: false }, + { value: '1.0.0', label: 'v1.0.0', disabled: false }, + ]); + }); + + // ── Loading & error states ───────────────────────────────────────────────── + + describe('loading and error states', () => { + it('shows a loader when detailLoading is true', () => { + render(); + expect(screen.getByTestId('loader')).toBeInTheDocument(); + expect(screen.getByText(/Loading plugin details/i)).toBeInTheDocument(); + }); + + it('shows error text when detail is null', () => { + render( + + ); + expect( + screen.getByText(/Failed to load plugin details/i) + ).toBeInTheDocument(); + }); + + it('shows error text when detail has no manifest', () => { + render( + + ); + expect( + screen.getByText(/Failed to load plugin details/i) + ).toBeInTheDocument(); + }); + }); + + // ── Description ─────────────────────────────────────────────────────────── + + describe('description', () => { + it('renders manifest description', () => { + render(); + expect(screen.getByText('A useful plugin.')).toBeInTheDocument(); + }); + + it('does not render description section when absent', () => { + render( + + ); + expect(screen.queryByText('A useful plugin.')).not.toBeInTheDocument(); + }); + }); + + // ── Author & license badges ──────────────────────────────────────────────── + + describe('author and license badges', () => { + it('renders author badge', () => { + render(); + expect(screen.getByText('Test Author')).toBeInTheDocument(); + }); + + it('does not render author badge when author is absent', () => { + render( + + ); + expect(screen.queryByText('Test Author')).not.toBeInTheDocument(); + }); + + it('renders license badge as a link to spdx.org', () => { + render(); + const badge = screen + .getAllByTestId('badge') + .find((el) => el.tagName === 'A' && el.href?.includes('spdx.org')); + expect(badge).toBeTruthy(); + expect(badge.href).toContain('MIT'); + }); + + it('does not render license badge when license is absent', () => { + render( + + ); + const badges = screen.getAllByTestId('badge'); + expect(badges.find((b) => b.href?.includes('spdx.org'))).toBeUndefined(); + }); + }); + + // ── Signature badges ─────────────────────────────────────────────────────── + + describe('signature badge', () => { + it('shows "Verified Signature" badge when signature_verified is true', () => { + render(); + expect(screen.getByText('Verified Signature')).toBeInTheDocument(); + expect(screen.getByTestId('icon-shield-check')).toBeInTheDocument(); + }); + + it('shows "Unverified" badge when signature_verified is false', () => { + render( + + ); + expect(screen.getByText('Unverified')).toBeInTheDocument(); + expect(screen.getByTestId('icon-shield-alert')).toBeInTheDocument(); + }); + + it('renders no signature badge when signature_verified is null', () => { + render( + + ); + expect(screen.queryByText('Verified Signature')).not.toBeInTheDocument(); + expect(screen.queryByText('Unverified')).not.toBeInTheDocument(); + }); + }); + + // ── GitHub link ──────────────────────────────────────────────────────────── + + describe('GitHub link', () => { + it('renders GitHub icon linking to repo_url', () => { + render(); + expect(screen.getByTestId('github-icon')).toBeInTheDocument(); + const link = screen + .getAllByTestId('action-icon') + .find((el) => el.href?.includes('github.com')); + expect(link).toBeTruthy(); + }); + + it('does not render GitHub icon when repo_url is absent', () => { + render( + + ); + expect(screen.queryByTestId('github-icon')).not.toBeInTheDocument(); + }); + }); + + // ── Discord link ─────────────────────────────────────────────────────────── + + describe('Discord link', () => { + it('renders Discord icon when discord_thread is set', () => { + render( + + ); + expect(screen.getByTestId('discord-icon')).toBeInTheDocument(); + }); + + it('does not render Discord icon when discord_thread is null', () => { + render(); + expect(screen.queryByTestId('discord-icon')).not.toBeInTheDocument(); + }); + + it('rewrites discord.com/channels URL to discord:// protocol', () => { + render( + + ); + const link = screen + .getAllByTestId('action-icon') + .find((el) => el.href?.startsWith('discord://')); + expect(link).toBeTruthy(); + }); + + it('keeps non-discord.com/channels URL unchanged', () => { + const url = 'https://discord.gg/invite/abc'; + render( + + ); + const link = screen + .getAllByTestId('action-icon') + .find((el) => el.href === url); + expect(link).toBeTruthy(); + }); + }); + + // ── Deprecated alert ─────────────────────────────────────────────────────── + + describe('deprecated alert', () => { + it('shows deprecated alert when deprecated is true', () => { + render( + + ); + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByTestId('alert-title')).toHaveTextContent( + 'Deprecated Plugin' + ); + expect(screen.getByTestId('icon-ban')).toBeInTheDocument(); + }); + + it('does not show deprecated alert when deprecated is false', () => { + render(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + }); + + // ── Version select ───────────────────────────────────────────────────────── + + describe('version select', () => { + it('renders the version select when versions exist', () => { + render(); + expect(screen.getByTestId('version-select')).toBeInTheDocument(); + }); + + it('does not render version select when versions list is empty', () => { + render( + + ); + expect(screen.queryByTestId('version-select')).not.toBeInTheDocument(); + }); + + it('renders options from buildVersionSelectItems return value', () => { + render(); + // default mock returns v2.0.0 (latest) and v1.0.0 + expect(screen.getByText('v2.0.0 (latest)')).toBeInTheDocument(); + expect(screen.getByText('v1.0.0')).toBeInTheDocument(); + }); + + it('calls buildVersionSelectItems with manifest versions, latest version, installedVersion, and installedVersionIsPrerelease', () => { + const detail = makeDetail({ + versions: [makeVersion('2.0.0'), makeVersion('1.0.0')], + latest: { version: '2.0.0' }, + }); + render( + + ); + expect(buildVersionSelectItems).toHaveBeenCalledWith( + detail.manifest.versions, + '2.0.0', + '1.0.0', + false + ); + }); + + it('passes installedVersionIsPrerelease=true to buildVersionSelectItems', () => { + render( + + ); + expect(buildVersionSelectItems).toHaveBeenCalledWith( + expect.any(Array), + expect.anything(), + '2.0.0-beta', + true + ); + }); + + it('passes null installedVersion to buildVersionSelectItems when not installed', () => { + render( + + ); + expect(buildVersionSelectItems).toHaveBeenCalledWith( + expect.any(Array), + expect.anything(), + null, + false + ); + }); + + it('does not call buildVersionSelectItems when versions list is empty', () => { + render( + + ); + expect(buildVersionSelectItems).not.toHaveBeenCalled(); + }); + + it('renders a disabled ghost option when buildVersionSelectItems returns one', () => { + vi.mocked(buildVersionSelectItems).mockReturnValue([ + { value: '2.0.0', label: 'v2.0.0 (latest)', disabled: false }, + { value: '1.5.0', label: 'v1.5.0 (installed)', disabled: true }, + { value: '1.0.0', label: 'v1.0.0', disabled: false }, + ]); + render( + + ); + const ghostOption = screen + .getAllByRole('option') + .find((o) => o.value === '1.5.0'); + expect(ghostOption).toBeTruthy(); + expect(ghostOption.disabled).toBe(true); + }); + + it('calls onVersionChange when version select changes', () => { + const onVersionChange = vi.fn(); + render(); + fireEvent.change(screen.getByTestId('version-select'), { + target: { value: '1.0.0' }, + }); + expect(onVersionChange).toHaveBeenCalledWith('1.0.0'); + }); + }); + + // ── Install button ───────────────────────────────────────────────────────── + + describe('install button', () => { + it('shows "Install" button when plugin is not installed', () => { + render(); + expect(screen.getByTestId('button')).toHaveTextContent('Install'); + }); + + it('calls onInstall with correct params when Install is clicked', () => { + const onInstall = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('button')); + expect(onInstall).toHaveBeenCalledWith( + expect.objectContaining({ + slug: 'my-plugin', + version: '2.0.0', + download_url: 'https://example.com/plugin-2.0.0.zip', + }) + ); + }); + + it('shows installing spinner while installing is true', () => { + render(); + expect(screen.getByTestId('button')).toHaveTextContent('Installing…'); + expect(screen.getByTestId('loader')).toBeInTheDocument(); + }); + + it('shows "Update" button when a newer version is selected over installed', () => { + vi.mocked(compareVersions).mockImplementation((a, b) => { + const strip = (v) => v.replace(/^v/, ''); + const pa = strip(a).split('.').map(Number); + const pb = strip(b).split('.').map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] || 0) - (pb[i] || 0); + if (d !== 0) return d; + } + return 0; + }); + render( + + ); + expect(screen.getByTestId('button')).toHaveTextContent('Update'); + }); + + it('shows "Downgrade" button when an older version is selected over installed', () => { + vi.mocked(compareVersions).mockImplementation((a, b) => { + const strip = (v) => v.replace(/^v/, ''); + const pa = strip(a).split('.').map(Number); + const pb = strip(b).split('.').map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] || 0) - (pb[i] || 0); + if (d !== 0) return d; + } + return 0; + }); + render( + + ); + expect(screen.getByTestId('button')).toHaveTextContent('Downgrade'); + }); + + it('shows "Uninstall" button when installed version equals selected version', () => { + vi.mocked(compareVersions).mockReturnValue(0); + render( + + ); + expect(screen.getByTestId('button')).toHaveTextContent('Uninstall'); + }); + + it('calls onUninstall when Uninstall is clicked', () => { + vi.mocked(compareVersions).mockReturnValue(0); + const onUninstall = vi.fn(); + render( + + ); + fireEvent.click(screen.getByTestId('button')); + expect(onUninstall).toHaveBeenCalled(); + }); + + it('shows "Uninstalling…" and loader while uninstalling', () => { + vi.mocked(compareVersions).mockReturnValue(0); + render( + + ); + expect(screen.getByTestId('button')).toHaveTextContent('Uninstalling…'); + }); + + it('shows "Overwrite" for unmanaged install status', () => { + render( + + ); + expect(screen.getByTestId('button')).toHaveTextContent('Overwrite'); + }); + + it('shows "Overwrite" for different_repo install status', () => { + render( + + ); + expect(screen.getByTestId('button')).toHaveTextContent('Overwrite'); + }); + + it('disables install button when no selectedVersionData url', () => { + const detail = makeDetail({ + versions: [makeVersion('2.0.0', { url: null })], + latest: { version: '2.0.0' }, + }); + render(); + expect(screen.getByTestId('button')).toBeDisabled(); + }); + + it('shows "Incompatible" when version does not meet min requirement', () => { + // selMeetsMin=false: appVersion < min_dispatcharr_version + vi.mocked(compareVersions).mockImplementation((a, b) => { + // appVersion (1.5.0) vs min (2.0.0): return negative + if (a === '1.5.0' && b === '2.0.0') return -1; + return 0; + }); + const detail = makeDetail({ + versions: [makeVersion('2.0.0', { min_dispatcharr_version: '2.0.0' })], + latest: { version: '2.0.0' }, + }); + render( + + ); + expect(screen.getByTestId('button')).toHaveTextContent('Incompatible'); + }); + }); + + // ── Compatibility warning ────────────────────────────────────────────────── + + describe('compatibility warning', () => { + it('shows compatibility warning tooltip when version is incompatible and not same as installed', () => { + vi.mocked(compareVersions).mockImplementation((a, b) => { + if (a === '1.5.0' && b === '2.0.0') return -1; + return 0; + }); + vi.mocked(buildCompatibilityTooltip).mockReturnValue('2.0.0 or newer'); + const detail = makeDetail({ + versions: [makeVersion('2.0.0', { min_dispatcharr_version: '2.0.0' })], + latest: { version: '2.0.0' }, + }); + render( + + ); + const tooltip = screen + .getAllByRole('generic') + .find((el) => + el.getAttribute('data-tooltip')?.includes('Incompatible') + ); + expect(tooltip).toBeTruthy(); + }); + }); + + // ── Version detail table ─────────────────────────────────────────────────── + + describe('version detail table', () => { + it('renders build timestamp', () => { + render(); + // The date is locale-formatted; just check the "Built" label exists + expect(screen.getByText('Built')).toBeInTheDocument(); + }); + + it('renders file size via formatKB', () => { + render(); + expect(screen.getByText('File Size')).toBeInTheDocument(); + expect(screen.getByText('1024 KB')).toBeInTheDocument(); + }); + + it('does not render file size row when size is 0', () => { + const detail = makeDetail({ + versions: [makeVersion('2.0.0', { size: 0 })], + latest: { version: '2.0.0' }, + }); + render(); + expect(screen.queryByText('File Size')).not.toBeInTheDocument(); + }); + + it('renders min version row when present', () => { + const detail = makeDetail({ + versions: [makeVersion('2.0.0', { min_dispatcharr_version: '1.0.0' })], + latest: { version: '2.0.0' }, + }); + render(); + expect(screen.getByText('Min Version')).toBeInTheDocument(); + expect(screen.getByText('1.0.0')).toBeInTheDocument(); + }); + + it('does not render min version row when absent', () => { + render(); + expect(screen.queryByText('Min Version')).not.toBeInTheDocument(); + }); + + it('renders max version row when present', () => { + const detail = makeDetail({ + versions: [makeVersion('2.0.0', { max_dispatcharr_version: '3.0.0' })], + latest: { version: '2.0.0' }, + }); + render(); + expect(screen.getByText('Max Version')).toBeInTheDocument(); + expect(screen.getByText('3.0.0')).toBeInTheDocument(); + }); + + it('renders commit short SHA', () => { + render(); + expect(screen.getByText('Commit')).toBeInTheDocument(); + expect(screen.getByText('abc')).toBeInTheDocument(); + }); + + it('renders commit as a link when registry_url is present', () => { + const detail = makeDetail({ + registry_url: 'https://github.com/example/plugin', + versions: [makeVersion('2.0.0', { commit_sha: 'abc123full' })], + latest: { version: '2.0.0' }, + }); + render(); + const commitLink = screen + .getAllByTestId('text-link') + .find((el) => el.href?.includes('abc123full')); + expect(commitLink).toBeTruthy(); + }); + + it('renders download URL as a link', () => { + render(); + expect(screen.getByText('Download')).toBeInTheDocument(); + const link = screen + .getAllByTestId('text-link') + .find((el) => el.href?.includes('plugin-2.0.0.zip')); + expect(link).toBeTruthy(); + }); + }); + + // ── buildVersionSelectItems integration ─────────────────────────────────── + + describe('buildVersionSelectItems integration', () => { + it('passes manifest.latest.version to buildVersionSelectItems', () => { + const detail = makeDetail({ latest: { version: '2.0.0' } }); + render(); + expect(buildVersionSelectItems).toHaveBeenCalledWith( + detail.manifest.versions, + '2.0.0', + null, // installedVersion from defaultProps + false // installedVersionIsPrerelease from defaultProps + ); + }); + + it('passes undefined latest to buildVersionSelectItems when manifest.latest is absent', () => { + const detail = makeDetail({ latest: null }); + render(); + expect(buildVersionSelectItems).toHaveBeenCalledWith( + detail.manifest.versions, + undefined, + null, // installedVersion from defaultProps + false // installedVersionIsPrerelease from defaultProps + ); + }); + + it('Select data reflects exactly what buildVersionSelectItems returns', () => { + const customItems = [ + { value: '3.0.0', label: 'v3.0.0 (latest)', disabled: false }, + { value: '2.0.0', label: 'v2.0.0 (installed)', disabled: false }, + ]; + vi.mocked(buildVersionSelectItems).mockReturnValue(customItems); + render( + + ); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(2); + expect(options[0].value).toBe('3.0.0'); + expect(options[1].value).toBe('2.0.0'); + }); + }); +}); diff --git a/frontend/src/components/__tests__/PluginWarnings.test.jsx b/frontend/src/components/__tests__/PluginWarnings.test.jsx new file mode 100644 index 00000000..dd454419 --- /dev/null +++ b/frontend/src/components/__tests__/PluginWarnings.test.jsx @@ -0,0 +1,266 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { + PluginSecurityWarning, + PluginSupportDisclaimer, + PluginDowngradeWarning, + PluginInfoNote, + PluginRestartWarning, +} from '../PluginWarnings'; + +// ── Image mock ───────────────────────────────────────────────────────────────── +vi.mock('../../images/logo.png', () => ({ default: 'mocked-logo.png' })); + +// ── lucide-react mock ────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + AlertTriangle: ({ size }) => ( + + ), + Info: ({ size }) => , + OctagonAlert: ({ size }) => ( + + ), +})); + +// ── Mantine core mock ────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Box: ({ children, style }) =>
{children}
, + Text: ({ children, size, style }) => ( +

+ {children} +

+ ), +})); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('PluginWarnings', () => { + // ── PluginSecurityWarning ──────────────────────────────────────────────────── + + describe('PluginSecurityWarning', () => { + it('renders children text', () => { + render( + This plugin is dangerous + ); + expect(screen.getByText('This plugin is dangerous')).toBeInTheDocument(); + }); + + it('renders the OctagonAlert icon', () => { + render(Warning); + expect(screen.getByTestId('icon-octagon-alert')).toBeInTheDocument(); + }); + + it('does not render AlertTriangle or Info icons', () => { + render(Warning); + expect( + screen.queryByTestId('icon-alert-triangle') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('icon-info')).not.toBeInTheDocument(); + }); + + it('renders different children correctly', () => { + render( + + Critical security issue + + ); + expect(screen.getByText('Critical')).toBeInTheDocument(); + }); + }); + + // ── PluginSupportDisclaimer ────────────────────────────────────────────────── + + describe('PluginSupportDisclaimer', () => { + it('renders the disclaimer text', () => { + render(); + expect( + screen.getByText( + /Dispatcharr community support cannot assist with third-party plugin issues/i + ) + ).toBeInTheDocument(); + }); + + it('mentions plugin Discord thread', () => { + render(); + expect( + screen.getByText(/use the plugin.*Discord thread/i) + ).toBeInTheDocument(); + }); + + it('mentions submitting an issue on the plugin repository', () => { + render(); + expect( + screen.getByText(/submit an issue.*on the plugin.*repository/i) + ).toBeInTheDocument(); + }); + + it('renders the Dispatcharr logo image', () => { + render(); + const logo = screen.getByAltText('Dispatcharr'); + expect(logo).toBeInTheDocument(); + expect(logo).toHaveAttribute('src', 'mocked-logo.png'); + }); + + it('renders logo as non-draggable', () => { + render(); + const logo = screen.getByAltText('Dispatcharr'); + expect(logo).toHaveAttribute('draggable', 'false'); + }); + + it('does not render any lucide icons', () => { + render(); + expect( + screen.queryByTestId('icon-octagon-alert') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('icon-alert-triangle') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('icon-info')).not.toBeInTheDocument(); + }); + }); + + // ── PluginDowngradeWarning ─────────────────────────────────────────────────── + + describe('PluginDowngradeWarning', () => { + it('renders children text', () => { + render( + + Downgrading may break things + + ); + expect( + screen.getByText('Downgrading may break things') + ).toBeInTheDocument(); + }); + + it('renders the AlertTriangle icon', () => { + render(Caution); + expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument(); + }); + + it('does not render OctagonAlert or Info icons', () => { + render(Caution); + expect( + screen.queryByTestId('icon-octagon-alert') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('icon-info')).not.toBeInTheDocument(); + }); + + it('renders JSX children correctly', () => { + render( + + Inner content + + ); + expect(screen.getByTestId('inner')).toBeInTheDocument(); + }); + }); + + // ── PluginInfoNote ─────────────────────────────────────────────────────────── + + describe('PluginInfoNote', () => { + it('renders children text', () => { + render(This is an informational note.); + expect( + screen.getByText('This is an informational note.') + ).toBeInTheDocument(); + }); + + it('renders the Info icon', () => { + render(Note); + expect(screen.getByTestId('icon-info')).toBeInTheDocument(); + }); + + it('does not render OctagonAlert or AlertTriangle icons', () => { + render(Note); + expect( + screen.queryByTestId('icon-octagon-alert') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('icon-alert-triangle') + ).not.toBeInTheDocument(); + }); + + it('renders JSX children correctly', () => { + render( + + details + + ); + expect(screen.getByTestId('info-child')).toBeInTheDocument(); + }); + }); + + // ── PluginRestartWarning ───────────────────────────────────────────────────── + + describe('PluginRestartWarning', () => { + it('renders the restart warning text', () => { + render(); + expect( + screen.getByText(/Importing a plugin may briefly restart the backend/i) + ).toBeInTheDocument(); + }); + + it('mentions temporary disconnect', () => { + render(); + expect( + screen.getByText(/you might see a.*temporary disconnect/i) + ).toBeInTheDocument(); + }); + + it('mentions automatic reconnect', () => { + render(); + expect( + screen.getByText(/the app will.*reconnect automatically/i) + ).toBeInTheDocument(); + }); + + it('renders the AlertTriangle icon', () => { + render(); + expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument(); + }); + + it('does not render OctagonAlert or Info icons', () => { + render(); + expect( + screen.queryByTestId('icon-octagon-alert') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('icon-info')).not.toBeInTheDocument(); + }); + }); + + // ── Shared layout structure ────────────────────────────────────────────────── + + describe('shared layout structure', () => { + it('PluginSecurityWarning renders xs Text', () => { + render(msg); + expect(screen.getByText('msg')).toHaveAttribute('data-size', 'xs'); + }); + + it('PluginSupportDisclaimer renders xs Text', () => { + render(); + const text = screen.getByText( + /Dispatcharr community support cannot assist/i + ); + expect(text).toHaveAttribute('data-size', 'xs'); + }); + + it('PluginDowngradeWarning renders xs Text', () => { + render(msg); + expect(screen.getByText('msg')).toHaveAttribute('data-size', 'xs'); + }); + + it('PluginInfoNote renders xs Text', () => { + render(msg); + expect(screen.getByText('msg')).toHaveAttribute('data-size', 'xs'); + }); + + it('PluginRestartWarning renders xs Text', () => { + render(); + expect(screen.getByText(/Importing a plugin/i)).toHaveAttribute( + 'data-size', + 'xs' + ); + }); + }); +}); diff --git a/frontend/src/components/backups/__tests__/BackupManager.test.jsx b/frontend/src/components/backups/__tests__/BackupManager.test.jsx new file mode 100644 index 00000000..4fe1820c --- /dev/null +++ b/frontend/src/components/backups/__tests__/BackupManager.test.jsx @@ -0,0 +1,1117 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import BackupManager from '../BackupManager'; + +// ── BackupManagerUtils ───────────────────────────────────────────────────────── +vi.mock('../../../utils/components/backups/BackupManagerUtils.js', () => ({ + listBackups: vi.fn(), + getBackupSchedule: vi.fn(), + updateBackupSchedule: vi.fn(), + createBackup: vi.fn(), + uploadBackup: vi.fn(), + downloadBackup: vi.fn(), + restoreBackup: vi.fn(), + deleteBackup: vi.fn(), + to12Hour: vi.fn(), + to24Hour: vi.fn(), + DAYS_OF_WEEK: [ + { value: '0', label: 'Sunday' }, + { value: '1', label: 'Monday' }, + { value: '2', label: 'Tuesday' }, + { value: '3', label: 'Wednesday' }, + { value: '4', label: 'Thursday' }, + { value: '5', label: 'Friday' }, + { value: '6', label: 'Saturday' }, + ], +})); + +// ── hooks ────────────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => ['UTC', vi.fn()]), +})); + +// ── store ────────────────────────────────────────────────────────────────────── +vi.mock('../../../store/warnings', () => ({ + default: vi.fn((sel) => sel({ suppressWarning: vi.fn() })), +})); + +// ── dateTimeUtils ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/dateTimeUtils.js', () => ({ + format: vi.fn(() => '01/01/2024, 10:00:00 AM'), + getDefaultTimeZone: vi.fn(() => 'UTC'), + useDateTimeFormat: vi.fn(() => ({ + fullDateTimeFormat: 'MM/DD/YYYY, HH:mm:ss', + timeFormatSetting: '24h', + })), +})); + +// ── utility functions ────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); +vi.mock('../../../utils/networkUtils.js', () => ({ + formatBytes: vi.fn((bytes) => `${bytes} B`), +})); +vi.mock('../../../utils/cronUtils', () => ({ + validateCronExpression: vi.fn(() => ({ valid: true })), +})); + +// ── CustomTable ──────────────────────────────────────────────────────────────── +vi.mock('../../tables/CustomTable', () => ({ + CustomTable: ({ table }) => ( +
+ {table.__rows?.map((row, i) => ( +
+ {table.__bodyCellRenderFns?.actions?.({ + cell: { column: { id: 'actions' } }, + row, + })} +
+ ))} +
+ ), + useTable: vi.fn(({ data, bodyCellRenderFns }) => ({ + __rows: (data ?? []).map((item) => ({ original: item })), + __bodyCellRenderFns: bodyCellRenderFns, + })), +})); + +// ── ScheduleInput ────────────────────────────────────────────────────────────── +vi.mock('../../forms/ScheduleInput', () => ({ + default: ({ + children, + scheduleType, + onScheduleTypeChange, + cronValue, + onCronChange, + disabled, + }) => ( +
+ {scheduleType === 'cron' ? ( + <> + + + + ) : ( + <> + {children} + {!disabled && ( + + )} + + )} +
+ ), +})); + +// ── ConfirmationDialog ───────────────────────────────────────────────────────── +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ + opened, + onClose, + onConfirm, + title, + message, + confirmLabel, + cancelLabel, + loading, + }) => + opened ? ( +
+
{title}
+
{message}
+ + +
+ ) : null, +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Download: () => , + RefreshCcw: () => , + RotateCcw: () => , + SquareMinus: () => , + SquarePlus: () => , + UploadCloud: () => , +})); + +// ── @mantine/core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, color, loading, disabled }) => ( + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, disabled, loading, color, variant }) => ( + + ), + FileInput: ({ onChange, label, accept }) => ( + + ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Loader: ({ size }) =>
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + NumberInput: ({ value, onChange, label, description, min, disabled }) => ( + + ), + Paper: ({ children }) =>
{children}
, + Select: ({ value, onChange, label, data, disabled }) => ( + + ), + Stack: ({ children }) =>
{children}
, + Switch: ({ checked, onChange, label, disabled }) => ( + + ), + Text: ({ children, size, c, style }) => ( + + {children} + + ), + Tooltip: ({ children, label }) =>
{children}
, +})); + +// ── imports after mocks ──────────────────────────────────────────────────────── +import { + listBackups, + getBackupSchedule, + updateBackupSchedule, + createBackup, + uploadBackup, + downloadBackup, + restoreBackup, + deleteBackup, + to12Hour, + to24Hour, +} from '../../../utils/components/backups/BackupManagerUtils.js'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import { useDateTimeFormat } from '../../../utils/dateTimeUtils.js'; + +// ── fixtures ─────────────────────────────────────────────────────────────────── +const defaultSchedule = { + enabled: true, + frequency: 'daily', + time: '03:00', + day_of_week: 0, + retention_count: 5, + cron_expression: '', +}; + +const defaultBackups = [ + { + name: 'backup-2024-01-01.zip', + size: 1024000, + created: '2024-01-01T10:00:00Z', + }, + { + name: 'backup-2024-01-02.zip', + size: 2048000, + created: '2024-01-02T10:00:00Z', + }, +]; + +const setupMocks = ({ schedule = defaultSchedule, backups = [] } = {}) => { + vi.mocked(listBackups).mockResolvedValue(backups); + vi.mocked(getBackupSchedule).mockResolvedValue(schedule); + vi.mocked(updateBackupSchedule).mockResolvedValue({ ...schedule }); + vi.mocked(createBackup).mockResolvedValue({}); + vi.mocked(uploadBackup).mockResolvedValue({}); + vi.mocked(downloadBackup).mockResolvedValue({}); + vi.mocked(restoreBackup).mockResolvedValue({}); + vi.mocked(deleteBackup).mockResolvedValue({}); + vi.mocked(to12Hour).mockReturnValue({ time: '3:00', period: 'AM' }); + vi.mocked(to24Hour).mockReturnValue('03:00'); +}; + +/** + * Render BackupManager and wait for both initial API calls to settle. + * `to12Hour` is called inside `loadSchedule` after `getBackupSchedule` resolves, + * so its first invocation is a reliable "initial load complete" indicator. + */ +const renderAndLoad = async (opts = {}) => { + setupMocks(opts); + render(); + await waitFor(() => expect(vi.mocked(to12Hour)).toHaveBeenCalled()); +}; + +// ───────────────────────────────────────────────────────────────────────────── + +describe('BackupManager', () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: { reload: vi.fn() }, + }); + }); + + // ── Initial render and data loading ──────────────────────────────────────── + + describe('initial data loading', () => { + it('renders "Scheduled Backups" heading', async () => { + await renderAndLoad(); + expect(screen.getByText('Scheduled Backups')).toBeInTheDocument(); + }); + + it('calls listBackups on mount', async () => { + await renderAndLoad(); + expect(vi.mocked(listBackups)).toHaveBeenCalledTimes(1); + }); + + it('calls getBackupSchedule on mount', async () => { + await renderAndLoad(); + expect(vi.mocked(getBackupSchedule)).toHaveBeenCalledTimes(1); + }); + + it('calls to12Hour with the loaded schedule time', async () => { + await renderAndLoad(); + expect(vi.mocked(to12Hour)).toHaveBeenCalledWith(defaultSchedule.time); + }); + + it('sets scheduleType to "cron" when loaded schedule has a cron_expression', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, cron_expression: '0 3 * * *' }, + }); + expect(screen.getByTestId('cron-input')).toBeInTheDocument(); + }); + + it('sets scheduleType to "interval" when loaded schedule has no cron_expression', async () => { + await renderAndLoad(); + expect(screen.getByTestId('switch-to-cron')).toBeInTheDocument(); + }); + + it('handles getBackupSchedule failure silently without showing a notification', async () => { + vi.mocked(getBackupSchedule).mockRejectedValue( + new Error('Network error') + ); + vi.mocked(listBackups).mockResolvedValue([]); + vi.mocked(to12Hour).mockReturnValue({ time: '3:00', period: 'AM' }); + render(); + await waitFor(() => expect(vi.mocked(listBackups)).toHaveBeenCalled()); + expect(vi.mocked(showNotification)).not.toHaveBeenCalled(); + }); + + it('shows an error notification when listBackups fails', async () => { + vi.mocked(listBackups).mockRejectedValue(new Error('Failed')); + vi.mocked(getBackupSchedule).mockResolvedValue(defaultSchedule); + vi.mocked(to12Hour).mockReturnValue({ time: '3:00', period: 'AM' }); + render(); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ) + ); + }); + }); + + // ── Backup list display ───────────────────────────────────────────────────── + + describe('backup list display', () => { + it('shows "No backups found" when list is empty', async () => { + await renderAndLoad({ backups: [] }); + expect(screen.getByText(/No backups found/)).toBeInTheDocument(); + }); + + it('renders CustomTable when backups are present', async () => { + await renderAndLoad({ backups: defaultBackups }); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('renders one table row per backup', async () => { + await renderAndLoad({ backups: defaultBackups }); + expect(screen.getAllByTestId('table-row')).toHaveLength( + defaultBackups.length + ); + }); + + it('does not render CustomTable when list is empty', async () => { + await renderAndLoad({ backups: [] }); + expect(screen.queryByTestId('custom-table')).not.toBeInTheDocument(); + }); + }); + + // ── Create backup ─────────────────────────────────────────────────────────── + + describe('create backup', () => { + it('calls createBackup when "Create Backup" button is clicked', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Create Backup')); + await waitFor(() => + expect(vi.mocked(createBackup)).toHaveBeenCalledTimes(1) + ); + }); + + it('shows success notification after creating backup', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Create Backup')); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Success', color: 'green' }) + ) + ); + }); + + it('refreshes backup list after creating backup', async () => { + await renderAndLoad(); + vi.mocked(listBackups).mockClear(); + fireEvent.click(screen.getByText('Create Backup')); + await waitFor(() => + expect(vi.mocked(listBackups)).toHaveBeenCalledTimes(1) + ); + }); + + it('shows error notification when createBackup fails', async () => { + await renderAndLoad(); + vi.mocked(createBackup).mockRejectedValue(new Error('Server error')); + fireEvent.click(screen.getByText('Create Backup')); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ) + ); + }); + }); + + // ── Refresh ───────────────────────────────────────────────────────────────── + + describe('refresh', () => { + it('calls listBackups again when Refresh is clicked', async () => { + await renderAndLoad(); + vi.mocked(listBackups).mockClear(); + fireEvent.click(screen.getByText('Refresh')); + await waitFor(() => + expect(vi.mocked(listBackups)).toHaveBeenCalledTimes(1) + ); + }); + }); + + // ── Download backup ───────────────────────────────────────────────────────── + + describe('download backup', () => { + it('calls downloadBackup with the correct filename', async () => { + await renderAndLoad({ backups: defaultBackups }); + const downloadBtn = screen + .getAllByTestId('icon-download')[0] + .closest('button'); + fireEvent.click(downloadBtn); + await waitFor(() => + expect(vi.mocked(downloadBackup)).toHaveBeenCalledWith( + defaultBackups[0].name + ) + ); + }); + + it('shows "Download Started" notification', async () => { + await renderAndLoad({ backups: defaultBackups }); + const downloadBtn = screen + .getAllByTestId('icon-download')[0] + .closest('button'); + fireEvent.click(downloadBtn); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Download Started', color: 'blue' }) + ) + ); + }); + + it('shows error notification when downloadBackup fails', async () => { + await renderAndLoad({ backups: defaultBackups }); + vi.mocked(downloadBackup).mockRejectedValue(new Error('Network error')); + const downloadBtn = screen + .getAllByTestId('icon-download')[0] + .closest('button'); + fireEvent.click(downloadBtn); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ) + ); + }); + }); + + // ── Delete backup ─────────────────────────────────────────────────────────── + + describe('delete backup', () => { + const openDeleteDialog = async () => { + await renderAndLoad({ backups: defaultBackups }); + const deleteBtn = screen + .getAllByTestId('icon-delete')[0] + .closest('button'); + fireEvent.click(deleteBtn); + }; + + it('opens delete ConfirmationDialog when delete action is clicked', async () => { + await openDeleteDialog(); + expect(screen.getByTestId('dialog-title')).toHaveTextContent( + 'Delete Backup' + ); + }); + + it('shows the backup filename in the delete dialog message', async () => { + await openDeleteDialog(); + expect(screen.getByTestId('dialog-message')).toHaveTextContent( + defaultBackups[0].name + ); + }); + + it('calls deleteBackup with the filename when confirmed', async () => { + await openDeleteDialog(); + fireEvent.click(screen.getByTestId('dialog-confirm')); + await waitFor(() => + expect(vi.mocked(deleteBackup)).toHaveBeenCalledWith( + defaultBackups[0].name + ) + ); + }); + + it('refreshes backup list after deletion', async () => { + await openDeleteDialog(); + vi.mocked(listBackups).mockClear(); + fireEvent.click(screen.getByTestId('dialog-confirm')); + await waitFor(() => + expect(vi.mocked(listBackups)).toHaveBeenCalledTimes(1) + ); + }); + + it('closes dialog after confirming deletion', async () => { + await openDeleteDialog(); + fireEvent.click(screen.getByTestId('dialog-confirm')); + await waitFor(() => + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument() + ); + }); + + it('closes dialog when Cancel is clicked', async () => { + await openDeleteDialog(); + fireEvent.click(screen.getByTestId('dialog-cancel')); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + + it('shows error notification when deleteBackup fails', async () => { + await openDeleteDialog(); + vi.mocked(deleteBackup).mockRejectedValue(new Error('Server error')); + fireEvent.click(screen.getByTestId('dialog-confirm')); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ) + ); + }); + }); + + // ── Restore backup ────────────────────────────────────────────────────────── + + describe('restore backup', () => { + const openRestoreDialog = async () => { + await renderAndLoad({ backups: defaultBackups }); + const restoreBtn = screen + .getAllByTestId('icon-restore')[0] + .closest('button'); + fireEvent.click(restoreBtn); + }; + + it('opens restore ConfirmationDialog when restore action is clicked', async () => { + await openRestoreDialog(); + expect(screen.getByTestId('dialog-title')).toHaveTextContent( + 'Restore Backup' + ); + }); + + it('shows the backup filename in the restore dialog message', async () => { + await openRestoreDialog(); + expect(screen.getByTestId('dialog-message')).toHaveTextContent( + defaultBackups[0].name + ); + }); + + it('calls restoreBackup with the filename when confirmed', async () => { + await openRestoreDialog(); + fireEvent.click(screen.getByTestId('dialog-confirm')); + await waitFor(() => + expect(vi.mocked(restoreBackup)).toHaveBeenCalledWith( + defaultBackups[0].name + ) + ); + }); + + it('shows "Restore Complete" notification after successful restore', async () => { + await openRestoreDialog(); + fireEvent.click(screen.getByTestId('dialog-confirm')); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Restore Complete', color: 'green' }) + ) + ); + }); + + it('schedules window.location.reload 4 seconds after restore', async () => { + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + await openRestoreDialog(); + fireEvent.click(screen.getByTestId('dialog-confirm')); + await waitFor(() => expect(vi.mocked(restoreBackup)).toHaveBeenCalled()); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 4000); + setTimeoutSpy.mockRestore(); + }); + + it('closes dialog when Cancel is clicked', async () => { + await openRestoreDialog(); + fireEvent.click(screen.getByTestId('dialog-cancel')); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + + it('shows error notification when restoreBackup fails', async () => { + await openRestoreDialog(); + vi.mocked(restoreBackup).mockRejectedValue(new Error('Restore failed')); + fireEvent.click(screen.getByTestId('dialog-confirm')); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ) + ); + }); + }); + + // ── Upload backup ─────────────────────────────────────────────────────────── + + describe('upload backup', () => { + it('opens upload modal when Upload button is clicked', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Upload')); + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Upload Backup' + ); + }); + + it('closes upload modal when × is clicked', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Upload')); + fireEvent.click(screen.getByTestId('modal-close')); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('closes upload modal when Cancel is clicked', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Upload')); + fireEvent.click(screen.getByText('Cancel')); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('submit Upload button is disabled when no file is selected', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Upload')); + // First "Upload" opens the modal; second is the submit button inside the modal + const submitBtn = screen.getAllByText('Upload')[1]; + expect(submitBtn).toBeDisabled(); + }); + + it('calls uploadBackup with the selected file when submitted', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Upload')); + + const file = new File(['backup data'], 'backup.zip', { + type: 'application/zip', + }); + const fileInput = screen.getByTestId('file-input'); + Object.defineProperty(fileInput, 'files', { + value: [file], + configurable: true, + }); + fireEvent.change(fileInput); + + fireEvent.click(screen.getAllByText('Upload')[1]); + await waitFor(() => + expect(vi.mocked(uploadBackup)).toHaveBeenCalledWith(file) + ); + }); + + it('closes modal and refreshes list after successful upload', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Upload')); + + const file = new File(['data'], 'backup.zip', { + type: 'application/zip', + }); + const fileInput = screen.getByTestId('file-input'); + Object.defineProperty(fileInput, 'files', { + value: [file], + configurable: true, + }); + fireEvent.change(fileInput); + + vi.mocked(listBackups).mockClear(); + fireEvent.click(screen.getAllByText('Upload')[1]); + + await waitFor(() => { + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + expect(vi.mocked(listBackups)).toHaveBeenCalledTimes(1); + }); + }); + + it('shows success notification after upload', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByText('Upload')); + + const file = new File(['data'], 'backup.zip', { + type: 'application/zip', + }); + const fileInput = screen.getByTestId('file-input'); + Object.defineProperty(fileInput, 'files', { + value: [file], + configurable: true, + }); + fireEvent.change(fileInput); + fireEvent.click(screen.getAllByText('Upload')[1]); + + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Success', color: 'green' }) + ) + ); + }); + + it('shows error notification when uploadBackup fails', async () => { + await renderAndLoad(); + vi.mocked(uploadBackup).mockRejectedValue(new Error('Upload failed')); + fireEvent.click(screen.getByText('Upload')); + + const file = new File(['data'], 'backup.zip', { + type: 'application/zip', + }); + const fileInput = screen.getByTestId('file-input'); + Object.defineProperty(fileInput, 'files', { + value: [file], + configurable: true, + }); + fireEvent.change(fileInput); + fireEvent.click(screen.getAllByText('Upload')[1]); + + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ) + ); + }); + }); + + // ── Schedule saving ───────────────────────────────────────────────────────── + + describe('schedule saving', () => { + it('Save button is disabled before any schedule field changes', async () => { + await renderAndLoad(); + expect(screen.getByText('Save')).toBeDisabled(); + }); + + it('Save button becomes enabled after a schedule field changes', async () => { + await renderAndLoad(); + fireEvent.change(screen.getByLabelText('Frequency'), { + target: { value: 'weekly' }, + }); + expect(screen.getByText('Save')).not.toBeDisabled(); + }); + + it('calls updateBackupSchedule when Save is clicked', async () => { + await renderAndLoad(); + fireEvent.change(screen.getByLabelText('Frequency'), { + target: { value: 'weekly' }, + }); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(vi.mocked(updateBackupSchedule)).toHaveBeenCalledTimes(1) + ); + }); + + it('sends schedule with an empty cron_expression in interval mode', async () => { + await renderAndLoad(); + fireEvent.change(screen.getByLabelText('Frequency'), { + target: { value: 'weekly' }, + }); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(vi.mocked(updateBackupSchedule)).toHaveBeenCalledWith( + expect.objectContaining({ cron_expression: '' }) + ) + ); + }); + + it('sends schedule with cron_expression intact in cron mode', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, cron_expression: '0 3 * * *' }, + }); + fireEvent.change(screen.getByTestId('cron-input'), { + target: { value: '0 4 * * *' }, + }); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(vi.mocked(updateBackupSchedule)).toHaveBeenCalledWith( + expect.objectContaining({ cron_expression: '0 4 * * *' }) + ) + ); + }); + + it('shows success notification after saving schedule', async () => { + await renderAndLoad(); + fireEvent.change(screen.getByLabelText('Frequency'), { + target: { value: 'weekly' }, + }); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Success', + message: 'Backup schedule saved', + }) + ) + ); + }); + + it('shows error notification when saving schedule fails', async () => { + await renderAndLoad(); + vi.mocked(updateBackupSchedule).mockRejectedValue( + new Error('Save failed') + ); + fireEvent.change(screen.getByLabelText('Frequency'), { + target: { value: 'weekly' }, + }); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(vi.mocked(showNotification)).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ) + ); + }); + + it('Save button is disabled again after a successful save', async () => { + await renderAndLoad(); + fireEvent.change(screen.getByLabelText('Frequency'), { + target: { value: 'weekly' }, + }); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(vi.mocked(updateBackupSchedule)).toHaveBeenCalled() + ); + await waitFor(() => expect(screen.getByText('Save')).toBeDisabled()); + }); + }); + + // ── Schedule enabled switch ───────────────────────────────────────────────── + + describe('schedule enabled switch', () => { + it('shows "Enabled" label when schedule.enabled is true', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, enabled: true }, + }); + expect(screen.getByText('Enabled')).toBeInTheDocument(); + }); + + it('shows "Disabled" label when schedule.enabled is false', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, enabled: false }, + }); + expect(screen.getByText('Disabled')).toBeInTheDocument(); + }); + + it('toggles label from "Enabled" to "Disabled" when switch is unchecked', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, enabled: true }, + }); + fireEvent.click(screen.getByTestId('schedule-switch')); + await waitFor(() => + expect(screen.getByText('Disabled')).toBeInTheDocument() + ); + }); + + it('toggles label from "Disabled" to "Enabled" when switch is checked', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, enabled: false }, + }); + fireEvent.click(screen.getByTestId('schedule-switch')); + await waitFor(() => + expect(screen.getByText('Enabled')).toBeInTheDocument() + ); + }); + }); + + // ── 12-hour vs 24-hour time display ───────────────────────────────────────── + + describe('time format display', () => { + it('shows Hour, Minute, and Period selects in 12h mode', async () => { + vi.mocked(useDateTimeFormat).mockReturnValue({ + fullDateTimeFormat: 'MM/DD/YYYY, h:mm:ss A', + timeFormatSetting: '12h', + }); + setupMocks(); + render(); + await waitFor(() => expect(vi.mocked(to12Hour)).toHaveBeenCalled()); + + expect(screen.getByLabelText('Hour')).toBeInTheDocument(); + expect(screen.getByLabelText('Minute')).toBeInTheDocument(); + expect(screen.getByLabelText('Period')).toBeInTheDocument(); + }); + + it('does not show a Period select in 24h mode', async () => { + vi.mocked(useDateTimeFormat).mockReturnValue({ + fullDateTimeFormat: 'MM/DD/YYYY, HH:mm:ss', + timeFormatSetting: '24h', + }); + await renderAndLoad(); + expect(screen.queryByLabelText('Period')).not.toBeInTheDocument(); + }); + + it('Hour select in 24h mode contains 24 options (00–23)', async () => { + vi.mocked(useDateTimeFormat).mockReturnValue({ + fullDateTimeFormat: 'MM/DD/YYYY, HH:mm:ss', + timeFormatSetting: '24h', + }); + await renderAndLoad(); + const hourSelect = screen.getByLabelText('Hour'); + expect(hourSelect.querySelectorAll('option')).toHaveLength(24); + }); + + it('Hour select in 12h mode contains 12 options (1–12)', async () => { + vi.mocked(useDateTimeFormat).mockReturnValue({ + fullDateTimeFormat: 'MM/DD/YYYY, h:mm:ss A', + timeFormatSetting: '12h', + }); + setupMocks(); + render(); + await waitFor(() => expect(vi.mocked(to12Hour)).toHaveBeenCalled()); + + const hourSelect = screen.getByLabelText('Hour'); + expect(hourSelect.querySelectorAll('option')).toHaveLength(12); + }); + + it('Minute select contains 60 options (00–59)', async () => { + await renderAndLoad(); + const minuteSelect = screen.getByLabelText('Minute'); + expect(minuteSelect.querySelectorAll('option')).toHaveLength(60); + }); + }); + + // ── Weekly frequency / Day selector ───────────────────────────────────────── + + describe('weekly frequency', () => { + it('shows Day select when frequency is "weekly"', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, frequency: 'weekly' }, + }); + expect(screen.getByLabelText('Day')).toBeInTheDocument(); + }); + + it('does not show Day select when frequency is "daily"', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, frequency: 'daily' }, + }); + expect(screen.queryByLabelText('Day')).not.toBeInTheDocument(); + }); + + it('shows Day select after switching from daily to weekly', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, frequency: 'daily' }, + }); + fireEvent.change(screen.getByLabelText('Frequency'), { + target: { value: 'weekly' }, + }); + expect(screen.getByLabelText('Day')).toBeInTheDocument(); + }); + + it('hides Day select after switching from weekly to daily', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, frequency: 'weekly' }, + }); + fireEvent.change(screen.getByLabelText('Frequency'), { + target: { value: 'daily' }, + }); + expect(screen.queryByLabelText('Day')).not.toBeInTheDocument(); + }); + + it('Day select contains all 7 days of the week', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, frequency: 'weekly' }, + }); + expect( + screen.getByLabelText('Day').querySelectorAll('option') + ).toHaveLength(7); + }); + }); + + // ── Timezone info text ─────────────────────────────────────────────────────── + + describe('timezone info text', () => { + it('shows timezone info when schedule is enabled and in interval mode', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, enabled: true, cron_expression: '' }, + }); + expect(screen.getByText(/System Timezone/)).toBeInTheDocument(); + }); + + it('does not show timezone info when schedule is disabled', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, enabled: false, cron_expression: '' }, + }); + expect(screen.queryByText(/System Timezone/)).not.toBeInTheDocument(); + }); + + it('does not show timezone info in cron mode', async () => { + await renderAndLoad({ + schedule: { + ...defaultSchedule, + enabled: true, + cron_expression: '0 3 * * *', + }, + }); + expect(screen.queryByText(/System Timezone/)).not.toBeInTheDocument(); + }); + + it('includes the user timezone string in the info text', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, enabled: true, cron_expression: '' }, + }); + // useLocalStorage mock returns 'UTC' + expect(screen.getByText(/UTC/)).toBeInTheDocument(); + }); + }); + + // ── Schedule type switching ────────────────────────────────────────────────── + + describe('schedule type switching', () => { + it('switches to cron mode when "Use custom cron schedule" is clicked', async () => { + await renderAndLoad(); + fireEvent.click(screen.getByTestId('switch-to-cron')); + expect(screen.getByTestId('cron-input')).toBeInTheDocument(); + }); + + it('switches back to interval mode when "Use simple schedule" is clicked', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, cron_expression: '0 3 * * *' }, + }); + fireEvent.click(screen.getByTestId('switch-to-interval')); + expect(screen.queryByTestId('cron-input')).not.toBeInTheDocument(); + expect(screen.getByTestId('switch-to-cron')).toBeInTheDocument(); + }); + + it('clears cron_expression when switching back to interval and saving', async () => { + await renderAndLoad({ + schedule: { ...defaultSchedule, cron_expression: '0 3 * * *' }, + }); + fireEvent.click(screen.getByTestId('switch-to-interval')); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(vi.mocked(updateBackupSchedule)).toHaveBeenCalledWith( + expect.objectContaining({ cron_expression: '' }) + ) + ); + }); + }); +}); diff --git a/frontend/src/components/cards/__tests__/AvailablePluginCard.test.jsx b/frontend/src/components/cards/__tests__/AvailablePluginCard.test.jsx new file mode 100644 index 00000000..027444ea --- /dev/null +++ b/frontend/src/components/cards/__tests__/AvailablePluginCard.test.jsx @@ -0,0 +1,1324 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import AvailablePluginCard from '../AvailablePluginCard'; + +// ── Router ───────────────────────────────────────────────────────────────────── +vi.mock('react-router-dom', () => ({ + useNavigate: vi.fn(), + useLocation: vi.fn(), +})); + +// ── Plugin store ─────────────────────────────────────────────────────────────── +vi.mock('../../../store/plugins', () => { + const mockUsePluginStore = vi.fn(); + mockUsePluginStore.getState = vi.fn(); + return { usePluginStore: mockUsePluginStore }; +}); + +// ── PluginWarnings ───────────────────────────────────────────────────────────── +vi.mock('../../PluginWarnings.jsx', () => ({ + PluginDowngradeWarning: ({ children }) => ( +
{children}
+ ), + PluginInfoNote: ({ children }) => ( +
{children}
+ ), + PluginSecurityWarning: ({ children }) => ( +
{children}
+ ), + PluginSupportDisclaimer: () =>
, +})); + +// ── PluginDetailPanel ────────────────────────────────────────────────────────── +vi.mock('../../PluginDetailPanel.jsx', () => ({ + default: ({ onInstall, onUninstall }) => ( +
+ + +
+ ), +})); + +// ── pluginUtils ──────────────────────────────────────────────────────────────── +vi.mock('../../../utils/components/pluginUtils.js', () => ({ + buildCompatibilityTooltip: vi.fn(), + compareVersions: vi.fn(), + getInstallInfo: vi.fn(), +})); + +// ── PluginsUtils ─────────────────────────────────────────────────────────────── +vi.mock('../../../utils/pages/PluginsUtils.js', () => ({ + deletePluginByKey: vi.fn(), + getPluginDetailManifest: vi.fn(), + setPluginEnabled: vi.fn(), +})); + +// ── SizedInstallButton ───────────────────────────────────────────────────────── +vi.mock('../../theme/SizedInstallButton.jsx', () => ({ + default: ({ children, onClick, disabled, loading, latest_size }) => ( + + ), +})); + +// ── @mantine/core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Avatar: ({ children, src, alt }) => ( +
+ {src ? {alt} : children} +
+ ), + Badge: ({ children, color, component, href, leftSection }) => + component === 'a' ? ( + + {leftSection} + {children} + + ) : ( + + {leftSection} + {children} + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, disabled, loading, color, variant }) => ( + + ), + Card: ({ children, style }) => ( +
+ {children} +
+ ), + Group: ({ children, style }) =>
{children}
, + Loader: () => , + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Stack: ({ children }) =>
{children}
, + Switch: ({ checked, onChange }) => ( + + ), + Text: ({ children, fw, style }) => ( + + {children} + + ), + Tooltip: ({ children, label }) =>
{children}
, +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + AlertTriangle: () => , + Ban: () => , + Check: () => , + Download: () => , + FlaskConical: () => , + Info: () => , + RefreshCw: () => , + RotateCcw: () => , + ShieldAlert: () => , + ShieldCheck: () => , + Trash2: () => , +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import { useNavigate, useLocation } from 'react-router-dom'; +import { usePluginStore } from '../../../store/plugins'; +import { + buildCompatibilityTooltip, + compareVersions, + getInstallInfo, +} from '../../../utils/components/pluginUtils.js'; +import * as PluginsUtils from '../../../utils/pages/PluginsUtils.js'; + +// ────────────────────────────────────────────────────────────────────────────── +// Factories +// ────────────────────────────────────────────────────────────────────────────── +const makePlugin = (overrides = {}) => ({ + slug: 'test-plugin', + name: 'Test Plugin', + author: 'Test Author', + description: 'A test plugin description', + latest_version: '1.0.0', + latest_url: 'https://example.com/plugin.zip', + latest_sha256: 'abc123', + latest_size: 1024, + install_status: 'not_installed', + installed: false, + installed_version: null, + repo_id: 1, + repo_name: 'Test Repo', + is_official_repo: false, + manifest_url: null, + deprecated: false, + license: 'MIT', + last_updated: '2024-01-01T00:00:00Z', + icon_url: null, + signature_verified: null, + key: null, + ...overrides, +}); + +const APP_VERSION = '1.0.0'; + +// ────────────────────────────────────────────────────────────────────────────── +// Mock helpers +// ────────────────────────────────────────────────────────────────────────────── +const setupMocks = ({ pathname = '/available-plugins' } = {}) => { + const mockNavigate = vi.fn(); + vi.mocked(useNavigate).mockReturnValue(mockNavigate); + vi.mocked(useLocation).mockReturnValue({ pathname }); + + const mockInstallPlugin = vi.fn().mockResolvedValue({ + success: true, + plugin: { key: 'test-plugin', enabled: true }, + }); + const mockInvalidatePlugins = vi.fn(); + const mockFetchAvailablePlugins = vi.fn(); + + vi.mocked(usePluginStore).mockImplementation((sel) => + sel({ installPlugin: mockInstallPlugin }) + ); + usePluginStore.getState.mockReturnValue({ + invalidatePlugins: mockInvalidatePlugins, + fetchAvailablePlugins: mockFetchAvailablePlugins, + }); + + // Default: versions are compatible, no downgrade, no bad signature + vi.mocked(compareVersions).mockReturnValue(0); + vi.mocked(buildCompatibilityTooltip).mockReturnValue('1.0.0 or newer'); + vi.mocked(getInstallInfo).mockReturnValue({ + isDowngrade: false, + isUpdate: false, + isBadSig: false, + }); + + vi.mocked(PluginsUtils.getPluginDetailManifest).mockResolvedValue(null); + vi.mocked(PluginsUtils.deletePluginByKey).mockResolvedValue({ + success: true, + }); + vi.mocked(PluginsUtils.setPluginEnabled).mockResolvedValue(undefined); + + return { + mockNavigate, + mockInstallPlugin, + mockInvalidatePlugins, + mockFetchAvailablePlugins, + }; +}; + +/** + * Finds the confirm/action button inside the modal (not the card's SizedInstallButton). + * Both share the same text in many tests, so we check by data-testid absence. + */ +const getModalActionButton = (text) => + screen + .getAllByText(text) + .find((el) => el.tagName === 'BUTTON' && !el.dataset.testid); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('AvailablePluginCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the plugin name', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Test Plugin')).toBeInTheDocument(); + }); + + it('renders the plugin description', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('A test plugin description')).toBeInTheDocument(); + }); + + it('renders the plugin author', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Test Author')).toBeInTheDocument(); + }); + + it('renders the latest version badge', () => { + setupMocks(); + render( + + ); + expect(screen.getByText(/1\.0\.0/)).toBeInTheDocument(); + }); + + it('renders the license badge', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('MIT')).toBeInTheDocument(); + }); + + it('renders a last_updated badge', () => { + setupMocks(); + render( + + ); + // The date badge is rendered from last_updated via toLocaleDateString() + expect( + screen.getByText(new Date('2024-01-01T00:00:00Z').toLocaleDateString()) + ).toBeInTheDocument(); + }); + + it('renders the More Info button', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('More Info')).toBeInTheDocument(); + }); + + it('renders Install button for not_installed plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByTestId('sized-install-button')).toHaveTextContent( + 'Install' + ); + }); + + it('renders Update button for update_available plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByTestId('sized-install-button')).toHaveTextContent( + 'Update' + ); + }); + + it('renders Uninstall button for installed plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Uninstall')).toBeInTheDocument(); + }); + + it('renders Overwrite button for unmanaged plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByTestId('sized-install-button')).toHaveTextContent( + 'Overwrite' + ); + }); + + it('renders Overwrite button for different_repo plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByTestId('sized-install-button')).toHaveTextContent( + 'Overwrite' + ); + }); + + it('does not render a sized install button when latest_url is null', () => { + setupMocks(); + render( + + ); + expect( + screen.queryByTestId('sized-install-button') + ).not.toBeInTheDocument(); + }); + + it('renders min version badge when min_dispatcharr_version is set', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('0.9.0')).toBeInTheDocument(); + }); + + it('renders max version badge when max_dispatcharr_version is set', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('2.0.0')).toBeInTheDocument(); + }); + + it('renders Downgrade button for update_available when isLatestDowngrade', () => { + setupMocks(); + // compareVersions(latest, installed) < 0 → isLatestDowngrade + vi.mocked(compareVersions).mockReturnValue(-1); + render( + + ); + expect(screen.getByTestId('sized-install-button')).toHaveTextContent( + 'Downgrade' + ); + }); + }); + + // ── RepoBadge ────────────────────────────────────────────────────────────── + + describe('RepoBadge', () => { + it('shows "Official Repo" badge when is_official_repo is true', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Official Repo')).toBeInTheDocument(); + }); + + it('shows community repo name badge when is_official_repo is false', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Test Repo')).toBeInTheDocument(); + }); + + it('shows ShieldCheck icon when signature_verified is true', () => { + setupMocks(); + render( + + ); + expect(screen.getByTestId('icon-shield-check')).toBeInTheDocument(); + }); + + it('shows ShieldAlert icon when signature_verified is false', () => { + setupMocks(); + render( + + ); + expect(screen.getByTestId('icon-shield-alert')).toBeInTheDocument(); + }); + + it('shows no shield icon when signature_verified is null', () => { + setupMocks(); + render( + + ); + expect(screen.queryByTestId('icon-shield-check')).not.toBeInTheDocument(); + expect(screen.queryByTestId('icon-shield-alert')).not.toBeInTheDocument(); + }); + }); + + // ── StatusBadge ──────────────────────────────────────────────────────────── + + describe('StatusBadge', () => { + it('shows Installed badge for installed plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Installed')).toBeInTheDocument(); + }); + + it('shows prerelease Installed badge when installed_version_is_prerelease', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Prerelease')).toBeInTheDocument(); + }); + + it('shows Update Available badge for update_available plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Update Available')).toBeInTheDocument(); + }); + + it('shows Deprecated badge for not-installed deprecated plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Deprecated')).toBeInTheDocument(); + }); + + it('shows Installed badge for unmanaged plugin', () => { + setupMocks(); + render( + + ); + expect(screen.getByText('Installed')).toBeInTheDocument(); + }); + }); + + // ── Compatibility ────────────────────────────────────────────────────────── + + describe('version compatibility', () => { + it('shows compatibility warning when min version is not met', () => { + // compareVersions returns negative → appVersion < min → meetsMinVersion = false + vi.mocked(compareVersions).mockReturnValue(-1); + render( + + ); + expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument(); + }); + + it('disables install button when version is incompatible', () => { + vi.mocked(compareVersions).mockReturnValue(-1); + render( + + ); + expect(screen.getByTestId('sized-install-button')).toBeDisabled(); + }); + + it('does not show compatibility warning when version is met', () => { + setupMocks(); + render( + + ); + expect( + screen.queryByTestId('icon-alert-triangle') + ).not.toBeInTheDocument(); + }); + }); + + // ── More Info modal ──────────────────────────────────────────────────────── + + describe('More Info modal', () => { + it('opens detail modal when More Info is clicked', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('More Info')); + expect(screen.getByTestId('plugin-detail-panel')).toBeInTheDocument(); + }); + + it('does not call getPluginDetailManifest when manifest_url is null', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('More Info')); + expect(PluginsUtils.getPluginDetailManifest).not.toHaveBeenCalled(); + }); + + it('calls getPluginDetailManifest with correct args when manifest_url is set', async () => { + setupMocks(); + const plugin = makePlugin({ + manifest_url: 'https://example.com/manifest.json', + }); + render(); + fireEvent.click(screen.getByText('More Info')); + await waitFor(() => { + expect(PluginsUtils.getPluginDetailManifest).toHaveBeenCalledWith( + 1, + 'https://example.com/manifest.json' + ); + }); + }); + + it('closes detail modal when modal close button is clicked', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('More Info')); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('calls onDetailClose when detail modal is closed', () => { + setupMocks(); + const onDetailClose = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('More Info')); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onDetailClose).toHaveBeenCalled(); + }); + + it('triggers install flow from PluginDetailPanel', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('More Info')); + fireEvent.click(screen.getByTestId('detail-install')); + expect(screen.getAllByTestId('modal-title').at(-1)).toHaveTextContent( + 'Confirm Install' + ); + }); + + it('triggers uninstall flow from PluginDetailPanel', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('More Info')); + fireEvent.click(screen.getByTestId('detail-uninstall')); + expect(screen.getAllByTestId('modal-title').at(-1)).toHaveTextContent( + 'Uninstall Plugin' + ); + }); + }); + + // ── autoOpenDetail ───────────────────────────────────────────────────────── + + describe('autoOpenDetail', () => { + it('immediately opens detail modal when autoOpenDetail is true', () => { + setupMocks(); + render( + + ); + expect(screen.getByTestId('plugin-detail-panel')).toBeInTheDocument(); + }); + }); + + // ── Install flow ─────────────────────────────────────────────────────────── + + describe('install flow', () => { + it('opens confirm modal when Install button is clicked', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Confirm Install' + ); + }); + + it('shows plugin name inside confirm modal', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getAllByText(/Test Plugin/).length).toBeGreaterThan(0); + }); + + it('shows security warning in confirm modal', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getByTestId('security-warning')).toBeInTheDocument(); + }); + + it('Cancel closes confirm modal without calling installPlugin', () => { + const { mockInstallPlugin } = setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(screen.getByText('Cancel')); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + expect(mockInstallPlugin).not.toHaveBeenCalled(); + }); + + it('calls installPlugin with correct params after confirmation', async () => { + const { mockInstallPlugin } = setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(mockInstallPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + repo_id: 1, + slug: 'test-plugin', + version: '1.0.0', + download_url: 'https://example.com/plugin.zip', + }) + ); + }); + }); + + it('calls onBeforeInstall with plugin slug before installing', async () => { + setupMocks(); + const onBeforeInstall = vi.fn(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(onBeforeInstall).toHaveBeenCalledWith('test-plugin'); + }); + }); + + it('calls onInstalled with plugin slug after successful install', async () => { + setupMocks(); + const onInstalled = vi.fn(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(onInstalled).toHaveBeenCalledWith('test-plugin'); + }); + }); + + it('shows restart prompt with "Plugin Installed" title after successful install', async () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Plugin Installed' + ); + }); + }); + + it('Done button in restart prompt closes it', async () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(screen.getByText('Done')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText('Done')); + await waitFor(() => { + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + it('"Go to My Plugins" button navigates to /plugins', async () => { + const { mockNavigate } = setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(screen.getByText('Go to My Plugins')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText('Go to My Plugins')); + expect(mockNavigate).toHaveBeenCalledWith('/plugins'); + }); + + it('does not show "Go to My Plugins" when already on /plugins path', async () => { + setupMocks({ pathname: '/plugins' }); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(screen.getByText('Done')).toBeInTheDocument(); + }); + expect(screen.queryByText('Go to My Plugins')).not.toBeInTheDocument(); + }); + + it('does not open restart prompt when installPlugin returns no success', async () => { + const { mockInstallPlugin } = setupMocks(); + mockInstallPlugin.mockResolvedValue({ success: false }); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(mockInstallPlugin).toHaveBeenCalled(); + }); + expect(screen.queryByText('Plugin Installed')).not.toBeInTheDocument(); + }); + }); + + // ── Enable now ───────────────────────────────────────────────────────────── + + describe('enable now (plugin installed as disabled)', () => { + const installDisabledPlugin = async (mockInstallPlugin) => { + mockInstallPlugin.mockResolvedValue({ + success: true, + plugin: { key: 'test-plugin', enabled: false }, + }); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(screen.getByTestId('enable-switch')).toBeInTheDocument(); + }); + }; + + it('shows enable switch in restart prompt when plugin is installed disabled', async () => { + const { mockInstallPlugin } = setupMocks(); + await installDisabledPlugin(mockInstallPlugin); + expect(screen.getByTestId('enable-switch')).toBeInTheDocument(); + }); + + it('calls setPluginEnabled when Done is clicked with enable switch toggled on', async () => { + const { mockInstallPlugin } = setupMocks(); + await installDisabledPlugin(mockInstallPlugin); + fireEvent.click(screen.getByTestId('enable-switch')); + fireEvent.click(screen.getByText('Done')); + await waitFor(() => { + expect(PluginsUtils.setPluginEnabled).toHaveBeenCalledWith( + 'test-plugin', + true + ); + }); + }); + + it('does not call setPluginEnabled when enable switch is left off', async () => { + const { mockInstallPlugin } = setupMocks(); + await installDisabledPlugin(mockInstallPlugin); + // Do NOT toggle the switch + fireEvent.click(screen.getByText('Done')); + await waitFor(() => { + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + expect(PluginsUtils.setPluginEnabled).not.toHaveBeenCalled(); + }); + }); + + // ── Deprecated plugin ────────────────────────────────────────────────────── + + describe('deprecated plugin', () => { + it('shows deprecation warning modal when Install is clicked', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Deprecated Plugin' + ); + }); + + it('shows plugin name in deprecation warning', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getAllByText(/Test Plugin/).length).toBeGreaterThan(0); + }); + + it('Cancel in deprecation modal closes it without proceeding', () => { + const { mockInstallPlugin } = setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(screen.getByText('Cancel')); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + expect(mockInstallPlugin).not.toHaveBeenCalled(); + }); + + it('"Install Anyway" proceeds to the install confirm modal', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(screen.getByText('Install Anyway')); + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Confirm Install' + ); + }); + + it('after deprecation → confirm → install, onInstalled is called', async () => { + setupMocks(); + const onInstalled = vi.fn(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(screen.getByText('Install Anyway')); + fireEvent.click(getModalActionButton('Install')); + await waitFor(() => { + expect(onInstalled).toHaveBeenCalledWith('test-plugin'); + }); + }); + }); + + // ── Downgrade / update confirm modal ────────────────────────────────────── + + describe('downgrade confirm modal', () => { + const makeUpdatePlugin = () => + makePlugin({ + install_status: 'update_available', + installed: true, + installed_version: '1.1.0', + }); + + it('shows Confirm Downgrade title when getInstallInfo returns isDowngrade', () => { + setupMocks(); + vi.mocked(getInstallInfo).mockReturnValue({ + isDowngrade: true, + isUpdate: false, + isBadSig: false, + }); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Confirm Downgrade' + ); + }); + + it('shows downgrade warning when isDowngrade', () => { + setupMocks(); + vi.mocked(getInstallInfo).mockReturnValue({ + isDowngrade: true, + isUpdate: false, + isBadSig: false, + }); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getByTestId('downgrade-warning')).toBeInTheDocument(); + }); + + it('shows Confirm Update title when getInstallInfo returns isUpdate', () => { + setupMocks(); + vi.mocked(getInstallInfo).mockReturnValue({ + isDowngrade: false, + isUpdate: true, + isBadSig: false, + }); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Confirm Update' + ); + }); + + it('shows restart prompt with "Plugin Downgraded" after downgrade confirms', async () => { + setupMocks(); + vi.mocked(getInstallInfo).mockReturnValue({ + isDowngrade: true, + isUpdate: false, + isBadSig: false, + }); + // compareVersions < 0 so wasDowngrade = true in executeInstall + vi.mocked(compareVersions).mockReturnValue(-1); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Downgrade')); + await waitFor(() => { + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Plugin Downgraded' + ); + }); + }); + + it('shows "Plugin Updated" restart prompt after update confirms', async () => { + setupMocks(); + vi.mocked(getInstallInfo).mockReturnValue({ + isDowngrade: false, + isUpdate: true, + isBadSig: false, + }); + // installed_version set → wasInstalled = true, wasDowngrade = false (compareVersions = 0) + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + fireEvent.click(getModalActionButton('Update')); + await waitFor(() => { + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Plugin Updated' + ); + }); + }); + }); + + // ── Unmanaged / different_repo notes ────────────────────────────────────── + + describe('unmanaged and different_repo notes', () => { + it('shows info note in confirm modal for unmanaged install', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getByTestId('info-note')).toBeInTheDocument(); + }); + + it('shows info note in confirm modal for different_repo install', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByTestId('sized-install-button')); + expect(screen.getByTestId('info-note')).toBeInTheDocument(); + }); + }); + + // ── Uninstall flow ───────────────────────────────────────────────────────── + + describe('uninstall flow', () => { + const makeInstalled = () => + makePlugin({ + install_status: 'installed', + installed: true, + installed_version: '1.0.0', + key: 'test-plugin', + }); + + it('clicking Uninstall button opens uninstall confirm modal', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('Uninstall')); + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Uninstall Plugin' + ); + }); + + it('Cancel in uninstall confirm modal closes it without deleting', () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('Uninstall')); + fireEvent.click(screen.getByText('Cancel')); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + expect(PluginsUtils.deletePluginByKey).not.toHaveBeenCalled(); + }); + + it('confirming calls deletePluginByKey with plugin key', async () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('Uninstall')); + // Second "Uninstall" text = modal confirm button + fireEvent.click(screen.getAllByText('Uninstall')[1]); + await waitFor(() => { + expect(PluginsUtils.deletePluginByKey).toHaveBeenCalledWith( + 'test-plugin' + ); + }); + }); + + it('calls invalidatePlugins and fetchAvailablePlugins after uninstall', async () => { + const { mockInvalidatePlugins, mockFetchAvailablePlugins } = setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('Uninstall')); + fireEvent.click(screen.getAllByText('Uninstall')[1]); + await waitFor(() => { + expect(mockInvalidatePlugins).toHaveBeenCalled(); + expect(mockFetchAvailablePlugins).toHaveBeenCalled(); + }); + }); + + it('calls onUninstalled callback with plugin slug after uninstall', async () => { + setupMocks(); + const onUninstalled = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Uninstall')); + fireEvent.click(screen.getAllByText('Uninstall')[1]); + await waitFor(() => { + expect(onUninstalled).toHaveBeenCalledWith('test-plugin'); + }); + }); + + it('shows "Plugin Uninstalled" done modal after uninstall', async () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('Uninstall')); + fireEvent.click(screen.getAllByText('Uninstall')[1]); + await waitFor(() => { + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Plugin Uninstalled' + ); + }); + }); + + it('Done in uninstall done modal closes it', async () => { + setupMocks(); + render( + + ); + fireEvent.click(screen.getByText('Uninstall')); + fireEvent.click(screen.getAllByText('Uninstall')[1]); + await waitFor(() => { + expect(screen.getByText('Done')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText('Done')); + await waitFor(() => { + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + it('does not call onUninstalled when deletePluginByKey returns no success', async () => { + setupMocks(); + vi.mocked(PluginsUtils.deletePluginByKey).mockResolvedValue({ + success: false, + }); + const onUninstalled = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Uninstall')); + fireEvent.click(screen.getAllByText('Uninstall')[1]); + await waitFor(() => { + expect(PluginsUtils.deletePluginByKey).toHaveBeenCalled(); + }); + expect(onUninstalled).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/components/forms/settings/__tests__/ConnectionSecurityPanel.test.jsx b/frontend/src/components/forms/settings/__tests__/ConnectionSecurityPanel.test.jsx new file mode 100644 index 00000000..96cd3ea7 --- /dev/null +++ b/frontend/src/components/forms/settings/__tests__/ConnectionSecurityPanel.test.jsx @@ -0,0 +1,592 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import ConnectionSecurityPanel from '../ConnectionSecurityPanel'; + +// ── Store mock ───────────────────────────────────────────────────────────────── +vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() })); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Badge: ({ children, color, variant, size }) => ( + + {children} + + ), + Group: ({ children, gap, align }) => ( +
+ {children} +
+ ), + Paper: ({ children, p, withBorder }) => ( +
+ {children} +
+ ), + SimpleGrid: ({ children, spacing }) => ( +
+ {children} +
+ ), + Stack: ({ children, gap }) =>
{children}
, + Text: ({ children, size, c, fw }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
+ {children} +
+ ), +})); + +import useSettingsStore from '../../../../store/settings.jsx'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const setupStore = (environment = {}) => { + vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ environment })); +}; + +/** Find the service card (Paper) whose subtree contains a Text matching `name` */ +const getServiceCard = (name) => { + const papers = screen.getAllByTestId('paper'); + return papers.find((p) => within(p).queryByText(name)); +}; + +/** Get all badges within a named service card */ +const getBadgesIn = (serviceName) => + within(getServiceCard(serviceName)).getAllByTestId('badge'); + +/** Get all tooltips within a named service card */ +const getTooltipsIn = (serviceName) => + within(getServiceCard(serviceName)).getAllByTestId('tooltip'); + +/** Find a badge by its exact text content within a service card */ +const getBadgeByText = (serviceName, text) => + getBadgesIn(serviceName).find((b) => b.textContent === text); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('ConnectionSecurityPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Top-level rendering ────────────────────────────────────────────────────── + + describe('top-level rendering', () => { + it('renders the introductory description text', () => { + setupStore(); + render(); + expect( + screen.getByText( + /Encrypt connections to Redis and PostgreSQL using environment variables/i + ) + ).toBeInTheDocument(); + }); + + it('renders both Redis and PostgreSQL service cards', () => { + setupStore(); + render(); + expect(getServiceCard('Redis')).toBeTruthy(); + expect(getServiceCard('PostgreSQL')).toBeTruthy(); + }); + }); + + // ── RedisStatus — TLS disabled ─────────────────────────────────────────────── + + describe('RedisStatus — TLS disabled (default)', () => { + beforeEach(() => { + setupStore({ redis_tls: { enabled: false } }); + render(); + }); + + it('renders all three option labels', () => { + const card = getServiceCard('Redis'); + expect(within(card).getByText('Encryption')).toBeInTheDocument(); + expect(within(card).getByText('Server Verification')).toBeInTheDocument(); + expect(within(card).getByText('Mutual TLS')).toBeInTheDocument(); + }); + + it('shows Encryption badge as "Disabled" with gray color', () => { + const badge = getBadgeByText('Redis', 'Disabled'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('gray'); + }); + + it('shows Server Verification badge as "Off" with gray color', () => { + const card = getServiceCard('Redis'); + const offBadge = within(card) + .getAllByTestId('badge') + .find((b) => b.textContent === 'Off'); + expect(offBadge).toBeTruthy(); + expect(offBadge.dataset.color).toBe('gray'); + }); + + it('shows Mutual TLS badge as "Inactive" with gray color', () => { + const badge = getBadgeByText('Redis', 'Inactive'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('gray'); + }); + + it('shows "not encrypted" tooltip for Encryption', () => { + const tooltips = getTooltipsIn('Redis'); + expect( + tooltips.find( + (t) => t.dataset.tooltip === 'The connection is not encrypted' + ) + ).toBeTruthy(); + }); + + it('shows "Verification is not active" tooltip for Server Verification', () => { + const tooltips = getTooltipsIn('Redis'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes('Verification is not active') + ) + ).toBeTruthy(); + }); + + it('shows "Mutual authentication is not active" tooltip for mTLS', () => { + const tooltips = getTooltipsIn('Redis'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes('Mutual authentication is not active') + ) + ).toBeTruthy(); + }); + }); + + // ── RedisStatus — TLS enabled, verify=true, mtls=false ────────────────────── + + describe('RedisStatus — TLS enabled, verify=true, mtls=false', () => { + beforeEach(() => { + setupStore({ redis_tls: { enabled: true, verify: true, mtls: false } }); + render(); + }); + + it('shows Encryption badge as "Enabled" with green color', () => { + const badge = getBadgeByText('Redis', 'Enabled'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('green'); + }); + + it('shows Server Verification badge as "On" with green color', () => { + const card = getServiceCard('Redis'); + const onBadge = within(card) + .getAllByTestId('badge') + .find((b) => b.textContent === 'On'); + expect(onBadge).toBeTruthy(); + expect(onBadge.dataset.color).toBe('green'); + }); + + it('shows Mutual TLS badge as "Inactive" with gray color', () => { + const badge = getBadgeByText('Redis', 'Inactive'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('gray'); + }); + + it('shows "connection is encrypted" tooltip for Encryption', () => { + const tooltips = getTooltipsIn('Redis'); + expect( + tooltips.find( + (t) => t.dataset.tooltip === 'The connection is encrypted' + ) + ).toBeTruthy(); + }); + + it('shows "server identity is verified" tooltip for Server Verification', () => { + const tooltips = getTooltipsIn('Redis'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes("server's identity is verified") + ) + ).toBeTruthy(); + }); + }); + + // ── RedisStatus — TLS enabled, verify=false ────────────────────────────────── + + describe('RedisStatus — TLS enabled, verify=false', () => { + beforeEach(() => { + setupStore({ redis_tls: { enabled: true, verify: false, mtls: false } }); + render(); + }); + + it('shows Server Verification badge as "Off" with yellow color', () => { + const card = getServiceCard('Redis'); + const offBadge = within(card) + .getAllByTestId('badge') + .find((b) => b.textContent === 'Off'); + expect(offBadge).toBeTruthy(); + expect(offBadge.dataset.color).toBe('yellow'); + }); + + it('shows "encrypted but server identity is not verified" tooltip', () => { + const tooltips = getTooltipsIn('Redis'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes('server identity is not verified') + ) + ).toBeTruthy(); + }); + }); + + // ── RedisStatus — TLS enabled, mtls=true ───────────────────────────────────── + + describe('RedisStatus — TLS enabled, mtls=true', () => { + beforeEach(() => { + setupStore({ redis_tls: { enabled: true, verify: true, mtls: true } }); + render(); + }); + + it('shows Mutual TLS badge as "Active" with green color', () => { + const badge = getBadgeByText('Redis', 'Active'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('green'); + }); + + it('shows "Both client and server verify each other" tooltip for mTLS', () => { + const tooltips = getTooltipsIn('Redis'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes( + 'Both client and server verify each other' + ) + ) + ).toBeTruthy(); + }); + }); + + // ── RedisStatus — redis_tls undefined ──────────────────────────────────────── + + describe('RedisStatus — redis_tls undefined', () => { + it('falls back to defaults (Disabled/Off/Inactive, all gray)', () => { + setupStore({}); + render(); + expect(getBadgeByText('Redis', 'Disabled').dataset.color).toBe('gray'); + expect( + within(getServiceCard('Redis')) + .getAllByTestId('badge') + .find((b) => b.textContent === 'Off').dataset.color + ).toBe('gray'); + expect(getBadgeByText('Redis', 'Inactive').dataset.color).toBe('gray'); + }); + }); + + // ── PostgresStatus — TLS disabled ─────────────────────────────────────────── + + describe('PostgresStatus — TLS disabled (default)', () => { + beforeEach(() => { + setupStore({ postgres_tls: { enabled: false } }); + render(); + }); + + it('renders all three option labels', () => { + const card = getServiceCard('PostgreSQL'); + expect(within(card).getByText('Encryption')).toBeInTheDocument(); + expect(within(card).getByText('Verification Mode')).toBeInTheDocument(); + expect(within(card).getByText('Mutual TLS')).toBeInTheDocument(); + }); + + it('shows Encryption badge as "Disabled" with gray color', () => { + const badge = getBadgeByText('PostgreSQL', 'Disabled'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('gray'); + }); + + it('shows Verification Mode badge as "Off" with gray color', () => { + const card = getServiceCard('PostgreSQL'); + const offBadge = within(card) + .getAllByTestId('badge') + .find((b) => b.textContent === 'Off'); + expect(offBadge).toBeTruthy(); + expect(offBadge.dataset.color).toBe('gray'); + }); + + it('shows Mutual TLS badge as "Inactive" with gray color', () => { + const badge = getBadgeByText('PostgreSQL', 'Inactive'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('gray'); + }); + + it('shows "Verification mode is not active" tooltip when TLS is disabled', () => { + const tooltips = getTooltipsIn('PostgreSQL'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes('Verification mode is not active') + ) + ).toBeTruthy(); + }); + }); + + // ── PostgresStatus — ssl_mode=verify-full ─────────────────────────────────── + + describe('PostgresStatus — ssl_mode=verify-full', () => { + beforeEach(() => { + setupStore({ + postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: false }, + }); + render(); + }); + + it('shows Verification Mode badge as "verify-full" with green color', () => { + const badge = getBadgeByText('PostgreSQL', 'verify-full'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('green'); + }); + + it('shows "Server certificate and hostname are both verified" tooltip', () => { + const tooltips = getTooltipsIn('PostgreSQL'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes( + 'Server certificate and hostname are both verified' + ) + ) + ).toBeTruthy(); + }); + }); + + // ── PostgresStatus — ssl_mode=verify-ca ───────────────────────────────────── + + describe('PostgresStatus — ssl_mode=verify-ca', () => { + beforeEach(() => { + setupStore({ + postgres_tls: { enabled: true, ssl_mode: 'verify-ca', mtls: false }, + }); + render(); + }); + + it('shows Verification Mode badge as "verify-ca" with yellow color', () => { + const badge = getBadgeByText('PostgreSQL', 'verify-ca'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('yellow'); + }); + + it('shows "hostname is not checked" tooltip', () => { + const tooltips = getTooltipsIn('PostgreSQL'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes('hostname is not checked') + ) + ).toBeTruthy(); + }); + }); + + // ── PostgresStatus — ssl_mode=require (other/fallback) ────────────────────── + + describe('PostgresStatus — ssl_mode=require (other)', () => { + beforeEach(() => { + setupStore({ + postgres_tls: { enabled: true, ssl_mode: 'require', mtls: false }, + }); + render(); + }); + + it('shows Verification Mode badge as "require" with yellow color', () => { + const badge = getBadgeByText('PostgreSQL', 'require'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('yellow'); + }); + + it('shows "encrypted but the server is not verified" tooltip', () => { + const tooltips = getTooltipsIn('PostgreSQL'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes('server is not verified') + ) + ).toBeTruthy(); + }); + }); + + // ── PostgresStatus — TLS enabled, ssl_mode absent ─────────────────────────── + + describe('PostgresStatus — TLS enabled, ssl_mode absent', () => { + it('shows Verification Mode badge as "Off" with gray color', () => { + setupStore({ postgres_tls: { enabled: true, mtls: false } }); + render(); + const card = getServiceCard('PostgreSQL'); + const offBadge = within(card) + .getAllByTestId('badge') + .find((b) => b.textContent === 'Off'); + expect(offBadge).toBeTruthy(); + expect(offBadge.dataset.color).toBe('gray'); + }); + }); + + // ── PostgresStatus — mtls=true ─────────────────────────────────────────────── + + describe('PostgresStatus — mtls=true', () => { + beforeEach(() => { + setupStore({ + postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: true }, + }); + render(); + }); + + it('shows Mutual TLS badge as "Active" with green color', () => { + const badge = getBadgeByText('PostgreSQL', 'Active'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('green'); + }); + + it('shows "Both client and server verify each other" tooltip for mTLS', () => { + const tooltips = getTooltipsIn('PostgreSQL'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes( + 'Both client and server verify each other' + ) + ) + ).toBeTruthy(); + }); + }); + + // ── PostgresStatus — mtls=false while TLS enabled ─────────────────────────── + + describe('PostgresStatus — mtls=false while TLS enabled', () => { + it('shows Mutual TLS badge as "Inactive" with gray color', () => { + setupStore({ + postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: false }, + }); + render(); + const badge = getBadgeByText('PostgreSQL', 'Inactive'); + expect(badge).toBeTruthy(); + expect(badge.dataset.color).toBe('gray'); + }); + + it('shows "Mutual authentication is not active" tooltip', () => { + setupStore({ + postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: false }, + }); + render(); + const tooltips = getTooltipsIn('PostgreSQL'); + expect( + tooltips.find((t) => + t.dataset.tooltip?.includes('Mutual authentication is not active') + ) + ).toBeTruthy(); + }); + }); + + // ── PostgresStatus — postgres_tls undefined ────────────────────────────────── + + describe('PostgresStatus — postgres_tls undefined', () => { + it('falls back to all defaults (Disabled/Off/Inactive, all gray)', () => { + setupStore({}); + render(); + expect(getBadgeByText('PostgreSQL', 'Disabled').dataset.color).toBe( + 'gray' + ); + expect( + within(getServiceCard('PostgreSQL')) + .getAllByTestId('badge') + .find((b) => b.textContent === 'Off').dataset.color + ).toBe('gray'); + expect(getBadgeByText('PostgreSQL', 'Inactive').dataset.color).toBe( + 'gray' + ); + }); + }); + + // ── Both services — independent state ──────────────────────────────────────── + + describe('both services — independent badge state', () => { + it('Redis and PostgreSQL badges reflect their own configs independently', () => { + setupStore({ + redis_tls: { enabled: true, verify: false, mtls: true }, + postgres_tls: { enabled: false }, + }); + render(); + + // Redis: enabled → green Enabled, verify=false → yellow Off, mtls=true → green Active + expect(getBadgeByText('Redis', 'Enabled').dataset.color).toBe('green'); + expect( + within(getServiceCard('Redis')) + .getAllByTestId('badge') + .find((b) => b.textContent === 'Off').dataset.color + ).toBe('yellow'); + expect(getBadgeByText('Redis', 'Active').dataset.color).toBe('green'); + + // Postgres: disabled → all gray/Off/Inactive + expect(getBadgeByText('PostgreSQL', 'Disabled').dataset.color).toBe( + 'gray' + ); + expect( + within(getServiceCard('PostgreSQL')) + .getAllByTestId('badge') + .find((b) => b.textContent === 'Off').dataset.color + ).toBe('gray'); + expect(getBadgeByText('PostgreSQL', 'Inactive').dataset.color).toBe( + 'gray' + ); + }); + }); + + // ── TlsOption description text ──────────────────────────────────────────────── + + describe('TlsOption description text', () => { + beforeEach(() => { + setupStore({ + redis_tls: { enabled: true, verify: true, mtls: true }, + postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: true }, + }); + render(); + }); + + it('renders Redis Encryption description', () => { + expect( + within(getServiceCard('Redis')).getByText( + 'Encrypt traffic between Dispatcharr and Redis.' + ) + ).toBeInTheDocument(); + }); + + it('renders Redis Server Verification description', () => { + expect( + within(getServiceCard('Redis')).getByText( + "Verify the Redis server's identity using a CA certificate." + ) + ).toBeInTheDocument(); + }); + + it('renders Redis Mutual TLS description', () => { + expect( + within(getServiceCard('Redis')).getByText( + 'Authenticate Dispatcharr to Redis using a client certificate.' + ) + ).toBeInTheDocument(); + }); + + it('renders PostgreSQL Encryption description', () => { + expect( + within(getServiceCard('PostgreSQL')).getByText( + 'Encrypt traffic between Dispatcharr and PostgreSQL.' + ) + ).toBeInTheDocument(); + }); + + it('renders PostgreSQL Verification Mode description', () => { + expect( + within(getServiceCard('PostgreSQL')).getByText( + "How strictly to verify the PostgreSQL server's identity." + ) + ).toBeInTheDocument(); + }); + + it('renders PostgreSQL Mutual TLS description', () => { + expect( + within(getServiceCard('PostgreSQL')).getByText( + 'Authenticate Dispatcharr to PostgreSQL using a client certificate.' + ) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/forms/settings/__tests__/EpgSettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/EpgSettingsForm.test.jsx new file mode 100644 index 00000000..88bac9fb --- /dev/null +++ b/frontend/src/components/forms/settings/__tests__/EpgSettingsForm.test.jsx @@ -0,0 +1,417 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import EpgSettingsForm from '../EpgSettingsForm'; + +// ── Store mock ───────────────────────────────────────────────────────────────── +vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() })); + +// ── @mantine/form mock ──────────────────────────────────────────────────────── +vi.mock('@mantine/form', () => ({ useForm: vi.fn() })); + +// ── @mantine/core mock ──────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Alert: ({ title, color, variant }) => ( +
+ {title} +
+ ), + Button: ({ children, type, disabled }) => ( + + ), + Flex: ({ children, justify }) =>
{children}
, + NumberInput: ({ label, description, min, max, value, onChange }) => ( +
+ {label && } + {description && ( + {description} + )} + onChange?.(Number(e.target.value))} + /> +
+ ), + Stack: ({ children, gap }) =>
{children}
, + Text: ({ children, size, c }) => ( + + {children} + + ), +})); + +// ── SettingsUtils mock ──────────────────────────────────────────────────────── +vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({ + getChangedSettings: vi.fn(), + parseSettings: vi.fn(), + saveChangedSettings: vi.fn(), +})); + +// ── EpgSettingsFormUtils mock ────────────────────────────────────────────────── +vi.mock('../../../../utils/forms/settings/EpgSettingsFormUtils.js', () => ({ + getEpgSettingsFormInitialValues: vi.fn(() => ({ + xmltv_prev_days_override: 0, + })), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useSettingsStore from '../../../../store/settings.jsx'; +import { useForm } from '@mantine/form'; +import * as SettingsUtils from '../../../../utils/pages/SettingsUtils.js'; +import { getEpgSettingsFormInitialValues } from '../../../../utils/forms/settings/EpgSettingsFormUtils.js'; +import { EPG_SETTINGS_OPTIONS } from '../../../../constants.js'; + +// ── Form mock factory ───────────────────────────────────────────────────────── + +let mockForm; + +const createMockForm = (initialValues = { xmltv_prev_days_override: 0 }) => { + const state = { ...initialValues }; + return { + values: state, + setFieldValue: vi.fn((field, value) => { + state[field] = value; + }), + getInputProps: vi.fn((field) => ({ + value: state[field], + onChange: vi.fn((val) => { + state[field] = val; + }), + })), + getValues: vi.fn(() => ({ ...state })), + onSubmit: vi.fn((handler) => (e) => { + e?.preventDefault?.(); + return handler(); + }), + submitting: false, + }; +}; + +// ── Settings factories ──────────────────────────────────────────────────────── + +const makeSettings = (epgValue = {}) => ({ + epg_settings: { id: 1, value: epgValue }, +}); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('EpgSettingsForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockForm = createMockForm(); + vi.mocked(useForm).mockReturnValue(mockForm); + + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: null }) + ); + + vi.mocked(SettingsUtils.parseSettings).mockReturnValue({ + xmltv_prev_days_override: 0, + }); + vi.mocked(SettingsUtils.getChangedSettings).mockReturnValue({}); + vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('number-input')).toBeInTheDocument(); + }); + + it('renders the NumberInput label from EPG_SETTINGS_OPTIONS', () => { + render(); + expect( + screen.getByText(EPG_SETTINGS_OPTIONS.xmltv_prev_days_override.label) + ).toBeInTheDocument(); + }); + + it('renders the NumberInput description from EPG_SETTINGS_OPTIONS', () => { + render(); + expect( + screen.getByText( + EPG_SETTINGS_OPTIONS.xmltv_prev_days_override.description + ) + ).toBeInTheDocument(); + }); + + it('renders the disclaimer text about per-user defaults', () => { + render(); + expect( + screen.getByText( + /Per-user defaults and URL parameters still override this global value/i + ) + ).toBeInTheDocument(); + }); + + it('renders the EPG channel matching hint', () => { + render(); + expect( + screen.getByText( + /EPG channel matching options are configured from the Channels page/i + ) + ).toBeInTheDocument(); + }); + + it('renders a Save button of type="submit"', () => { + render(); + const btn = screen.getByText('Save'); + expect(btn).toBeInTheDocument(); + expect(btn).toHaveAttribute('type', 'submit'); + }); + + it('does not show the "Saved Successfully" alert initially', () => { + render(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('NumberInput has min=0', () => { + render(); + expect(screen.getByTestId('number-input')).toHaveAttribute('min', '0'); + }); + + it('NumberInput has max=30', () => { + render(); + expect(screen.getByTestId('number-input')).toHaveAttribute('max', '30'); + }); + + it('NumberInput starts with value 0 from initial form values', () => { + render(); + expect(screen.getByTestId('number-input')).toHaveValue(0); + }); + }); + + // ── Initialization ───────────────────────────────────────────────────────── + + describe('initialization', () => { + it('calls getEpgSettingsFormInitialValues to seed useForm', () => { + render(); + expect(getEpgSettingsFormInitialValues).toHaveBeenCalled(); + }); + + it('passes initial values from getEpgSettingsFormInitialValues to useForm', () => { + vi.mocked(getEpgSettingsFormInitialValues).mockReturnValue({ + xmltv_prev_days_override: 5, + }); + render(); + expect(vi.mocked(useForm)).toHaveBeenCalledWith( + expect.objectContaining({ + initialValues: { xmltv_prev_days_override: 5 }, + }) + ); + }); + + it('calls useForm with mode="controlled"', () => { + render(); + expect(vi.mocked(useForm)).toHaveBeenCalledWith( + expect.objectContaining({ mode: 'controlled' }) + ); + }); + }); + + // ── Settings effect ──────────────────────────────────────────────────────── + + describe('settings effect', () => { + it('calls parseSettings when settings are provided', () => { + const settings = makeSettings({ xmltv_prev_days_override: 7 }); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings }) + ); + vi.mocked(SettingsUtils.parseSettings).mockReturnValue({ + xmltv_prev_days_override: 7, + }); + + render(); + + expect(SettingsUtils.parseSettings).toHaveBeenCalledWith(settings); + }); + + it('calls setFieldValue with parsed xmltv_prev_days_override', () => { + const settings = makeSettings({ xmltv_prev_days_override: 14 }); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings }) + ); + vi.mocked(SettingsUtils.parseSettings).mockReturnValue({ + xmltv_prev_days_override: 14, + }); + + render(); + + expect(mockForm.setFieldValue).toHaveBeenCalledWith( + 'xmltv_prev_days_override', + 14 + ); + }); + + it('calls setFieldValue with 0 when parsed value is undefined', () => { + const settings = makeSettings({}); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings }) + ); + vi.mocked(SettingsUtils.parseSettings).mockReturnValue({}); + + render(); + + expect(mockForm.setFieldValue).toHaveBeenCalledWith( + 'xmltv_prev_days_override', + 0 + ); + }); + + it('does not call parseSettings when settings is null', () => { + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: null }) + ); + + render(); + + expect(SettingsUtils.parseSettings).not.toHaveBeenCalled(); + }); + }); + + // ── active prop effect ───────────────────────────────────────────────────── + + describe('active prop effect', () => { + it('resets the saved alert when active changes to false', async () => { + vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined); + const { rerender } = render(); + + // Trigger a successful save to get saved=true + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + }); + + // Switching active to false should dismiss the alert + rerender(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('does not show alert when active starts as false', () => { + render(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + }); + + // ── Submit — success ─────────────────────────────────────────────────────── + + describe('submit — success', () => { + beforeEach(() => { + vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined); + }); + + it('calls getChangedSettings with form values and settings on submit', async () => { + const settings = makeSettings({ xmltv_prev_days_override: 3 }); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings }) + ); + + render(); + // Set the value after render so the settings useEffect (which resets the + // field to the parseSettings result) has already run and won't overwrite it. + mockForm.values.xmltv_prev_days_override = 5; + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(SettingsUtils.getChangedSettings).toHaveBeenCalledWith( + expect.objectContaining({ xmltv_prev_days_override: 5 }), + settings + ); + }); + }); + + it('calls saveChangedSettings with settings and changedSettings on submit', async () => { + const settings = makeSettings({ xmltv_prev_days_override: 3 }); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings }) + ); + const changed = { xmltv_prev_days_override: 7 }; + vi.mocked(SettingsUtils.getChangedSettings).mockReturnValue(changed); + + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(SettingsUtils.saveChangedSettings).toHaveBeenCalledWith( + settings, + changed + ); + }); + }); + + it('shows "Saved Successfully" alert after a successful save', async () => { + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByText('Saved Successfully')).toBeInTheDocument(); + }); + }); + + it('clears saved state before re-submitting', async () => { + render(); + + // First save + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + }); + + // Second save — saved resets to false then true again + vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + }); + }); + }); + + // ── Submit — error ───────────────────────────────────────────────────────── + + describe('submit — error', () => { + it('does not show alert when saveChangedSettings throws', async () => { + vi.mocked(SettingsUtils.saveChangedSettings).mockRejectedValue( + new Error('network error') + ); + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(SettingsUtils.saveChangedSettings).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('does not throw when saveChangedSettings rejects', async () => { + vi.mocked(SettingsUtils.saveChangedSettings).mockRejectedValue( + new Error('fail') + ); + render(); + + await expect( + waitFor(() => fireEvent.click(screen.getByText('Save'))) + ).resolves.not.toThrow(); + }); + }); + + // ── getInputProps wiring ─────────────────────────────────────────────────── + + describe('getInputProps wiring', () => { + it('calls form.getInputProps with xmltv_prev_days_override', () => { + render(); + expect(mockForm.getInputProps).toHaveBeenCalledWith( + 'xmltv_prev_days_override' + ); + }); + }); +}); diff --git a/frontend/src/components/forms/settings/__tests__/UserLimitsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/UserLimitsForm.test.jsx new file mode 100644 index 00000000..642078c5 --- /dev/null +++ b/frontend/src/components/forms/settings/__tests__/UserLimitsForm.test.jsx @@ -0,0 +1,428 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import UserLimitsForm from '../UserLimitsForm'; + +// ── Store mock ───────────────────────────────────────────────────────────────── +vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() })); + +// ── @mantine/form mock ──────────────────────────────────────────────────────── +vi.mock('@mantine/form', () => ({ useForm: vi.fn() })); + +// ── @mantine/core mock ──────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Alert: ({ title, color, variant }) => ( +
+ {title} +
+ ), + Button: ({ children, type, disabled, variant, color, onClick }) => ( + + ), + Checkbox: ({ label, description, checked, onChange }) => ( +
+ {label && } + {description && ( + {description} + )} + onChange?.(e.currentTarget.checked)} + /> +
+ ), + Flex: ({ children, mih, gap, justify, align }) => ( +
+ {children} +
+ ), + Stack: ({ children, gap }) =>
{children}
, +})); + +// ── SettingsUtils mock ──────────────────────────────────────────────────────── +vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({ + updateSetting: vi.fn(), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useSettingsStore from '../../../../store/settings.jsx'; +import { useForm } from '@mantine/form'; +import { updateSetting } from '../../../../utils/pages/SettingsUtils.js'; +import { USER_LIMITS_OPTIONS } from '../../../../constants.js'; + +// ── Derived constants (mirrors the component) ───────────────────────────────── +const USER_LIMIT_DEFAULTS = Object.keys(USER_LIMITS_OPTIONS).reduce( + (acc, key) => { + acc[key] = USER_LIMITS_OPTIONS[key].default; + return acc; + }, + {} +); + +// ── Form mock factory ───────────────────────────────────────────────────────── + +let mockForm; + +const createMockForm = (initialValues = { ...USER_LIMIT_DEFAULTS }) => { + const state = { ...initialValues }; + return { + values: state, + setValues: vi.fn((vals) => { + Object.assign(state, vals); + }), + getInputProps: vi.fn((field, opts) => { + if (opts?.type === 'checkbox') { + return { + checked: state[field], + onChange: vi.fn((val) => { + state[field] = val; + }), + }; + } + return { value: state[field], onChange: vi.fn() }; + }), + getValues: vi.fn(() => ({ ...state })), + onSubmit: vi.fn((handler) => (e) => { + e?.preventDefault?.(); + return handler(); + }), + submitting: false, + }; +}; + +// ── Settings factories ──────────────────────────────────────────────────────── + +const makeSettings = (userLimitValue = {}) => ({ + user_limit_settings: { id: 1, value: userLimitValue }, +}); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('UserLimitsForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockForm = createMockForm(); + vi.mocked(useForm).mockReturnValue(mockForm); + + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: null }) + ); + + vi.mocked(updateSetting).mockResolvedValue({ id: 1 }); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders without crashing', () => { + render(); + expect(screen.getAllByTestId('checkbox-wrapper')).toHaveLength( + Object.keys(USER_LIMITS_OPTIONS).length + ); + }); + + it('renders a Checkbox for every USER_LIMITS_OPTIONS entry', () => { + render(); + Object.values(USER_LIMITS_OPTIONS).forEach((opt) => { + expect(screen.getByText(opt.label)).toBeInTheDocument(); + }); + }); + + it('renders every checkbox description', () => { + render(); + Object.values(USER_LIMITS_OPTIONS).forEach((opt) => { + expect(screen.getByText(opt.description)).toBeInTheDocument(); + }); + }); + + it('renders a Save button of type="submit"', () => { + render(); + const btn = screen.getByText('Save'); + expect(btn).toHaveAttribute('type', 'submit'); + }); + + it('renders a "Reset to Defaults" button', () => { + render(); + expect(screen.getByText('Reset to Defaults')).toBeInTheDocument(); + }); + + it('does not show the "Saved Successfully" alert initially', () => { + render(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + }); + + // ── Initialization ───────────────────────────────────────────────────────── + + describe('initialization', () => { + it('calls useForm with mode="controlled"', () => { + render(); + expect(vi.mocked(useForm)).toHaveBeenCalledWith( + expect.objectContaining({ mode: 'controlled' }) + ); + }); + + it('seeds useForm with USER_LIMIT_DEFAULTS as initialValues', () => { + render(); + expect(vi.mocked(useForm)).toHaveBeenCalledWith( + expect.objectContaining({ initialValues: USER_LIMIT_DEFAULTS }) + ); + }); + + it('calls getInputProps with type:"checkbox" for each option key', () => { + render(); + Object.keys(USER_LIMITS_OPTIONS).forEach((key) => { + expect(mockForm.getInputProps).toHaveBeenCalledWith(key, { + type: 'checkbox', + }); + }); + }); + }); + + // ── Settings effect ──────────────────────────────────────────────────────── + + describe('settings effect', () => { + it('calls setValues with merged defaults + stored values when settings provided', () => { + const stored = { + terminate_on_limit_exceeded: false, + ignore_same_channel_connections: true, + }; + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: makeSettings(stored) }) + ); + + render(); + + expect(mockForm.setValues).toHaveBeenCalledWith({ + ...USER_LIMIT_DEFAULTS, + ...stored, + }); + }); + + it('does not call setValues when settings is null', () => { + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: null }) + ); + + render(); + + expect(mockForm.setValues).not.toHaveBeenCalled(); + }); + + it('does not call setValues when user_limit_settings.value is absent', () => { + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: { user_limit_settings: { id: 1 } } }) + ); + + render(); + + expect(mockForm.setValues).not.toHaveBeenCalled(); + }); + }); + + // ── active prop effect ───────────────────────────────────────────────────── + + describe('active prop effect', () => { + it('resets the saved alert when active changes to false', async () => { + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: makeSettings({}) }) + ); + + const { rerender } = render(); + + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + }); + + rerender(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('does not show alert when active starts as false', () => { + render(); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + }); + + // ── Reset to Defaults ────────────────────────────────────────────────────── + + describe('"Reset to Defaults" button', () => { + it('calls form.setValues with USER_LIMIT_DEFAULTS when clicked', () => { + render(); + fireEvent.click(screen.getByText('Reset to Defaults')); + expect(mockForm.setValues).toHaveBeenCalledWith(USER_LIMIT_DEFAULTS); + }); + + it('can be clicked multiple times without error', () => { + render(); + fireEvent.click(screen.getByText('Reset to Defaults')); + fireEvent.click(screen.getByText('Reset to Defaults')); + expect(mockForm.setValues).toHaveBeenCalledTimes(2); + }); + }); + + // ── Submit — success ─────────────────────────────────────────────────────── + + describe('submit — success', () => { + it('calls updateSetting with merged user_limit_settings + current values', async () => { + const stored = { id: 42, key: 'user_limit_settings', value: {} }; + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: { user_limit_settings: stored } }) + ); + + render(); + // Set values after render to avoid settings effect overwriting them + Object.assign(mockForm.values, { terminate_on_limit_exceeded: false }); + + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(updateSetting).toHaveBeenCalledWith({ + ...stored, + value: expect.objectContaining({ + terminate_on_limit_exceeded: false, + }), + }); + }); + }); + + it('shows "Saved Successfully" alert when updateSetting returns a truthy result', async () => { + vi.mocked(updateSetting).mockResolvedValue({ id: 1 }); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: makeSettings({}) }) + ); + + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByText('Saved Successfully')).toBeInTheDocument(); + }); + }); + + it('does NOT show alert when updateSetting returns null/falsy', async () => { + vi.mocked(updateSetting).mockResolvedValue(null); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: makeSettings({}) }) + ); + + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(updateSetting).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('passes getValues() result as the value to updateSetting', async () => { + const currentValues = { + terminate_on_limit_exceeded: false, + prioritize_single_client_channels: true, + ignore_same_channel_connections: true, + terminate_oldest: false, + }; + mockForm.getValues.mockReturnValue(currentValues); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: makeSettings({}) }) + ); + + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(updateSetting).toHaveBeenCalledWith( + expect.objectContaining({ value: currentValues }) + ); + }); + }); + + it('clears saved=false at start of each submit before setting it true', async () => { + vi.mocked(updateSetting).mockResolvedValue({ id: 1 }); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: makeSettings({}) }) + ); + + render(); + + // First save + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(screen.getByTestId('alert')).toBeInTheDocument() + ); + + // Second save — alert should still appear after re-submit + fireEvent.click(screen.getByText('Save')); + await waitFor(() => + expect(screen.getByTestId('alert')).toBeInTheDocument() + ); + }); + }); + + // ── Submit — error ───────────────────────────────────────────────────────── + + describe('submit — error', () => { + it('does not show alert when updateSetting throws', async () => { + vi.mocked(updateSetting).mockRejectedValue(new Error('network error')); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: makeSettings({}) }) + ); + + render(); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => { + expect(updateSetting).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('alert')).not.toBeInTheDocument(); + }); + + it('does not throw to the caller when updateSetting rejects', async () => { + vi.mocked(updateSetting).mockRejectedValue(new Error('fail')); + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ settings: makeSettings({}) }) + ); + + render(); + + await expect( + waitFor(() => fireEvent.click(screen.getByText('Save'))) + ).resolves.not.toThrow(); + }); + }); + + // ── Submit disabled state ────────────────────────────────────────────────── + + describe('Save button disabled state', () => { + it('is disabled when form.submitting is true', () => { + mockForm.submitting = true; + render(); + expect(screen.getByText('Save')).toBeDisabled(); + }); + + it('is not disabled when form.submitting is false', () => { + mockForm.submitting = false; + render(); + expect(screen.getByText('Save')).not.toBeDisabled(); + }); + }); +});