From b228d1cab1f51c03e4419f2a2573ecff6cc7885b Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Wed, 8 Jul 2026 14:56:06 -0700
Subject: [PATCH] Added tests for components
---
.../components/__tests__/AboutModal.test.jsx | 318 ++++
.../__tests__/PluginDetailPanel.test.jsx | 863 +++++++++++
.../__tests__/PluginWarnings.test.jsx | 266 ++++
.../backups/__tests__/BackupManager.test.jsx | 1117 ++++++++++++++
.../__tests__/AvailablePluginCard.test.jsx | 1324 +++++++++++++++++
.../ConnectionSecurityPanel.test.jsx | 592 ++++++++
.../__tests__/EpgSettingsForm.test.jsx | 417 ++++++
.../__tests__/UserLimitsForm.test.jsx | 428 ++++++
8 files changed, 5325 insertions(+)
create mode 100644 frontend/src/components/__tests__/AboutModal.test.jsx
create mode 100644 frontend/src/components/__tests__/PluginDetailPanel.test.jsx
create mode 100644 frontend/src/components/__tests__/PluginWarnings.test.jsx
create mode 100644 frontend/src/components/backups/__tests__/BackupManager.test.jsx
create mode 100644 frontend/src/components/cards/__tests__/AvailablePluginCard.test.jsx
create mode 100644 frontend/src/components/forms/settings/__tests__/ConnectionSecurityPanel.test.jsx
create mode 100644 frontend/src/components/forms/settings/__tests__/EpgSettingsForm.test.jsx
create mode 100644 frontend/src/components/forms/settings/__tests__/UserLimitsForm.test.jsx
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 }) => ,
+ 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 ?

: 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();
+ });
+ });
+});