mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Added tests for components
This commit is contained in:
parent
71f1c7d5e5
commit
b228d1cab1
8 changed files with 5325 additions and 0 deletions
318
frontend/src/components/__tests__/AboutModal.test.jsx
Normal file
318
frontend/src/components/__tests__/AboutModal.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
863
frontend/src/components/__tests__/PluginDetailPanel.test.jsx
Normal file
863
frontend/src/components/__tests__/PluginDetailPanel.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
266
frontend/src/components/__tests__/PluginWarnings.test.jsx
Normal file
266
frontend/src/components/__tests__/PluginWarnings.test.jsx
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
1117
frontend/src/components/backups/__tests__/BackupManager.test.jsx
Normal file
1117
frontend/src/components/backups/__tests__/BackupManager.test.jsx
Normal file
File diff suppressed because it is too large
Load diff
1324
frontend/src/components/cards/__tests__/AvailablePluginCard.test.jsx
Normal file
1324
frontend/src/components/cards/__tests__/AvailablePluginCard.test.jsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue