Added tests for components

This commit is contained in:
Nick Sandstrom 2026-07-08 14:56:06 -07:00
parent 71f1c7d5e5
commit b228d1cab1
8 changed files with 5325 additions and 0 deletions

View file

@ -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 }) => (
<svg data-testid="discord-icon" data-size={size} />
),
GitHubIcon: ({ size }) => <svg data-testid="github-icon" data-size={size} />,
}));
// lucide-react mock
vi.mock('lucide-react', () => ({
BookOpen: ({ size }) => <svg data-testid="icon-book-open" data-size={size} />,
Heart: ({ size }) => <svg data-testid="icon-heart" data-size={size} />,
Users: ({ size }) => <svg data-testid="icon-users" data-size={size} />,
}));
// Mantine core mock
vi.mock('@mantine/core', async () => ({
Box: ({ children, style }) => <div style={style}>{children}</div>,
Button: ({ children, href, target, rel, variant, color, leftSection }) => (
<a
data-testid="button"
href={href}
target={target}
rel={rel}
data-variant={variant}
data-color={color}
>
{leftSection}
{children}
</a>
),
Divider: () => <hr data-testid="divider" />,
Group: ({ children, justify }) => (
<div data-justify={justify}>{children}</div>
),
Modal: ({ children, opened, onClose, title, size }) =>
opened ? (
<div data-testid="modal" data-size={size}>
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
SimpleGrid: ({ children, cols }) => <div data-cols={cols}>{children}</div>,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, fw, size, c, span }) =>
span ? (
<span data-fw={fw} data-size={size} data-color={c}>
{children}
</span>
) : (
<p data-fw={fw} data-size={size} data-color={c}>
{children}
</p>
),
Tooltip: ({ children, label, position }) => (
<div data-tooltip={label} data-position={position}>
{children}
</div>
),
}));
//
// 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(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render modal content when isOpen is false', () => {
setupStore();
render(<AboutModal isOpen={false} onClose={vi.fn()} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('calls onClose when close button is clicked', () => {
setupStore();
const onClose = vi.fn();
render(<AboutModal isOpen={true} onClose={onClose} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('renders with the correct modal title', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
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(<AboutModal isOpen={true} onClose={vi.fn()} />);
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(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('v1.5.0')).toBeInTheDocument();
});
it('displays version without timestamp when timestamp is undefined', () => {
setupStore({ version: '1.5.0' });
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
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(<AboutModal isOpen={true} onClose={vi.fn()} />);
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(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('v0.0.0')).toBeInTheDocument();
});
});
// Logo & branding
describe('logo and branding', () => {
it('renders the Dispatcharr logo', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
const logo = screen.getByAltText('Dispatcharr');
expect(logo).toBeInTheDocument();
expect(logo).toHaveAttribute('src', 'mocked-logo.png');
});
it('renders the app name "Dispatcharr"', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('Dispatcharr')).toBeInTheDocument();
});
});
// Action buttons
describe('action buttons', () => {
it('renders the Documentation button with correct href', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('Documentation').closest('a')).toHaveAttribute(
'href',
'https://dispatcharr.github.io/Dispatcharr-Docs/'
);
});
it('renders the Discord button with correct href', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('Discord').closest('a')).toHaveAttribute(
'href',
'https://discord.gg/Sp45V5BcxU'
);
});
it('renders the GitHub button with correct href', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('GitHub').closest('a')).toHaveAttribute(
'href',
'https://github.com/Dispatcharr/Dispatcharr'
);
});
it('renders the Donate button with correct href', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('Donate').closest('a')).toHaveAttribute(
'href',
'https://opencollective.com/dispatcharr/contribute'
);
});
it('all external buttons open in a new tab', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
const buttons = screen.getAllByTestId('button');
buttons.forEach((btn) => {
expect(btn).toHaveAttribute('target', '_blank');
});
});
it('all external buttons have noopener noreferrer rel', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
const buttons = screen.getAllByTestId('button');
buttons.forEach((btn) => {
expect(btn).toHaveAttribute('rel', 'noopener noreferrer');
});
});
it('renders 4 action buttons', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getAllByTestId('button')).toHaveLength(4);
});
});
// Icons
describe('icons', () => {
it('renders BookOpen icon for Documentation button', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('icon-book-open')).toBeInTheDocument();
});
it('renders DiscordIcon for Discord button', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('discord-icon')).toBeInTheDocument();
});
it('renders GitHubIcon for GitHub button', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('github-icon')).toBeInTheDocument();
});
it('renders Heart icon for Donate button', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('icon-heart')).toBeInTheDocument();
});
it('renders Users icon in Contributors section', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('icon-users')).toBeInTheDocument();
});
});
// Contributors section
describe('contributors section', () => {
it('renders the Contributors heading', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('Contributors')).toBeInTheDocument();
});
it('renders the contributors description text', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
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(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText(/In memory of/i)).toBeInTheDocument();
});
it('renders Jesse Mann name in the memorial', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('Jesse Mann')).toBeInTheDocument();
});
it('renders the memorial tooltip with correct label', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
const tooltip = screen
.getByText('Jesse Mann')
.closest('[data-tooltip="Remembering Jesse Mann"]');
expect(tooltip).toBeInTheDocument();
});
});
});

View file

@ -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 }) => (
<svg data-testid="discord-icon" data-size={size} />
),
GitHubIcon: ({ size }) => <svg data-testid="github-icon" data-size={size} />,
}));
vi.mock('lucide-react', () => ({
AlertTriangle: ({ size, color }) => (
<svg
data-testid="icon-alert-triangle"
data-size={size}
data-color={color}
/>
),
Ban: ({ size }) => <svg data-testid="icon-ban" data-size={size} />,
Download: ({ size }) => <svg data-testid="icon-download" data-size={size} />,
RefreshCw: ({ size }) => (
<svg data-testid="icon-refresh-cw" data-size={size} />
),
ShieldAlert: ({ size }) => (
<svg data-testid="icon-shield-alert" data-size={size} />
),
ShieldCheck: ({ size }) => (
<svg data-testid="icon-shield-check" data-size={size} />
),
Trash2: ({ size }) => <svg data-testid="icon-trash2" data-size={size} />,
}));
// Mantine core mock
vi.mock('@mantine/core', async () => ({
ActionIcon: ({ children, href, target, rel, color }) => (
<a
data-testid="action-icon"
href={href}
target={target}
rel={rel}
data-color={color}
>
{children}
</a>
),
Alert: ({ children, title, color, icon }) => (
<div data-testid="alert" data-color={color}>
<div data-testid="alert-title">{title}</div>
{icon}
{children}
</div>
),
Badge: ({
children,
component,
href,
target,
rel,
leftSection,
color,
style,
}) =>
component === 'a' ? (
<a
data-testid="badge"
href={href}
target={target}
rel={rel}
style={style}
>
{leftSection}
{children}
</a>
) : (
<span data-testid="badge" data-color={color}>
{leftSection}
{children}
</span>
),
Button: ({ children, onClick, disabled, variant, color, leftSection }) => (
<button
data-testid="button"
onClick={onClick}
disabled={disabled}
data-variant={variant}
data-color={color}
>
{leftSection}
{children}
</button>
),
Group: ({ children }) => <div>{children}</div>,
Loader: ({ size }) => <span data-testid="loader" data-size={size} />,
Select: ({ label, value, onChange, data, disabled }) => (
<select
data-testid="version-select"
value={value ?? ''}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
aria-label={label}
>
{(data ?? []).map((item) => (
<option key={item.value} value={item.value} disabled={item.disabled}>
{item.label}
</option>
))}
</select>
),
Stack: ({ children }) => <div>{children}</div>,
Table: ({ children }) => <table>{children}</table>,
TableTbody: ({ children }) => <tbody>{children}</tbody>,
TableTd: ({ children, style }) => <td style={style}>{children}</td>,
TableTr: ({ children }) => <tr>{children}</tr>,
Text: ({ children, size, c, fw, component, href, target, rel }) =>
component === 'a' ? (
<a data-testid="text-link" href={href} target={target} rel={rel}>
{children}
</a>
) : (
<span data-size={size} data-color={c} data-fw={fw}>
{children}
</span>
),
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
}));
//
// 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(<PluginDetailPanel {...defaultProps({ detailLoading: true })} />);
expect(screen.getByTestId('loader')).toBeInTheDocument();
expect(screen.getByText(/Loading plugin details/i)).toBeInTheDocument();
});
it('shows error text when detail is null', () => {
render(
<PluginDetailPanel
{...defaultProps({ detail: null, detailLoading: false })}
/>
);
expect(
screen.getByText(/Failed to load plugin details/i)
).toBeInTheDocument();
});
it('shows error text when detail has no manifest', () => {
render(
<PluginDetailPanel
{...defaultProps({ detail: {}, detailLoading: false })}
/>
);
expect(
screen.getByText(/Failed to load plugin details/i)
).toBeInTheDocument();
});
});
// Description
describe('description', () => {
it('renders manifest description', () => {
render(<PluginDetailPanel {...defaultProps()} />);
expect(screen.getByText('A useful plugin.')).toBeInTheDocument();
});
it('does not render description section when absent', () => {
render(
<PluginDetailPanel
{...defaultProps({ detail: makeDetail({ description: null }) })}
/>
);
expect(screen.queryByText('A useful plugin.')).not.toBeInTheDocument();
});
});
// Author & license badges
describe('author and license badges', () => {
it('renders author badge', () => {
render(<PluginDetailPanel {...defaultProps()} />);
expect(screen.getByText('Test Author')).toBeInTheDocument();
});
it('does not render author badge when author is absent', () => {
render(
<PluginDetailPanel
{...defaultProps({ detail: makeDetail({ author: null }) })}
/>
);
expect(screen.queryByText('Test Author')).not.toBeInTheDocument();
});
it('renders license badge as a link to spdx.org', () => {
render(<PluginDetailPanel {...defaultProps()} />);
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(
<PluginDetailPanel
{...defaultProps({ detail: makeDetail({ license: null }) })}
/>
);
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(<PluginDetailPanel {...defaultProps()} />);
expect(screen.getByText('Verified Signature')).toBeInTheDocument();
expect(screen.getByTestId('icon-shield-check')).toBeInTheDocument();
});
it('shows "Unverified" badge when signature_verified is false', () => {
render(
<PluginDetailPanel
{...defaultProps({
detail: makeDetail({}, { signature_verified: false }),
})}
/>
);
expect(screen.getByText('Unverified')).toBeInTheDocument();
expect(screen.getByTestId('icon-shield-alert')).toBeInTheDocument();
});
it('renders no signature badge when signature_verified is null', () => {
render(
<PluginDetailPanel
{...defaultProps({
detail: makeDetail({}, { signature_verified: null }),
})}
/>
);
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(<PluginDetailPanel {...defaultProps()} />);
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(
<PluginDetailPanel
{...defaultProps({ detail: makeDetail({ repo_url: null }) })}
/>
);
expect(screen.queryByTestId('github-icon')).not.toBeInTheDocument();
});
});
// Discord link
describe('Discord link', () => {
it('renders Discord icon when discord_thread is set', () => {
render(
<PluginDetailPanel
{...defaultProps({
detail: makeDetail({
discord_thread: 'https://example.com/discord',
}),
})}
/>
);
expect(screen.getByTestId('discord-icon')).toBeInTheDocument();
});
it('does not render Discord icon when discord_thread is null', () => {
render(<PluginDetailPanel {...defaultProps()} />);
expect(screen.queryByTestId('discord-icon')).not.toBeInTheDocument();
});
it('rewrites discord.com/channels URL to discord:// protocol', () => {
render(
<PluginDetailPanel
{...defaultProps({
detail: makeDetail({
discord_thread: 'https://discord.com/channels/123/456',
}),
})}
/>
);
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(
<PluginDetailPanel
{...defaultProps({
detail: makeDetail({ discord_thread: url }),
})}
/>
);
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(
<PluginDetailPanel
{...defaultProps({ detail: makeDetail({ deprecated: true }) })}
/>
);
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(<PluginDetailPanel {...defaultProps()} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
});
// Version select
describe('version select', () => {
it('renders the version select when versions exist', () => {
render(<PluginDetailPanel {...defaultProps()} />);
expect(screen.getByTestId('version-select')).toBeInTheDocument();
});
it('does not render version select when versions list is empty', () => {
render(
<PluginDetailPanel
{...defaultProps({ detail: makeDetail({ versions: [] }) })}
/>
);
expect(screen.queryByTestId('version-select')).not.toBeInTheDocument();
});
it('renders options from buildVersionSelectItems return value', () => {
render(<PluginDetailPanel {...defaultProps()} />);
// 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(
<PluginDetailPanel
{...defaultProps({
detail,
installedVersion: '1.0.0',
installedVersionIsPrerelease: false,
})}
/>
);
expect(buildVersionSelectItems).toHaveBeenCalledWith(
detail.manifest.versions,
'2.0.0',
'1.0.0',
false
);
});
it('passes installedVersionIsPrerelease=true to buildVersionSelectItems', () => {
render(
<PluginDetailPanel
{...defaultProps({
installedVersion: '2.0.0-beta',
installedVersionIsPrerelease: true,
})}
/>
);
expect(buildVersionSelectItems).toHaveBeenCalledWith(
expect.any(Array),
expect.anything(),
'2.0.0-beta',
true
);
});
it('passes null installedVersion to buildVersionSelectItems when not installed', () => {
render(
<PluginDetailPanel {...defaultProps({ installedVersion: null })} />
);
expect(buildVersionSelectItems).toHaveBeenCalledWith(
expect.any(Array),
expect.anything(),
null,
false
);
});
it('does not call buildVersionSelectItems when versions list is empty', () => {
render(
<PluginDetailPanel
{...defaultProps({ detail: makeDetail({ versions: [] }) })}
/>
);
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(
<PluginDetailPanel
{...defaultProps({
installedVersion: '1.5.0',
installStatus: 'installed',
})}
/>
);
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(<PluginDetailPanel {...defaultProps({ onVersionChange })} />);
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(<PluginDetailPanel {...defaultProps()} />);
expect(screen.getByTestId('button')).toHaveTextContent('Install');
});
it('calls onInstall with correct params when Install is clicked', () => {
const onInstall = vi.fn();
render(<PluginDetailPanel {...defaultProps({ onInstall })} />);
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(<PluginDetailPanel {...defaultProps({ installing: true })} />);
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(
<PluginDetailPanel
{...defaultProps({
installedVersion: '1.0.0',
selectedVersion: '2.0.0',
installStatus: 'installed',
})}
/>
);
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(
<PluginDetailPanel
{...defaultProps({
installedVersion: '2.0.0',
selectedVersion: '1.0.0',
installStatus: 'installed',
})}
/>
);
expect(screen.getByTestId('button')).toHaveTextContent('Downgrade');
});
it('shows "Uninstall" button when installed version equals selected version', () => {
vi.mocked(compareVersions).mockReturnValue(0);
render(
<PluginDetailPanel
{...defaultProps({
installedVersion: '2.0.0',
selectedVersion: '2.0.0',
installStatus: 'installed',
})}
/>
);
expect(screen.getByTestId('button')).toHaveTextContent('Uninstall');
});
it('calls onUninstall when Uninstall is clicked', () => {
vi.mocked(compareVersions).mockReturnValue(0);
const onUninstall = vi.fn();
render(
<PluginDetailPanel
{...defaultProps({
installedVersion: '2.0.0',
selectedVersion: '2.0.0',
installStatus: 'installed',
onUninstall,
})}
/>
);
fireEvent.click(screen.getByTestId('button'));
expect(onUninstall).toHaveBeenCalled();
});
it('shows "Uninstalling…" and loader while uninstalling', () => {
vi.mocked(compareVersions).mockReturnValue(0);
render(
<PluginDetailPanel
{...defaultProps({
installedVersion: '2.0.0',
selectedVersion: '2.0.0',
installStatus: 'installed',
uninstalling: true,
})}
/>
);
expect(screen.getByTestId('button')).toHaveTextContent('Uninstalling…');
});
it('shows "Overwrite" for unmanaged install status', () => {
render(
<PluginDetailPanel {...defaultProps({ installStatus: 'unmanaged' })} />
);
expect(screen.getByTestId('button')).toHaveTextContent('Overwrite');
});
it('shows "Overwrite" for different_repo install status', () => {
render(
<PluginDetailPanel
{...defaultProps({
installStatus: 'different_repo',
installedSourceRepoName: 'Other Repo',
})}
/>
);
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(<PluginDetailPanel {...defaultProps({ detail })} />);
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(
<PluginDetailPanel {...defaultProps({ detail, appVersion: '1.5.0' })} />
);
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(
<PluginDetailPanel {...defaultProps({ detail, appVersion: '1.5.0' })} />
);
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(<PluginDetailPanel {...defaultProps()} />);
// The date is locale-formatted; just check the "Built" label exists
expect(screen.getByText('Built')).toBeInTheDocument();
});
it('renders file size via formatKB', () => {
render(<PluginDetailPanel {...defaultProps()} />);
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(<PluginDetailPanel {...defaultProps({ detail })} />);
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(<PluginDetailPanel {...defaultProps({ detail })} />);
expect(screen.getByText('Min Version')).toBeInTheDocument();
expect(screen.getByText('1.0.0')).toBeInTheDocument();
});
it('does not render min version row when absent', () => {
render(<PluginDetailPanel {...defaultProps()} />);
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(<PluginDetailPanel {...defaultProps({ detail })} />);
expect(screen.getByText('Max Version')).toBeInTheDocument();
expect(screen.getByText('3.0.0')).toBeInTheDocument();
});
it('renders commit short SHA', () => {
render(<PluginDetailPanel {...defaultProps()} />);
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(<PluginDetailPanel {...defaultProps({ detail })} />);
const commitLink = screen
.getAllByTestId('text-link')
.find((el) => el.href?.includes('abc123full'));
expect(commitLink).toBeTruthy();
});
it('renders download URL as a link', () => {
render(<PluginDetailPanel {...defaultProps()} />);
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(<PluginDetailPanel {...defaultProps({ detail })} />);
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(<PluginDetailPanel {...defaultProps({ detail })} />);
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(
<PluginDetailPanel {...defaultProps({ selectedVersion: '3.0.0' })} />
);
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');
});
});
});

View file

@ -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 }) => (
<svg data-testid="icon-alert-triangle" data-size={size} />
),
Info: ({ size }) => <svg data-testid="icon-info" data-size={size} />,
OctagonAlert: ({ size }) => (
<svg data-testid="icon-octagon-alert" data-size={size} />
),
}));
// Mantine core mock
vi.mock('@mantine/core', () => ({
Box: ({ children, style }) => <div style={style}>{children}</div>,
Text: ({ children, size, style }) => (
<p data-size={size} style={style}>
{children}
</p>
),
}));
//
describe('PluginWarnings', () => {
// PluginSecurityWarning
describe('PluginSecurityWarning', () => {
it('renders children text', () => {
render(
<PluginSecurityWarning>This plugin is dangerous</PluginSecurityWarning>
);
expect(screen.getByText('This plugin is dangerous')).toBeInTheDocument();
});
it('renders the OctagonAlert icon', () => {
render(<PluginSecurityWarning>Warning</PluginSecurityWarning>);
expect(screen.getByTestId('icon-octagon-alert')).toBeInTheDocument();
});
it('does not render AlertTriangle or Info icons', () => {
render(<PluginSecurityWarning>Warning</PluginSecurityWarning>);
expect(
screen.queryByTestId('icon-alert-triangle')
).not.toBeInTheDocument();
expect(screen.queryByTestId('icon-info')).not.toBeInTheDocument();
});
it('renders different children correctly', () => {
render(
<PluginSecurityWarning>
<strong>Critical</strong> security issue
</PluginSecurityWarning>
);
expect(screen.getByText('Critical')).toBeInTheDocument();
});
});
// PluginSupportDisclaimer
describe('PluginSupportDisclaimer', () => {
it('renders the disclaimer text', () => {
render(<PluginSupportDisclaimer />);
expect(
screen.getByText(
/Dispatcharr community support cannot assist with third-party plugin issues/i
)
).toBeInTheDocument();
});
it('mentions plugin Discord thread', () => {
render(<PluginSupportDisclaimer />);
expect(
screen.getByText(/use the plugin.*Discord thread/i)
).toBeInTheDocument();
});
it('mentions submitting an issue on the plugin repository', () => {
render(<PluginSupportDisclaimer />);
expect(
screen.getByText(/submit an issue.*on the plugin.*repository/i)
).toBeInTheDocument();
});
it('renders the Dispatcharr logo image', () => {
render(<PluginSupportDisclaimer />);
const logo = screen.getByAltText('Dispatcharr');
expect(logo).toBeInTheDocument();
expect(logo).toHaveAttribute('src', 'mocked-logo.png');
});
it('renders logo as non-draggable', () => {
render(<PluginSupportDisclaimer />);
const logo = screen.getByAltText('Dispatcharr');
expect(logo).toHaveAttribute('draggable', 'false');
});
it('does not render any lucide icons', () => {
render(<PluginSupportDisclaimer />);
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(
<PluginDowngradeWarning>
Downgrading may break things
</PluginDowngradeWarning>
);
expect(
screen.getByText('Downgrading may break things')
).toBeInTheDocument();
});
it('renders the AlertTriangle icon', () => {
render(<PluginDowngradeWarning>Caution</PluginDowngradeWarning>);
expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument();
});
it('does not render OctagonAlert or Info icons', () => {
render(<PluginDowngradeWarning>Caution</PluginDowngradeWarning>);
expect(
screen.queryByTestId('icon-octagon-alert')
).not.toBeInTheDocument();
expect(screen.queryByTestId('icon-info')).not.toBeInTheDocument();
});
it('renders JSX children correctly', () => {
render(
<PluginDowngradeWarning>
<span data-testid="inner">Inner content</span>
</PluginDowngradeWarning>
);
expect(screen.getByTestId('inner')).toBeInTheDocument();
});
});
// PluginInfoNote
describe('PluginInfoNote', () => {
it('renders children text', () => {
render(<PluginInfoNote>This is an informational note.</PluginInfoNote>);
expect(
screen.getByText('This is an informational note.')
).toBeInTheDocument();
});
it('renders the Info icon', () => {
render(<PluginInfoNote>Note</PluginInfoNote>);
expect(screen.getByTestId('icon-info')).toBeInTheDocument();
});
it('does not render OctagonAlert or AlertTriangle icons', () => {
render(<PluginInfoNote>Note</PluginInfoNote>);
expect(
screen.queryByTestId('icon-octagon-alert')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('icon-alert-triangle')
).not.toBeInTheDocument();
});
it('renders JSX children correctly', () => {
render(
<PluginInfoNote>
<span data-testid="info-child">details</span>
</PluginInfoNote>
);
expect(screen.getByTestId('info-child')).toBeInTheDocument();
});
});
// PluginRestartWarning
describe('PluginRestartWarning', () => {
it('renders the restart warning text', () => {
render(<PluginRestartWarning />);
expect(
screen.getByText(/Importing a plugin may briefly restart the backend/i)
).toBeInTheDocument();
});
it('mentions temporary disconnect', () => {
render(<PluginRestartWarning />);
expect(
screen.getByText(/you might see a.*temporary disconnect/i)
).toBeInTheDocument();
});
it('mentions automatic reconnect', () => {
render(<PluginRestartWarning />);
expect(
screen.getByText(/the app will.*reconnect automatically/i)
).toBeInTheDocument();
});
it('renders the AlertTriangle icon', () => {
render(<PluginRestartWarning />);
expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument();
});
it('does not render OctagonAlert or Info icons', () => {
render(<PluginRestartWarning />);
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(<PluginSecurityWarning>msg</PluginSecurityWarning>);
expect(screen.getByText('msg')).toHaveAttribute('data-size', 'xs');
});
it('PluginSupportDisclaimer renders xs Text', () => {
render(<PluginSupportDisclaimer />);
const text = screen.getByText(
/Dispatcharr community support cannot assist/i
);
expect(text).toHaveAttribute('data-size', 'xs');
});
it('PluginDowngradeWarning renders xs Text', () => {
render(<PluginDowngradeWarning>msg</PluginDowngradeWarning>);
expect(screen.getByText('msg')).toHaveAttribute('data-size', 'xs');
});
it('PluginInfoNote renders xs Text', () => {
render(<PluginInfoNote>msg</PluginInfoNote>);
expect(screen.getByText('msg')).toHaveAttribute('data-size', 'xs');
});
it('PluginRestartWarning renders xs Text', () => {
render(<PluginRestartWarning />);
expect(screen.getByText(/Importing a plugin/i)).toHaveAttribute(
'data-size',
'xs'
);
});
});
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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 }) => (
<span
data-testid="badge"
data-color={color}
data-variant={variant}
data-size={size}
>
{children}
</span>
),
Group: ({ children, gap, align }) => (
<div data-gap={gap} data-align={align}>
{children}
</div>
),
Paper: ({ children, p, withBorder }) => (
<div data-testid="paper" data-p={p} data-with-border={String(withBorder)}>
{children}
</div>
),
SimpleGrid: ({ children, spacing }) => (
<div data-testid="simple-grid" data-spacing={spacing}>
{children}
</div>
),
Stack: ({ children, gap }) => <div data-gap={gap}>{children}</div>,
Text: ({ children, size, c, fw }) => (
<span data-size={size} data-color={c} data-fw={fw}>
{children}
</span>
),
Tooltip: ({ children, label }) => (
<div data-testid="tooltip" data-tooltip={label}>
{children}
</div>
),
}));
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(<ConnectionSecurityPanel />);
expect(
screen.getByText(
/Encrypt connections to Redis and PostgreSQL using environment variables/i
)
).toBeInTheDocument();
});
it('renders both Redis and PostgreSQL service cards', () => {
setupStore();
render(<ConnectionSecurityPanel />);
expect(getServiceCard('Redis')).toBeTruthy();
expect(getServiceCard('PostgreSQL')).toBeTruthy();
});
});
// RedisStatus TLS disabled
describe('RedisStatus — TLS disabled (default)', () => {
beforeEach(() => {
setupStore({ redis_tls: { enabled: false } });
render(<ConnectionSecurityPanel />);
});
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(<ConnectionSecurityPanel />);
});
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(<ConnectionSecurityPanel />);
});
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(<ConnectionSecurityPanel />);
});
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(<ConnectionSecurityPanel />);
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(<ConnectionSecurityPanel />);
});
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(<ConnectionSecurityPanel />);
});
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(<ConnectionSecurityPanel />);
});
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(<ConnectionSecurityPanel />);
});
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(<ConnectionSecurityPanel />);
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(<ConnectionSecurityPanel />);
});
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(<ConnectionSecurityPanel />);
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(<ConnectionSecurityPanel />);
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(<ConnectionSecurityPanel />);
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(<ConnectionSecurityPanel />);
// 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(<ConnectionSecurityPanel />);
});
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();
});
});
});

View file

@ -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 }) => (
<div data-testid="alert" data-color={color} data-variant={variant}>
{title}
</div>
),
Button: ({ children, type, disabled }) => (
<button type={type} disabled={disabled}>
{children}
</button>
),
Flex: ({ children, justify }) => <div data-justify={justify}>{children}</div>,
NumberInput: ({ label, description, min, max, value, onChange }) => (
<div>
{label && <label data-testid="number-input-label">{label}</label>}
{description && (
<span data-testid="number-input-description">{description}</span>
)}
<input
data-testid="number-input"
type="number"
min={min}
max={max}
value={value ?? 0}
onChange={(e) => onChange?.(Number(e.target.value))}
/>
</div>
),
Stack: ({ children, gap }) => <div data-gap={gap}>{children}</div>,
Text: ({ children, size, c }) => (
<span data-testid="text" data-size={size} data-color={c}>
{children}
</span>
),
}));
// 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(<EpgSettingsForm active={true} />);
expect(screen.getByTestId('number-input')).toBeInTheDocument();
});
it('renders the NumberInput label from EPG_SETTINGS_OPTIONS', () => {
render(<EpgSettingsForm active={true} />);
expect(
screen.getByText(EPG_SETTINGS_OPTIONS.xmltv_prev_days_override.label)
).toBeInTheDocument();
});
it('renders the NumberInput description from EPG_SETTINGS_OPTIONS', () => {
render(<EpgSettingsForm active={true} />);
expect(
screen.getByText(
EPG_SETTINGS_OPTIONS.xmltv_prev_days_override.description
)
).toBeInTheDocument();
});
it('renders the disclaimer text about per-user defaults', () => {
render(<EpgSettingsForm active={true} />);
expect(
screen.getByText(
/Per-user defaults and URL parameters still override this global value/i
)
).toBeInTheDocument();
});
it('renders the EPG channel matching hint', () => {
render(<EpgSettingsForm active={true} />);
expect(
screen.getByText(
/EPG channel matching options are configured from the Channels page/i
)
).toBeInTheDocument();
});
it('renders a Save button of type="submit"', () => {
render(<EpgSettingsForm active={true} />);
const btn = screen.getByText('Save');
expect(btn).toBeInTheDocument();
expect(btn).toHaveAttribute('type', 'submit');
});
it('does not show the "Saved Successfully" alert initially', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('NumberInput has min=0', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.getByTestId('number-input')).toHaveAttribute('min', '0');
});
it('NumberInput has max=30', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.getByTestId('number-input')).toHaveAttribute('max', '30');
});
it('NumberInput starts with value 0 from initial form values', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.getByTestId('number-input')).toHaveValue(0);
});
});
// Initialization
describe('initialization', () => {
it('calls getEpgSettingsFormInitialValues to seed useForm', () => {
render(<EpgSettingsForm active={true} />);
expect(getEpgSettingsFormInitialValues).toHaveBeenCalled();
});
it('passes initial values from getEpgSettingsFormInitialValues to useForm', () => {
vi.mocked(getEpgSettingsFormInitialValues).mockReturnValue({
xmltv_prev_days_override: 5,
});
render(<EpgSettingsForm active={true} />);
expect(vi.mocked(useForm)).toHaveBeenCalledWith(
expect.objectContaining({
initialValues: { xmltv_prev_days_override: 5 },
})
);
});
it('calls useForm with mode="controlled"', () => {
render(<EpgSettingsForm active={true} />);
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(<EpgSettingsForm active={true} />);
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(<EpgSettingsForm active={true} />);
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(<EpgSettingsForm active={true} />);
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(<EpgSettingsForm active={true} />);
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(<EpgSettingsForm active={true} />);
// 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(<EpgSettingsForm active={false} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('does not show alert when active starts as false', () => {
render(<EpgSettingsForm active={false} />);
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(<EpgSettingsForm active={true} />);
// 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(<EpgSettingsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(SettingsUtils.saveChangedSettings).toHaveBeenCalledWith(
settings,
changed
);
});
});
it('shows "Saved Successfully" alert after a successful save', async () => {
render(<EpgSettingsForm active={true} />);
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(<EpgSettingsForm active={true} />);
// 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(<EpgSettingsForm active={true} />);
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(<EpgSettingsForm active={true} />);
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(<EpgSettingsForm active={true} />);
expect(mockForm.getInputProps).toHaveBeenCalledWith(
'xmltv_prev_days_override'
);
});
});
});

View file

@ -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 }) => (
<div data-testid="alert" data-color={color} data-variant={variant}>
{title}
</div>
),
Button: ({ children, type, disabled, variant, color, onClick }) => (
<button
type={type}
disabled={disabled}
data-variant={variant}
data-color={color}
onClick={onClick}
>
{children}
</button>
),
Checkbox: ({ label, description, checked, onChange }) => (
<div data-testid="checkbox-wrapper">
{label && <label data-testid="checkbox-label">{label}</label>}
{description && (
<span data-testid="checkbox-description">{description}</span>
)}
<input
data-testid={`checkbox-${label}`}
type="checkbox"
checked={checked ?? false}
onChange={(e) => onChange?.(e.currentTarget.checked)}
/>
</div>
),
Flex: ({ children, mih, gap, justify, align }) => (
<div
data-mih={mih}
data-gap={gap}
data-justify={justify}
data-align={align}
>
{children}
</div>
),
Stack: ({ children, gap }) => <div data-gap={gap}>{children}</div>,
}));
// 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(<UserLimitsForm active={true} />);
expect(screen.getAllByTestId('checkbox-wrapper')).toHaveLength(
Object.keys(USER_LIMITS_OPTIONS).length
);
});
it('renders a Checkbox for every USER_LIMITS_OPTIONS entry', () => {
render(<UserLimitsForm active={true} />);
Object.values(USER_LIMITS_OPTIONS).forEach((opt) => {
expect(screen.getByText(opt.label)).toBeInTheDocument();
});
});
it('renders every checkbox description', () => {
render(<UserLimitsForm active={true} />);
Object.values(USER_LIMITS_OPTIONS).forEach((opt) => {
expect(screen.getByText(opt.description)).toBeInTheDocument();
});
});
it('renders a Save button of type="submit"', () => {
render(<UserLimitsForm active={true} />);
const btn = screen.getByText('Save');
expect(btn).toHaveAttribute('type', 'submit');
});
it('renders a "Reset to Defaults" button', () => {
render(<UserLimitsForm active={true} />);
expect(screen.getByText('Reset to Defaults')).toBeInTheDocument();
});
it('does not show the "Saved Successfully" alert initially', () => {
render(<UserLimitsForm active={true} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
});
// Initialization
describe('initialization', () => {
it('calls useForm with mode="controlled"', () => {
render(<UserLimitsForm active={true} />);
expect(vi.mocked(useForm)).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'controlled' })
);
});
it('seeds useForm with USER_LIMIT_DEFAULTS as initialValues', () => {
render(<UserLimitsForm active={true} />);
expect(vi.mocked(useForm)).toHaveBeenCalledWith(
expect.objectContaining({ initialValues: USER_LIMIT_DEFAULTS })
);
});
it('calls getInputProps with type:"checkbox" for each option key', () => {
render(<UserLimitsForm active={true} />);
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(<UserLimitsForm active={true} />);
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(<UserLimitsForm active={true} />);
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(<UserLimitsForm active={true} />);
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(<UserLimitsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
rerender(<UserLimitsForm active={false} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('does not show alert when active starts as false', () => {
render(<UserLimitsForm active={false} />);
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(<UserLimitsForm active={true} />);
fireEvent.click(screen.getByText('Reset to Defaults'));
expect(mockForm.setValues).toHaveBeenCalledWith(USER_LIMIT_DEFAULTS);
});
it('can be clicked multiple times without error', () => {
render(<UserLimitsForm active={true} />);
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(<UserLimitsForm active={true} />);
// 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(<UserLimitsForm active={true} />);
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(<UserLimitsForm active={true} />);
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(<UserLimitsForm active={true} />);
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(<UserLimitsForm active={true} />);
// 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(<UserLimitsForm active={true} />);
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(<UserLimitsForm active={true} />);
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(<UserLimitsForm active={true} />);
expect(screen.getByText('Save')).toBeDisabled();
});
it('is not disabled when form.submitting is false', () => {
mockForm.submitting = false;
render(<UserLimitsForm active={true} />);
expect(screen.getByText('Save')).not.toBeDisabled();
});
});
});