Merge pull request #1424 from nick4810:tests/frontend-unit-tests

Tests/frontend unit tests
This commit is contained in:
SergeantPanda 2026-07-12 09:27:26 -05:00 committed by GitHub
commit d56680cad8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 6765 additions and 416 deletions

View file

@ -10,8 +10,8 @@ import {
Text,
Tooltip,
} from '@mantine/core';
import { BookOpen, Github, Heart, Users } from 'lucide-react';
import { DiscordIcon } from './icons.jsx';
import { BookOpen, Heart, Users } from 'lucide-react';
import { DiscordIcon, GitHubIcon } from './icons.jsx';
import logo from '../images/logo.png';
import useSettingsStore from '../store/settings';
@ -76,7 +76,7 @@ const AboutModal = ({ isOpen, onClose }) => {
target="_blank"
rel="noopener noreferrer"
variant="default"
leftSection={<Github size={15} />}
leftSection={<GitHubIcon size={15} />}
fullWidth
>
GitHub

View file

@ -9,6 +9,9 @@ import {
Select,
Stack,
Table,
TableTbody,
TableTd,
TableTr,
Text,
Tooltip,
} from '@mantine/core';
@ -21,7 +24,11 @@ import {
ShieldCheck,
Trash2,
} from 'lucide-react';
import { compareVersions } from './pluginUtils.js';
import {
buildCompatibilityTooltip,
buildVersionSelectItems,
compareVersions,
} from '../utils/components/pluginUtils.js';
import { formatKB } from '../utils/networkUtils.js';
import { DiscordIcon, GitHubIcon } from './icons.jsx';
@ -294,42 +301,12 @@ const PluginDetailPanel = ({
{manifest.versions?.length > 0 &&
(() => {
const installedMissing =
installedVersion &&
!manifest.versions.some(
(v) => compareVersions(v.version, installedVersion) === 0
);
const buildLabel = (v) =>
`v${v.version}${v.prerelease ? ' (prerelease)' : ''}${v.version === manifest.latest?.version ? ' (latest)' : ''}${installedVersion && compareVersions(v.version, installedVersion) === 0 ? ' (installed)' : ''}`;
let versions = [...manifest.versions];
if (installedVersionIsPrerelease) {
const prereleases = versions.filter((v) => v.prerelease);
const stable = versions.filter((v) => !v.prerelease);
versions = [...prereleases, ...stable];
}
const versionItems = versions.map((v) => ({
value: v.version,
label: buildLabel(v),
disabled: false,
}));
if (installedMissing) {
const ghostItem = {
value: installedVersion,
label: `v${installedVersion} (installed)`,
disabled: true,
};
// Insert in sorted position (newest first, matching manifest order convention)
const idx = versionItems.findIndex(
(item) => compareVersions(installedVersion, item.value) > 0
);
if (idx === -1) {
versionItems.push(ghostItem);
} else {
versionItems.splice(idx, 0, ghostItem);
}
}
const versionItems = buildVersionSelectItems(
manifest.versions,
manifest.latest?.version,
installedVersion,
installedVersionIsPrerelease
);
return (
<>
<Group gap="xs" align="flex-end">
@ -372,21 +349,17 @@ const PluginDetailPanel = ({
selectedVersionData &&
!isSelSame &&
(() => {
const parts = [];
if (!selMeetsMin)
parts.push(
`${selectedVersionData.min_dispatcharr_version} or newer`
);
if (!selMeetsMax)
parts.push(
`${selectedVersionData.max_dispatcharr_version} or older`
);
const tooltip = buildCompatibilityTooltip(
selMeetsMin,
selectedVersionData,
selMeetsMax
);
const label = !selMeetsMin
? `Min ${selectedVersionData.min_dispatcharr_version}`
: `Max ${selectedVersionData.max_dispatcharr_version}`;
return (
<Tooltip
label={`Incompatible: requires Dispatcharr ${parts.join(' and ')} (you have v${appVersion})`}
label={`Incompatible: requires Dispatcharr ${tooltip} (you have v${appVersion})`}
>
<Group gap={4} align="center" wrap="nowrap">
<AlertTriangle
@ -409,56 +382,56 @@ const PluginDetailPanel = ({
highlightOnHover
style={{ tableLayout: 'auto' }}
>
<Table.Tbody>
<TableTbody>
{selectedVersionData.build_timestamp && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
<TableTr>
<TableTd fw={500} style={{ whiteSpace: 'nowrap' }}>
Built
</Table.Td>
<Table.Td>
</TableTd>
<TableTd>
{new Date(
selectedVersionData.build_timestamp
).toLocaleString()}
</Table.Td>
</Table.Tr>
</TableTd>
</TableTr>
)}
{Number.isFinite(selectedVersionData.size) &&
selectedVersionData.size > 0 && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
<TableTr>
<TableTd fw={500} style={{ whiteSpace: 'nowrap' }}>
File Size
</Table.Td>
<Table.Td>
</TableTd>
<TableTd>
{formatKB(selectedVersionData.size)}
</Table.Td>
</Table.Tr>
</TableTd>
</TableTr>
)}
{selectedVersionData.min_dispatcharr_version && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
<TableTr>
<TableTd fw={500} style={{ whiteSpace: 'nowrap' }}>
Min Version
</Table.Td>
<Table.Td>
</TableTd>
<TableTd>
{selectedVersionData.min_dispatcharr_version}
</Table.Td>
</Table.Tr>
</TableTd>
</TableTr>
)}
{selectedVersionData.max_dispatcharr_version && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
<TableTr>
<TableTd fw={500} style={{ whiteSpace: 'nowrap' }}>
Max Version
</Table.Td>
<Table.Td>
</TableTd>
<TableTd>
{selectedVersionData.max_dispatcharr_version}
</Table.Td>
</Table.Tr>
</TableTd>
</TableTr>
)}
{selectedVersionData.commit_sha_short && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
<TableTr>
<TableTd fw={500} style={{ whiteSpace: 'nowrap' }}>
Commit
</Table.Td>
<Table.Td>
</TableTd>
<TableTd>
{manifest.registry_url ? (
<Text
size="xs"
@ -473,15 +446,15 @@ const PluginDetailPanel = ({
) : (
selectedVersionData.commit_sha_short
)}
</Table.Td>
</Table.Tr>
</TableTd>
</TableTr>
)}
{selectedVersionData.url && (
<Table.Tr>
<Table.Td fw={500} style={{ whiteSpace: 'nowrap' }}>
<TableTr>
<TableTd fw={500} style={{ whiteSpace: 'nowrap' }}>
Download
</Table.Td>
<Table.Td>
</TableTd>
<TableTd>
<Text
size="xs"
component="a"
@ -492,10 +465,10 @@ const PluginDetailPanel = ({
>
{selectedVersionData.url.split('/').pop()}
</Text>
</Table.Td>
</Table.Tr>
</TableTd>
</TableTr>
)}
</Table.Tbody>
</TableTbody>
</Table>
)}
</>

View file

@ -0,0 +1,318 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import AboutModal from '../AboutModal';
// Store mock
vi.mock('../../store/settings', () => ({ default: vi.fn() }));
// Image mock
vi.mock('../../images/logo.png', () => ({ default: 'mocked-logo.png' }));
// Custom icons mock
vi.mock('../icons.jsx', () => ({
DiscordIcon: ({ size }) => (
<svg data-testid="discord-icon" data-size={size} />
),
GitHubIcon: ({ size }) => <svg data-testid="github-icon" data-size={size} />,
}));
// lucide-react mock
vi.mock('lucide-react', () => ({
BookOpen: ({ size }) => <svg data-testid="icon-book-open" data-size={size} />,
Heart: ({ size }) => <svg data-testid="icon-heart" data-size={size} />,
Users: ({ size }) => <svg data-testid="icon-users" data-size={size} />,
}));
// Mantine core mock
vi.mock('@mantine/core', async () => ({
Box: ({ children, style }) => <div style={style}>{children}</div>,
Button: ({ children, href, target, rel, variant, color, leftSection }) => (
<a
data-testid="button"
href={href}
target={target}
rel={rel}
data-variant={variant}
data-color={color}
>
{leftSection}
{children}
</a>
),
Divider: () => <hr data-testid="divider" />,
Group: ({ children, justify }) => (
<div data-justify={justify}>{children}</div>
),
Modal: ({ children, opened, onClose, title, size }) =>
opened ? (
<div data-testid="modal" data-size={size}>
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
SimpleGrid: ({ children, cols }) => <div data-cols={cols}>{children}</div>,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, fw, size, c, span }) =>
span ? (
<span data-fw={fw} data-size={size} data-color={c}>
{children}
</span>
) : (
<p data-fw={fw} data-size={size} data-color={c}>
{children}
</p>
),
Tooltip: ({ children, label, position }) => (
<div data-tooltip={label} data-position={position}>
{children}
</div>
),
}));
//
// Imports after mocks
//
import useSettingsStore from '../../store/settings';
// Helpers
const setupStore = (version = { version: '1.2.3', timestamp: '20240601' }) => {
vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ version }));
};
describe('AboutModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Visibility
describe('visibility', () => {
it('renders modal content when isOpen is true', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render modal content when isOpen is false', () => {
setupStore();
render(<AboutModal isOpen={false} onClose={vi.fn()} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('calls onClose when close button is clicked', () => {
setupStore();
const onClose = vi.fn();
render(<AboutModal isOpen={true} onClose={onClose} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('renders with the correct modal title', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'About Dispatcharr'
);
});
});
// Version string
describe('version string', () => {
it('displays version with timestamp when both are present', () => {
setupStore({ version: '2.0.0', timestamp: '20240601' });
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('v2.0.0-20240601')).toBeInTheDocument();
});
it('displays version without timestamp when timestamp is null', () => {
setupStore({ version: '1.5.0', timestamp: null });
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('v1.5.0')).toBeInTheDocument();
});
it('displays version without timestamp when timestamp is undefined', () => {
setupStore({ version: '1.5.0' });
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('v1.5.0')).toBeInTheDocument();
});
it('falls back to v0.0.0 when version is undefined', () => {
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ version: undefined })
);
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('v0.0.0')).toBeInTheDocument();
});
it('falls back to v0.0.0 when version object has no version field', () => {
setupStore({ version: '', timestamp: null });
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('v0.0.0')).toBeInTheDocument();
});
});
// Logo & branding
describe('logo and branding', () => {
it('renders the Dispatcharr logo', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
const logo = screen.getByAltText('Dispatcharr');
expect(logo).toBeInTheDocument();
expect(logo).toHaveAttribute('src', 'mocked-logo.png');
});
it('renders the app name "Dispatcharr"', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('Dispatcharr')).toBeInTheDocument();
});
});
// Action buttons
describe('action buttons', () => {
it('renders the Documentation button with correct href', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('Documentation').closest('a')).toHaveAttribute(
'href',
'https://dispatcharr.github.io/Dispatcharr-Docs/'
);
});
it('renders the Discord button with correct href', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('Discord').closest('a')).toHaveAttribute(
'href',
'https://discord.gg/Sp45V5BcxU'
);
});
it('renders the GitHub button with correct href', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('GitHub').closest('a')).toHaveAttribute(
'href',
'https://github.com/Dispatcharr/Dispatcharr'
);
});
it('renders the Donate button with correct href', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('Donate').closest('a')).toHaveAttribute(
'href',
'https://opencollective.com/dispatcharr/contribute'
);
});
it('all external buttons open in a new tab', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
const buttons = screen.getAllByTestId('button');
buttons.forEach((btn) => {
expect(btn).toHaveAttribute('target', '_blank');
});
});
it('all external buttons have noopener noreferrer rel', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
const buttons = screen.getAllByTestId('button');
buttons.forEach((btn) => {
expect(btn).toHaveAttribute('rel', 'noopener noreferrer');
});
});
it('renders 4 action buttons', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getAllByTestId('button')).toHaveLength(4);
});
});
// Icons
describe('icons', () => {
it('renders BookOpen icon for Documentation button', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('icon-book-open')).toBeInTheDocument();
});
it('renders DiscordIcon for Discord button', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('discord-icon')).toBeInTheDocument();
});
it('renders GitHubIcon for GitHub button', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('github-icon')).toBeInTheDocument();
});
it('renders Heart icon for Donate button', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('icon-heart')).toBeInTheDocument();
});
it('renders Users icon in Contributors section', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('icon-users')).toBeInTheDocument();
});
});
// Contributors section
describe('contributors section', () => {
it('renders the Contributors heading', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('Contributors')).toBeInTheDocument();
});
it('renders the contributors description text', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(
screen.getByText(
/Dispatcharr is built by the community, for the community/i
)
).toBeInTheDocument();
});
});
// Memorial section
describe('memorial section', () => {
it('renders the memorial text for Jesse Mann', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText(/In memory of/i)).toBeInTheDocument();
});
it('renders Jesse Mann name in the memorial', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText('Jesse Mann')).toBeInTheDocument();
});
it('renders the memorial tooltip with correct label', () => {
setupStore();
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
const tooltip = screen
.getByText('Jesse Mann')
.closest('[data-tooltip="Remembering Jesse Mann"]');
expect(tooltip).toBeInTheDocument();
});
});
});

View file

@ -10,9 +10,7 @@ vi.mock('@mantine/core', () => ({
</span>
),
Box: ({ children, ...props }) => <span {...props}>{children}</span>,
Tooltip: ({ children, label }) => (
<div data-tooltip={label}>{children}</div>
),
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
}));
vi.mock('lucide-react', () => ({
@ -21,12 +19,8 @@ vi.mock('lucide-react', () => ({
describe('formatCatchupTooltip', () => {
it('includes archive days when known', () => {
expect(formatCatchupTooltip(7)).toBe(
'Catch-up enabled (7 days archive)'
);
expect(formatCatchupTooltip(1)).toBe(
'Catch-up enabled (1 day archive)'
);
expect(formatCatchupTooltip(7)).toBe('Catch-up enabled (7 days archive)');
expect(formatCatchupTooltip(1)).toBe('Catch-up enabled (1 day archive)');
});
it('falls back when days are zero', () => {
@ -45,13 +39,13 @@ describe('CatchupIndicator', () => {
it('renders a history icon in table rows', () => {
render(<CatchupIndicator isCatchup catchupDays={3} />);
expect(screen.getByTestId('icon-history')).toBeInTheDocument();
expect(screen.getByLabelText('Catch-up enabled (3 days archive)')).toBeInTheDocument();
expect(
screen.getByLabelText('Catch-up enabled (3 days archive)')
).toBeInTheDocument();
});
it('renders a badge in expanded stream rows', () => {
render(
<CatchupIndicator isCatchup catchupDays={5} variant="badge" />
);
render(<CatchupIndicator isCatchup catchupDays={5} variant="badge" />);
expect(screen.getByTestId('catchup-badge')).toHaveTextContent('Catch-up');
expect(screen.getByTestId('icon-history')).toBeInTheDocument();
});

View file

@ -24,11 +24,7 @@ vi.mock('@mantine/core', async () => {
),
Checkbox: ({ label, checked, onChange }) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={onChange}
/>
<input type="checkbox" checked={checked} onChange={onChange} />
{label}
</label>
),
@ -64,7 +60,9 @@ describe('ConfirmationDialog', () => {
);
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument();
expect(
screen.getByText('Are you sure you want to proceed?')
).toBeInTheDocument();
});
it('should not render when closed', () => {
@ -91,7 +89,9 @@ describe('ConfirmationDialog', () => {
);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Delete Item');
expect(screen.getByText('This action cannot be undone')).toBeInTheDocument();
expect(
screen.getByText('This action cannot be undone')
).toBeInTheDocument();
});
it('should call onConfirm when confirm button is clicked', () => {
@ -144,7 +144,9 @@ describe('ConfirmationDialog', () => {
/>
);
expect(screen.queryByLabelText("Don't ask me again")).not.toBeInTheDocument();
expect(
screen.queryByLabelText("Don't ask me again")
).not.toBeInTheDocument();
});
it('should call suppressWarning when suppress is checked and confirmed', () => {
@ -188,7 +190,9 @@ describe('ConfirmationDialog', () => {
/>
);
expect(screen.getByLabelText('Also delete files from disk')).toBeInTheDocument();
expect(
screen.getByLabelText('Also delete files from disk')
).toBeInTheDocument();
});
it('should pass deleteFiles state to onConfirm when delete option is checked', () => {
@ -229,7 +233,9 @@ describe('ConfirmationDialog', () => {
/>
);
expect(screen.getByLabelText('Also delete files from disk')).not.toBeChecked();
expect(
screen.getByLabelText('Also delete files from disk')
).not.toBeChecked();
});
it('should show loading state on confirm button', () => {

View file

@ -520,7 +520,9 @@ describe('Field', () => {
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('Help text takes priority')).toBeInTheDocument();
expect(screen.queryByText('This should not appear')).not.toBeInTheDocument();
expect(
screen.queryByText('This should not appear')
).not.toBeInTheDocument();
});
it('should use field.value if no help_text or description', () => {
@ -562,7 +564,10 @@ describe('Field', () => {
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password');
expect(screen.getByLabelText('Password')).toHaveAttribute(
'type',
'password'
);
});
it('should render text input when input_type is not password', () => {

View file

@ -18,7 +18,13 @@ vi.mock('../../images/logo.png', () => ({
vi.mock('@mantine/core', async () => {
return {
Skeleton: ({ height, width, style, ...props }) => {
return <div data-testid="skeleton" style={{ height, width, ...style }} {...props} />;
return (
<div
data-testid="skeleton"
style={{ height, width, ...style }}
{...props}
/>
);
},
};
});
@ -103,7 +109,11 @@ describe('LazyLogo', () => {
};
render(
<LazyLogo logoId="logo-1" className="test-class" data-testid="custom-logo" />
<LazyLogo
logoId="logo-1"
className="test-class"
data-testid="custom-logo"
/>
);
const img = screen.getByTestId('custom-logo');

View file

@ -0,0 +1,863 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import PluginDetailPanel from '../PluginDetailPanel';
// Utility mocks
vi.mock('../../utils/components/pluginUtils.js', () => ({
compareVersions: vi.fn(),
buildCompatibilityTooltip: vi.fn(),
buildVersionSelectItems: vi.fn(),
}));
vi.mock('../../utils/networkUtils.js', () => ({
formatKB: vi.fn((kb) => `${kb} KB`),
}));
// Icon mocks
vi.mock('../icons.jsx', () => ({
DiscordIcon: ({ size }) => (
<svg data-testid="discord-icon" data-size={size} />
),
GitHubIcon: ({ size }) => <svg data-testid="github-icon" data-size={size} />,
}));
vi.mock('lucide-react', () => ({
AlertTriangle: ({ size, color }) => (
<svg
data-testid="icon-alert-triangle"
data-size={size}
data-color={color}
/>
),
Ban: ({ size }) => <svg data-testid="icon-ban" data-size={size} />,
Download: ({ size }) => <svg data-testid="icon-download" data-size={size} />,
RefreshCw: ({ size }) => (
<svg data-testid="icon-refresh-cw" data-size={size} />
),
ShieldAlert: ({ size }) => (
<svg data-testid="icon-shield-alert" data-size={size} />
),
ShieldCheck: ({ size }) => (
<svg data-testid="icon-shield-check" data-size={size} />
),
Trash2: ({ size }) => <svg data-testid="icon-trash2" data-size={size} />,
}));
// Mantine core mock
vi.mock('@mantine/core', async () => ({
ActionIcon: ({ children, href, target, rel, color }) => (
<a
data-testid="action-icon"
href={href}
target={target}
rel={rel}
data-color={color}
>
{children}
</a>
),
Alert: ({ children, title, color, icon }) => (
<div data-testid="alert" data-color={color}>
<div data-testid="alert-title">{title}</div>
{icon}
{children}
</div>
),
Badge: ({
children,
component,
href,
target,
rel,
leftSection,
color,
style,
}) =>
component === 'a' ? (
<a
data-testid="badge"
href={href}
target={target}
rel={rel}
style={style}
>
{leftSection}
{children}
</a>
) : (
<span data-testid="badge" data-color={color}>
{leftSection}
{children}
</span>
),
Button: ({ children, onClick, disabled, variant, color, leftSection }) => (
<button
data-testid="button"
onClick={onClick}
disabled={disabled}
data-variant={variant}
data-color={color}
>
{leftSection}
{children}
</button>
),
Group: ({ children }) => <div>{children}</div>,
Loader: ({ size }) => <span data-testid="loader" data-size={size} />,
Select: ({ label, value, onChange, data, disabled }) => (
<select
data-testid="version-select"
value={value ?? ''}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
aria-label={label}
>
{(data ?? []).map((item) => (
<option key={item.value} value={item.value} disabled={item.disabled}>
{item.label}
</option>
))}
</select>
),
Stack: ({ children }) => <div>{children}</div>,
Table: ({ children }) => <table>{children}</table>,
TableTbody: ({ children }) => <tbody>{children}</tbody>,
TableTd: ({ children, style }) => <td style={style}>{children}</td>,
TableTr: ({ children }) => <tr>{children}</tr>,
Text: ({ children, size, c, fw, component, href, target, rel }) =>
component === 'a' ? (
<a data-testid="text-link" href={href} target={target} rel={rel}>
{children}
</a>
) : (
<span data-size={size} data-color={c} data-fw={fw}>
{children}
</span>
),
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
}));
//
// Imports after mocks
//
import {
compareVersions,
buildCompatibilityTooltip,
buildVersionSelectItems,
} from '../../utils/components/pluginUtils.js';
//
// Factories & helpers
//
const makeVersion = (version, overrides = {}) => ({
version,
prerelease: false,
url: `https://example.com/plugin-${version}.zip`,
checksum_sha256: `sha256-${version}`,
size: 1024,
build_timestamp: '2024-01-15T10:00:00Z',
min_dispatcharr_version: null,
max_dispatcharr_version: null,
commit_sha: `abc${version}`,
commit_sha_short: `abc`,
...overrides,
});
const makeManifest = (overrides = {}) => ({
description: 'A useful plugin.',
author: 'Test Author',
license: 'MIT',
repo_url: 'https://github.com/example/plugin',
discord_thread: null,
deprecated: false,
registry_url: null,
versions: [makeVersion('2.0.0'), makeVersion('1.0.0')],
latest: { version: '2.0.0' },
...overrides,
});
const makeDetail = (manifestOverrides = {}, overrides = {}) => ({
manifest: makeManifest(manifestOverrides),
signature_verified: true,
...overrides,
});
const defaultProps = (overrides = {}) => ({
detail: makeDetail(),
detailLoading: false,
selectedVersion: '2.0.0',
onVersionChange: vi.fn(),
installedVersion: null,
installedVersionIsPrerelease: false,
appVersion: '1.5.0',
installing: false,
uninstalling: false,
onInstall: vi.fn(),
onUninstall: vi.fn(),
installStatus: 'not_installed',
installedSourceRepoName: null,
repoId: 1,
slug: 'my-plugin',
...overrides,
});
//
describe('PluginDetailPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
// default: every compareVersions call returns 0 (versions equal) unless
// individual tests override it
vi.mocked(compareVersions).mockReturnValue(0);
vi.mocked(buildCompatibilityTooltip).mockReturnValue('1.0.0 or newer');
// default: buildVersionSelectItems returns two standard items so that
// any test rendering the version select has populated options
vi.mocked(buildVersionSelectItems).mockReturnValue([
{ value: '2.0.0', label: 'v2.0.0 (latest)', disabled: false },
{ value: '1.0.0', label: 'v1.0.0', disabled: false },
]);
});
// Loading & error states
describe('loading and error states', () => {
it('shows a loader when detailLoading is true', () => {
render(<PluginDetailPanel {...defaultProps({ detailLoading: true })} />);
expect(screen.getByTestId('loader')).toBeInTheDocument();
expect(screen.getByText(/Loading plugin details/i)).toBeInTheDocument();
});
it('shows error text when detail is null', () => {
render(
<PluginDetailPanel
{...defaultProps({ detail: null, detailLoading: false })}
/>
);
expect(
screen.getByText(/Failed to load plugin details/i)
).toBeInTheDocument();
});
it('shows error text when detail has no manifest', () => {
render(
<PluginDetailPanel
{...defaultProps({ detail: {}, detailLoading: false })}
/>
);
expect(
screen.getByText(/Failed to load plugin details/i)
).toBeInTheDocument();
});
});
// Description
describe('description', () => {
it('renders manifest description', () => {
render(<PluginDetailPanel {...defaultProps()} />);
expect(screen.getByText('A useful plugin.')).toBeInTheDocument();
});
it('does not render description section when absent', () => {
render(
<PluginDetailPanel
{...defaultProps({ detail: makeDetail({ description: null }) })}
/>
);
expect(screen.queryByText('A useful plugin.')).not.toBeInTheDocument();
});
});
// Author & license badges
describe('author and license badges', () => {
it('renders author badge', () => {
render(<PluginDetailPanel {...defaultProps()} />);
expect(screen.getByText('Test Author')).toBeInTheDocument();
});
it('does not render author badge when author is absent', () => {
render(
<PluginDetailPanel
{...defaultProps({ detail: makeDetail({ author: null }) })}
/>
);
expect(screen.queryByText('Test Author')).not.toBeInTheDocument();
});
it('renders license badge as a link to spdx.org', () => {
render(<PluginDetailPanel {...defaultProps()} />);
const badge = screen
.getAllByTestId('badge')
.find((el) => el.tagName === 'A' && el.href?.includes('spdx.org'));
expect(badge).toBeTruthy();
expect(badge.href).toContain('MIT');
});
it('does not render license badge when license is absent', () => {
render(
<PluginDetailPanel
{...defaultProps({ detail: makeDetail({ license: null }) })}
/>
);
const badges = screen.getAllByTestId('badge');
expect(badges.find((b) => b.href?.includes('spdx.org'))).toBeUndefined();
});
});
// Signature badges
describe('signature badge', () => {
it('shows "Verified Signature" badge when signature_verified is true', () => {
render(<PluginDetailPanel {...defaultProps()} />);
expect(screen.getByText('Verified Signature')).toBeInTheDocument();
expect(screen.getByTestId('icon-shield-check')).toBeInTheDocument();
});
it('shows "Unverified" badge when signature_verified is false', () => {
render(
<PluginDetailPanel
{...defaultProps({
detail: makeDetail({}, { signature_verified: false }),
})}
/>
);
expect(screen.getByText('Unverified')).toBeInTheDocument();
expect(screen.getByTestId('icon-shield-alert')).toBeInTheDocument();
});
it('renders no signature badge when signature_verified is null', () => {
render(
<PluginDetailPanel
{...defaultProps({
detail: makeDetail({}, { signature_verified: null }),
})}
/>
);
expect(screen.queryByText('Verified Signature')).not.toBeInTheDocument();
expect(screen.queryByText('Unverified')).not.toBeInTheDocument();
});
});
// GitHub link
describe('GitHub link', () => {
it('renders GitHub icon linking to repo_url', () => {
render(<PluginDetailPanel {...defaultProps()} />);
expect(screen.getByTestId('github-icon')).toBeInTheDocument();
const link = screen
.getAllByTestId('action-icon')
.find((el) => el.href?.includes('github.com'));
expect(link).toBeTruthy();
});
it('does not render GitHub icon when repo_url is absent', () => {
render(
<PluginDetailPanel
{...defaultProps({ detail: makeDetail({ repo_url: null }) })}
/>
);
expect(screen.queryByTestId('github-icon')).not.toBeInTheDocument();
});
});
// Discord link
describe('Discord link', () => {
it('renders Discord icon when discord_thread is set', () => {
render(
<PluginDetailPanel
{...defaultProps({
detail: makeDetail({
discord_thread: 'https://example.com/discord',
}),
})}
/>
);
expect(screen.getByTestId('discord-icon')).toBeInTheDocument();
});
it('does not render Discord icon when discord_thread is null', () => {
render(<PluginDetailPanel {...defaultProps()} />);
expect(screen.queryByTestId('discord-icon')).not.toBeInTheDocument();
});
it('rewrites discord.com/channels URL to discord:// protocol', () => {
render(
<PluginDetailPanel
{...defaultProps({
detail: makeDetail({
discord_thread: 'https://discord.com/channels/123/456',
}),
})}
/>
);
const link = screen
.getAllByTestId('action-icon')
.find((el) => el.href?.startsWith('discord://'));
expect(link).toBeTruthy();
});
it('keeps non-discord.com/channels URL unchanged', () => {
const url = 'https://discord.gg/invite/abc';
render(
<PluginDetailPanel
{...defaultProps({
detail: makeDetail({ discord_thread: url }),
})}
/>
);
const link = screen
.getAllByTestId('action-icon')
.find((el) => el.href === url);
expect(link).toBeTruthy();
});
});
// Deprecated alert
describe('deprecated alert', () => {
it('shows deprecated alert when deprecated is true', () => {
render(
<PluginDetailPanel
{...defaultProps({ detail: makeDetail({ deprecated: true }) })}
/>
);
expect(screen.getByTestId('alert')).toBeInTheDocument();
expect(screen.getByTestId('alert-title')).toHaveTextContent(
'Deprecated Plugin'
);
expect(screen.getByTestId('icon-ban')).toBeInTheDocument();
});
it('does not show deprecated alert when deprecated is false', () => {
render(<PluginDetailPanel {...defaultProps()} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
});
// Version select
describe('version select', () => {
it('renders the version select when versions exist', () => {
render(<PluginDetailPanel {...defaultProps()} />);
expect(screen.getByTestId('version-select')).toBeInTheDocument();
});
it('does not render version select when versions list is empty', () => {
render(
<PluginDetailPanel
{...defaultProps({ detail: makeDetail({ versions: [] }) })}
/>
);
expect(screen.queryByTestId('version-select')).not.toBeInTheDocument();
});
it('renders options from buildVersionSelectItems return value', () => {
render(<PluginDetailPanel {...defaultProps()} />);
// default mock returns v2.0.0 (latest) and v1.0.0
expect(screen.getByText('v2.0.0 (latest)')).toBeInTheDocument();
expect(screen.getByText('v1.0.0')).toBeInTheDocument();
});
it('calls buildVersionSelectItems with manifest versions, latest version, installedVersion, and installedVersionIsPrerelease', () => {
const detail = makeDetail({
versions: [makeVersion('2.0.0'), makeVersion('1.0.0')],
latest: { version: '2.0.0' },
});
render(
<PluginDetailPanel
{...defaultProps({
detail,
installedVersion: '1.0.0',
installedVersionIsPrerelease: false,
})}
/>
);
expect(buildVersionSelectItems).toHaveBeenCalledWith(
detail.manifest.versions,
'2.0.0',
'1.0.0',
false
);
});
it('passes installedVersionIsPrerelease=true to buildVersionSelectItems', () => {
render(
<PluginDetailPanel
{...defaultProps({
installedVersion: '2.0.0-beta',
installedVersionIsPrerelease: true,
})}
/>
);
expect(buildVersionSelectItems).toHaveBeenCalledWith(
expect.any(Array),
expect.anything(),
'2.0.0-beta',
true
);
});
it('passes null installedVersion to buildVersionSelectItems when not installed', () => {
render(
<PluginDetailPanel {...defaultProps({ installedVersion: null })} />
);
expect(buildVersionSelectItems).toHaveBeenCalledWith(
expect.any(Array),
expect.anything(),
null,
false
);
});
it('does not call buildVersionSelectItems when versions list is empty', () => {
render(
<PluginDetailPanel
{...defaultProps({ detail: makeDetail({ versions: [] }) })}
/>
);
expect(buildVersionSelectItems).not.toHaveBeenCalled();
});
it('renders a disabled ghost option when buildVersionSelectItems returns one', () => {
vi.mocked(buildVersionSelectItems).mockReturnValue([
{ value: '2.0.0', label: 'v2.0.0 (latest)', disabled: false },
{ value: '1.5.0', label: 'v1.5.0 (installed)', disabled: true },
{ value: '1.0.0', label: 'v1.0.0', disabled: false },
]);
render(
<PluginDetailPanel
{...defaultProps({
installedVersion: '1.5.0',
installStatus: 'installed',
})}
/>
);
const ghostOption = screen
.getAllByRole('option')
.find((o) => o.value === '1.5.0');
expect(ghostOption).toBeTruthy();
expect(ghostOption.disabled).toBe(true);
});
it('calls onVersionChange when version select changes', () => {
const onVersionChange = vi.fn();
render(<PluginDetailPanel {...defaultProps({ onVersionChange })} />);
fireEvent.change(screen.getByTestId('version-select'), {
target: { value: '1.0.0' },
});
expect(onVersionChange).toHaveBeenCalledWith('1.0.0');
});
});
// Install button
describe('install button', () => {
it('shows "Install" button when plugin is not installed', () => {
render(<PluginDetailPanel {...defaultProps()} />);
expect(screen.getByTestId('button')).toHaveTextContent('Install');
});
it('calls onInstall with correct params when Install is clicked', () => {
const onInstall = vi.fn();
render(<PluginDetailPanel {...defaultProps({ onInstall })} />);
fireEvent.click(screen.getByTestId('button'));
expect(onInstall).toHaveBeenCalledWith(
expect.objectContaining({
slug: 'my-plugin',
version: '2.0.0',
download_url: 'https://example.com/plugin-2.0.0.zip',
})
);
});
it('shows installing spinner while installing is true', () => {
render(<PluginDetailPanel {...defaultProps({ installing: true })} />);
expect(screen.getByTestId('button')).toHaveTextContent('Installing…');
expect(screen.getByTestId('loader')).toBeInTheDocument();
});
it('shows "Update" button when a newer version is selected over installed', () => {
vi.mocked(compareVersions).mockImplementation((a, b) => {
const strip = (v) => v.replace(/^v/, '');
const pa = strip(a).split('.').map(Number);
const pb = strip(b).split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d !== 0) return d;
}
return 0;
});
render(
<PluginDetailPanel
{...defaultProps({
installedVersion: '1.0.0',
selectedVersion: '2.0.0',
installStatus: 'installed',
})}
/>
);
expect(screen.getByTestId('button')).toHaveTextContent('Update');
});
it('shows "Downgrade" button when an older version is selected over installed', () => {
vi.mocked(compareVersions).mockImplementation((a, b) => {
const strip = (v) => v.replace(/^v/, '');
const pa = strip(a).split('.').map(Number);
const pb = strip(b).split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d !== 0) return d;
}
return 0;
});
render(
<PluginDetailPanel
{...defaultProps({
installedVersion: '2.0.0',
selectedVersion: '1.0.0',
installStatus: 'installed',
})}
/>
);
expect(screen.getByTestId('button')).toHaveTextContent('Downgrade');
});
it('shows "Uninstall" button when installed version equals selected version', () => {
vi.mocked(compareVersions).mockReturnValue(0);
render(
<PluginDetailPanel
{...defaultProps({
installedVersion: '2.0.0',
selectedVersion: '2.0.0',
installStatus: 'installed',
})}
/>
);
expect(screen.getByTestId('button')).toHaveTextContent('Uninstall');
});
it('calls onUninstall when Uninstall is clicked', () => {
vi.mocked(compareVersions).mockReturnValue(0);
const onUninstall = vi.fn();
render(
<PluginDetailPanel
{...defaultProps({
installedVersion: '2.0.0',
selectedVersion: '2.0.0',
installStatus: 'installed',
onUninstall,
})}
/>
);
fireEvent.click(screen.getByTestId('button'));
expect(onUninstall).toHaveBeenCalled();
});
it('shows "Uninstalling…" and loader while uninstalling', () => {
vi.mocked(compareVersions).mockReturnValue(0);
render(
<PluginDetailPanel
{...defaultProps({
installedVersion: '2.0.0',
selectedVersion: '2.0.0',
installStatus: 'installed',
uninstalling: true,
})}
/>
);
expect(screen.getByTestId('button')).toHaveTextContent('Uninstalling…');
});
it('shows "Overwrite" for unmanaged install status', () => {
render(
<PluginDetailPanel {...defaultProps({ installStatus: 'unmanaged' })} />
);
expect(screen.getByTestId('button')).toHaveTextContent('Overwrite');
});
it('shows "Overwrite" for different_repo install status', () => {
render(
<PluginDetailPanel
{...defaultProps({
installStatus: 'different_repo',
installedSourceRepoName: 'Other Repo',
})}
/>
);
expect(screen.getByTestId('button')).toHaveTextContent('Overwrite');
});
it('disables install button when no selectedVersionData url', () => {
const detail = makeDetail({
versions: [makeVersion('2.0.0', { url: null })],
latest: { version: '2.0.0' },
});
render(<PluginDetailPanel {...defaultProps({ detail })} />);
expect(screen.getByTestId('button')).toBeDisabled();
});
it('shows "Incompatible" when version does not meet min requirement', () => {
// selMeetsMin=false: appVersion < min_dispatcharr_version
vi.mocked(compareVersions).mockImplementation((a, b) => {
// appVersion (1.5.0) vs min (2.0.0): return negative
if (a === '1.5.0' && b === '2.0.0') return -1;
return 0;
});
const detail = makeDetail({
versions: [makeVersion('2.0.0', { min_dispatcharr_version: '2.0.0' })],
latest: { version: '2.0.0' },
});
render(
<PluginDetailPanel {...defaultProps({ detail, appVersion: '1.5.0' })} />
);
expect(screen.getByTestId('button')).toHaveTextContent('Incompatible');
});
});
// Compatibility warning
describe('compatibility warning', () => {
it('shows compatibility warning tooltip when version is incompatible and not same as installed', () => {
vi.mocked(compareVersions).mockImplementation((a, b) => {
if (a === '1.5.0' && b === '2.0.0') return -1;
return 0;
});
vi.mocked(buildCompatibilityTooltip).mockReturnValue('2.0.0 or newer');
const detail = makeDetail({
versions: [makeVersion('2.0.0', { min_dispatcharr_version: '2.0.0' })],
latest: { version: '2.0.0' },
});
render(
<PluginDetailPanel {...defaultProps({ detail, appVersion: '1.5.0' })} />
);
const tooltip = screen
.getAllByRole('generic')
.find((el) =>
el.getAttribute('data-tooltip')?.includes('Incompatible')
);
expect(tooltip).toBeTruthy();
});
});
// Version detail table
describe('version detail table', () => {
it('renders build timestamp', () => {
render(<PluginDetailPanel {...defaultProps()} />);
// The date is locale-formatted; just check the "Built" label exists
expect(screen.getByText('Built')).toBeInTheDocument();
});
it('renders file size via formatKB', () => {
render(<PluginDetailPanel {...defaultProps()} />);
expect(screen.getByText('File Size')).toBeInTheDocument();
expect(screen.getByText('1024 KB')).toBeInTheDocument();
});
it('does not render file size row when size is 0', () => {
const detail = makeDetail({
versions: [makeVersion('2.0.0', { size: 0 })],
latest: { version: '2.0.0' },
});
render(<PluginDetailPanel {...defaultProps({ detail })} />);
expect(screen.queryByText('File Size')).not.toBeInTheDocument();
});
it('renders min version row when present', () => {
const detail = makeDetail({
versions: [makeVersion('2.0.0', { min_dispatcharr_version: '1.0.0' })],
latest: { version: '2.0.0' },
});
render(<PluginDetailPanel {...defaultProps({ detail })} />);
expect(screen.getByText('Min Version')).toBeInTheDocument();
expect(screen.getByText('1.0.0')).toBeInTheDocument();
});
it('does not render min version row when absent', () => {
render(<PluginDetailPanel {...defaultProps()} />);
expect(screen.queryByText('Min Version')).not.toBeInTheDocument();
});
it('renders max version row when present', () => {
const detail = makeDetail({
versions: [makeVersion('2.0.0', { max_dispatcharr_version: '3.0.0' })],
latest: { version: '2.0.0' },
});
render(<PluginDetailPanel {...defaultProps({ detail })} />);
expect(screen.getByText('Max Version')).toBeInTheDocument();
expect(screen.getByText('3.0.0')).toBeInTheDocument();
});
it('renders commit short SHA', () => {
render(<PluginDetailPanel {...defaultProps()} />);
expect(screen.getByText('Commit')).toBeInTheDocument();
expect(screen.getByText('abc')).toBeInTheDocument();
});
it('renders commit as a link when registry_url is present', () => {
const detail = makeDetail({
registry_url: 'https://github.com/example/plugin',
versions: [makeVersion('2.0.0', { commit_sha: 'abc123full' })],
latest: { version: '2.0.0' },
});
render(<PluginDetailPanel {...defaultProps({ detail })} />);
const commitLink = screen
.getAllByTestId('text-link')
.find((el) => el.href?.includes('abc123full'));
expect(commitLink).toBeTruthy();
});
it('renders download URL as a link', () => {
render(<PluginDetailPanel {...defaultProps()} />);
expect(screen.getByText('Download')).toBeInTheDocument();
const link = screen
.getAllByTestId('text-link')
.find((el) => el.href?.includes('plugin-2.0.0.zip'));
expect(link).toBeTruthy();
});
});
// buildVersionSelectItems integration
describe('buildVersionSelectItems integration', () => {
it('passes manifest.latest.version to buildVersionSelectItems', () => {
const detail = makeDetail({ latest: { version: '2.0.0' } });
render(<PluginDetailPanel {...defaultProps({ detail })} />);
expect(buildVersionSelectItems).toHaveBeenCalledWith(
detail.manifest.versions,
'2.0.0',
null, // installedVersion from defaultProps
false // installedVersionIsPrerelease from defaultProps
);
});
it('passes undefined latest to buildVersionSelectItems when manifest.latest is absent', () => {
const detail = makeDetail({ latest: null });
render(<PluginDetailPanel {...defaultProps({ detail })} />);
expect(buildVersionSelectItems).toHaveBeenCalledWith(
detail.manifest.versions,
undefined,
null, // installedVersion from defaultProps
false // installedVersionIsPrerelease from defaultProps
);
});
it('Select data reflects exactly what buildVersionSelectItems returns', () => {
const customItems = [
{ value: '3.0.0', label: 'v3.0.0 (latest)', disabled: false },
{ value: '2.0.0', label: 'v2.0.0 (installed)', disabled: false },
];
vi.mocked(buildVersionSelectItems).mockReturnValue(customItems);
render(
<PluginDetailPanel {...defaultProps({ selectedVersion: '3.0.0' })} />
);
const options = screen.getAllByRole('option');
expect(options).toHaveLength(2);
expect(options[0].value).toBe('3.0.0');
expect(options[1].value).toBe('2.0.0');
});
});
});

View file

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

View file

@ -12,7 +12,9 @@ vi.mock('../../utils/cards/RecordingCardUtils.js', () => ({
getShowVideoUrl: vi.fn(() => 'http://video.test'),
}));
vi.mock('../../images/logo.png', () => ({ default: 'default-logo.png' }));
vi.mock('../../images/logo.png', () => ({
default: 'default-logo.png',
}));
vi.mock('../../utils/dateTimeUtils.js', () => ({
format: vi.fn(() => '12:00 PM'),

View file

@ -12,9 +12,7 @@ vi.mock('@mantine/core', () => {
),
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Progress: ({ value }) => (
<div data-testid="progress" data-value={value} />
),
Progress: ({ value }) => <div data-testid="progress" data-value={value} />,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
Tooltip: ({ children }) => <>{children}</>,
@ -61,13 +59,7 @@ describe('ProgramPreview', () => {
start_time: new Date(now - 1800000).toISOString(),
end_time: new Date(now + 1800000).toISOString(),
};
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
render(<ProgramPreview loading={false} fetched={true} program={program} />);
expect(screen.getByText('Test Show')).toBeTruthy();
});
@ -86,13 +78,7 @@ describe('ProgramPreview', () => {
it('shows default label "Now Playing:"', () => {
const program = { title: 'Show', start_time: null, end_time: null };
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
render(<ProgramPreview loading={false} fetched={true} program={program} />);
expect(screen.getByText('Now Playing:')).toBeTruthy();
});
@ -107,13 +93,7 @@ describe('ProgramPreview', () => {
start_time: new Date(now - 3600000).toISOString(),
end_time: new Date(now + 3600000).toISOString(),
};
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
render(<ProgramPreview loading={false} fetched={true} program={program} />);
// Description not visible initially
expect(screen.queryByText('A detailed description')).toBeNull();
@ -149,13 +129,7 @@ describe('ProgramPreview', () => {
start_time: new Date(now - 3600000).toISOString(),
end_time: new Date(now + 3600000).toISOString(),
};
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
render(<ProgramPreview loading={false} fetched={true} program={program} />);
// Expand
const expandButton = screen.getByTestId('chevron-right').closest('button');
@ -210,13 +184,7 @@ describe('formatProgramTime', () => {
start_time: new Date(now - 2 * 3600000 - 30 * 60000).toISOString(), // 2h30m ago
end_time: new Date(now + 30 * 60000).toISOString(), // 30m from now
};
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
render(<ProgramPreview loading={false} fetched={true} program={program} />);
// Expand to see time
const expandButton = screen.getByTestId('chevron-right').closest('button');
@ -238,13 +206,7 @@ describe('formatProgramTime', () => {
start_time: new Date(now - 5 * 60000).toISOString(), // 5m ago
end_time: new Date(now + 25 * 60000).toISOString(), // 25m from now
};
render(
<ProgramPreview
loading={false}
fetched={true}
program={program}
/>
);
render(<ProgramPreview loading={false} fetched={true} program={program} />);
const expandButton = screen.getByTestId('chevron-right').closest('button');
fireEvent.click(expandButton);

View file

@ -6,7 +6,16 @@ import RecordingSynopsis from '../RecordingSynopsis';
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Text: ({ children, size, c, lineClamp, onClick, title, style, ...props }) => {
Text: ({
children,
size,
c,
lineClamp,
onClick,
title,
style,
...props
}) => {
return (
<div
data-testid="text"

View file

@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import SystemEvents from '../SystemEvents';
import API from '../../api';
import useLocalStorage from "../../hooks/useLocalStorage";
import useLocalStorage from '../../hooks/useLocalStorage';
// Mock the API module
vi.mock('../../api', () => ({
@ -158,7 +158,7 @@ describe('SystemEvents', () => {
fireEvent.click(refreshButton);
await waitFor(() => {
expect(API.getSystemEvents).toHaveBeenCalledTimes(3)
expect(API.getSystemEvents).toHaveBeenCalledTimes(3);
});
});
@ -203,7 +203,9 @@ describe('SystemEvents', () => {
});
it('should handle API errors gracefully', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
API.getSystemEvents.mockRejectedValue(new Error('API Error'));
render(<SystemEvents />);

View file

@ -14,7 +14,6 @@ import {
Stack,
Switch,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import {
@ -25,16 +24,32 @@ import {
SquarePlus,
UploadCloud,
} from 'lucide-react';
import { notifications } from '@mantine/notifications';
import dayjs from 'dayjs';
import API from '../../api';
import ConfirmationDialog from '../ConfirmationDialog';
import useLocalStorage from '../../hooks/useLocalStorage';
import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from '../tables/CustomTable';
import { validateCronExpression } from '../../utils/cronUtils';
import ScheduleInput from '../forms/ScheduleInput';
import { showNotification } from '../../utils/notificationUtils.js';
import { formatBytes } from '../../utils/networkUtils.js';
import {
format,
getDefaultTimeZone,
useDateTimeFormat,
} from '../../utils/dateTimeUtils.js';
import {
createBackup,
DAYS_OF_WEEK,
deleteBackup,
downloadBackup,
getBackupSchedule,
listBackups,
restoreBackup,
to12Hour,
to24Hour,
updateBackupSchedule,
uploadBackup,
} from '../../utils/components/backups/BackupManagerUtils.js';
const RowActions = ({
row,
@ -81,58 +96,6 @@ const RowActions = ({
);
};
// Convert 24h time string to 12h format with period
function to12Hour(time24) {
if (!time24) return { time: '12:00', period: 'AM' };
const [hours, minutes] = time24.split(':').map(Number);
const period = hours >= 12 ? 'PM' : 'AM';
const hours12 = hours % 12 || 12;
return {
time: `${hours12}:${String(minutes).padStart(2, '0')}`,
period,
};
}
// Convert 12h time + period to 24h format
function to24Hour(time12, period) {
if (!time12) return '00:00';
const [hours, minutes] = time12.split(':').map(Number);
let hours24 = hours;
if (period === 'PM' && hours !== 12) {
hours24 = hours + 12;
} else if (period === 'AM' && hours === 12) {
hours24 = 0;
}
return `${String(hours24).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
}
// Get default timezone (same as Settings page)
function getDefaultTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch {
return 'UTC';
}
}
const DAYS_OF_WEEK = [
{ value: '0', label: 'Sunday' },
{ value: '1', label: 'Monday' },
{ value: '2', label: 'Tuesday' },
{ value: '3', label: 'Wednesday' },
{ value: '4', label: 'Thursday' },
{ value: '5', label: 'Friday' },
{ value: '6', label: 'Saturday' },
];
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
}
export default function BackupManager() {
const [backups, setBackups] = useState([]);
const [loading, setLoading] = useState(false);
@ -147,18 +110,9 @@ export default function BackupManager() {
const [deleting, setDeleting] = useState(false);
// Read user's preferences from settings
const [timeFormat] = useLocalStorage('time-format', '12h');
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
const { fullDateTimeFormat, timeFormatSetting } = useDateTimeFormat();
const [userTimezone] = useLocalStorage('time-zone', getDefaultTimeZone());
const is12Hour = timeFormat === '12h';
// Format date according to user preferences
const formatDate = (dateString) => {
const date = dayjs(dateString);
const datePart = dateFormatSetting === 'mdy' ? 'MM/DD/YYYY' : 'DD/MM/YYYY';
const timePart = is12Hour ? 'h:mm:ss A' : 'HH:mm:ss';
return date.format(`${datePart}, ${timePart}`);
};
const is12Hour = timeFormatSetting === '12h';
// Warning suppression for confirmation dialogs
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
@ -213,7 +167,7 @@ export default function BackupManager() {
minSize: 180,
cell: ({ cell }) => (
<Text size="sm" style={{ whiteSpace: 'nowrap' }}>
{formatDate(cell.getValue())}
{format(cell.getValue(), fullDateTimeFormat)}
</Text>
),
},
@ -267,10 +221,10 @@ export default function BackupManager() {
const loadBackups = async () => {
setLoading(true);
try {
const backupList = await API.listBackups();
const backupList = await listBackups();
setBackups(backupList);
} catch (error) {
notifications.show({
showNotification({
title: 'Error',
message: error?.message || 'Failed to load backups',
color: 'red',
@ -283,7 +237,7 @@ export default function BackupManager() {
const loadSchedule = async () => {
setScheduleLoading(true);
try {
const settings = await API.getBackupSchedule();
const settings = await getBackupSchedule();
setSchedule(settings);
setScheduleType(settings.cron_expression ? 'cron' : 'interval');
@ -294,7 +248,7 @@ export default function BackupManager() {
setTimePeriod(period);
setScheduleChanged(false);
} catch (error) {
} catch {
// Ignore errors on initial load - settings may not exist yet
} finally {
setScheduleLoading(false);
@ -340,17 +294,17 @@ export default function BackupManager() {
? schedule
: { ...schedule, cron_expression: '' };
const updated = await API.updateBackupSchedule(scheduleToSave);
const updated = await updateBackupSchedule(scheduleToSave);
setSchedule(updated);
setScheduleChanged(false);
notifications.show({
showNotification({
title: 'Success',
message: 'Backup schedule saved',
color: 'green',
});
} catch (error) {
notifications.show({
showNotification({
title: 'Error',
message: error?.message || 'Failed to save schedule',
color: 'red',
@ -363,15 +317,15 @@ export default function BackupManager() {
const handleCreateBackup = async () => {
setCreating(true);
try {
await API.createBackup();
notifications.show({
await createBackup();
showNotification({
title: 'Success',
message: 'Backup created successfully',
color: 'green',
});
await loadBackups();
} catch (error) {
notifications.show({
showNotification({
title: 'Error',
message: error?.message || 'Failed to create backup',
color: 'red',
@ -384,14 +338,14 @@ export default function BackupManager() {
const handleDownload = async (filename) => {
setDownloading(filename);
try {
await API.downloadBackup(filename);
notifications.show({
await downloadBackup(filename);
showNotification({
title: 'Download Started',
message: `Downloading ${filename}...`,
color: 'blue',
});
} catch (error) {
notifications.show({
showNotification({
title: 'Error',
message: error?.message || 'Failed to download backup',
color: 'red',
@ -409,15 +363,15 @@ export default function BackupManager() {
const handleDeleteConfirm = async () => {
setDeleting(true);
try {
await API.deleteBackup(selectedBackup.name);
notifications.show({
await deleteBackup(selectedBackup.name);
showNotification({
title: 'Success',
message: 'Backup deleted successfully',
color: 'green',
});
await loadBackups();
} catch (error) {
notifications.show({
showNotification({
title: 'Error',
message: error?.message || 'Failed to delete backup',
color: 'red',
@ -437,8 +391,8 @@ export default function BackupManager() {
const handleRestoreConfirm = async () => {
setRestoring(true);
try {
await API.restoreBackup(selectedBackup.name);
notifications.show({
await restoreBackup(selectedBackup.name);
showNotification({
title: 'Restore Complete',
message:
'Backup restored successfully. A restart is recommended to ensure all services are running against the restored data.',
@ -446,7 +400,7 @@ export default function BackupManager() {
});
setTimeout(() => window.location.reload(), 4000);
} catch (error) {
notifications.show({
showNotification({
title: 'Error',
message: error?.message || 'Failed to restore backup',
color: 'red',
@ -462,8 +416,8 @@ export default function BackupManager() {
if (!uploadFile) return;
try {
await API.uploadBackup(uploadFile);
notifications.show({
await uploadBackup(uploadFile);
showNotification({
title: 'Success',
message: 'Backup uploaded successfully',
color: 'green',
@ -472,7 +426,7 @@ export default function BackupManager() {
setUploadFile(null);
await loadBackups();
} catch (error) {
notifications.show({
showNotification({
title: 'Error',
message: error?.message || 'Failed to upload backup',
color: 'red',

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
import React, { useState } from 'react';
import {
ActionIcon,
Avatar,
Badge,
Box,
@ -33,12 +32,20 @@ import {
PluginSecurityWarning,
PluginSupportDisclaimer,
} from '../PluginWarnings.jsx';
import { useNavigate, useLocation } from 'react-router-dom';
import API from '../../api';
import { useLocation, useNavigate } from 'react-router-dom';
import { usePluginStore } from '../../store/plugins';
import PluginDetailPanel from '../PluginDetailPanel.jsx';
import { compareVersions } from '../pluginUtils.js';
import {
buildCompatibilityTooltip,
compareVersions,
getInstallInfo,
} from '../../utils/components/pluginUtils.js';
import SizedInstallButton from '../theme/SizedInstallButton.jsx';
import {
deletePluginByKey,
getPluginDetailManifest,
setPluginEnabled,
} from '../../utils/pages/PluginsUtils.js';
const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => {
if (isOfficial) {
@ -315,7 +322,7 @@ const AvailablePluginCard = ({
if (enableNow && installedKey) {
setEnabling(true);
try {
await API.setPluginEnabled(installedKey, true);
await setPluginEnabled(installedKey, true);
} finally {
setEnabling(false);
}
@ -329,7 +336,7 @@ const AvailablePluginCard = ({
if (!key) return;
setUninstalling(true);
try {
const resp = await API.deletePlugin(key);
const resp = await deletePluginByKey(key);
if (resp?.success) {
onUninstalled?.(plugin.slug);
usePluginStore.getState().invalidatePlugins();
@ -375,7 +382,7 @@ const AvailablePluginCard = ({
return;
}
setDetailLoading(true);
const result = await API.getPluginDetailManifest(
const result = await getPluginDetailManifest(
plugin.repo_id,
plugin.manifest_url
);
@ -530,17 +537,17 @@ const AvailablePluginCard = ({
<Group justify="space-between" mt="sm" align="center" wrap="nowrap">
{!meetsVersion &&
(() => {
const parts = [];
if (!meetsMinVersion)
parts.push(`${plugin.min_dispatcharr_version} or newer`);
if (!meetsMaxVersion)
parts.push(`${plugin.max_dispatcharr_version} or older`);
const tooltip = buildCompatibilityTooltip(
meetsMinVersion,
plugin,
meetsMaxVersion
);
const label = !meetsMinVersion
? `Min ${plugin.min_dispatcharr_version}`
: `Max ${plugin.max_dispatcharr_version}`;
return (
<Tooltip
label={`Incompatible: requires Dispatcharr ${parts.join(' and ')} (you have v${appVersion})`}
label={`Incompatible: requires Dispatcharr ${tooltip} (you have v${appVersion})`}
>
<Group gap={4} align="center" wrap="nowrap">
<AlertTriangle
@ -771,16 +778,10 @@ const AvailablePluginCard = ({
{/* Unified install confirmation modal */}
{(() => {
const isDowngrade =
pendingInstall &&
plugin.installed_version &&
compareVersions(pendingInstall.version, plugin.installed_version) < 0;
const isUpdate =
pendingInstall &&
plugin.installed_version &&
!isDowngrade &&
compareVersions(pendingInstall.version, plugin.installed_version) > 0;
const isBadSig = plugin.signature_verified === false;
const { isDowngrade, isUpdate, isBadSig } = getInstallInfo(
pendingInstall,
plugin
);
const actionLabel = isDowngrade
? 'Downgrade'
: isUpdate

View file

@ -17,14 +17,24 @@ import {
Text,
Tooltip,
} from '@mantine/core';
import { Ban, Check, Download, FlaskConical, Info, RefreshCw, Settings, Trash2, Zap } from 'lucide-react';
import {
Ban,
Check,
Download,
FlaskConical,
Info,
RefreshCw,
Settings,
Trash2,
Zap,
} from 'lucide-react';
import { getConfirmationDetails } from '../../utils/cards/PluginCardUtils.js';
import { SUBSCRIPTION_EVENTS } from '../../constants.js';
import useSettingsStore from '../../store/settings.jsx';
import { usePluginStore } from '../../store/plugins.jsx';
import API from '../../api';
import PluginDetailPanel from '../PluginDetailPanel.jsx';
import { compareVersions } from '../pluginUtils.js';
import { compareVersions } from '../../utils/components/pluginUtils.js';
import {
PluginDowngradeWarning,
PluginSecurityWarning,
@ -65,7 +75,12 @@ const PluginActionList = ({
Event Triggers
</Text>
{events.map((event) => (
<Badge key={`${action.id}:${event}`} size="xs" variant="light" color="green">
<Badge
key={`${action.id}:${event}`}
size="xs"
variant="light"
color="green"
>
{SUBSCRIPTION_EVENTS[event] || event}
</Badge>
))}
@ -154,19 +169,28 @@ const PluginCard = ({
const fetchDetail = async () => {
if (detailLoading || !isManaged) return;
// Find the available plugin entry for manifest_url
let avail = usePluginStore.getState().availablePlugins.find(
(ap) => ap.slug === plugin.slug && ap.repo_id === plugin.source_repo
);
let avail = usePluginStore
.getState()
.availablePlugins.find(
(ap) => ap.slug === plugin.slug && ap.repo_id === plugin.source_repo
);
if (!avail) {
setDetailLoading(true);
try {
await usePluginStore.getState().fetchAvailablePlugins();
avail = usePluginStore.getState().availablePlugins.find(
(ap) => ap.slug === plugin.slug && ap.repo_id === plugin.source_repo
);
} catch { /* ignore */ }
avail = usePluginStore
.getState()
.availablePlugins.find(
(ap) => ap.slug === plugin.slug && ap.repo_id === plugin.source_repo
);
} catch {
/* ignore */
}
}
if (!avail) {
setDetailLoading(false);
return;
}
if (!avail) { setDetailLoading(false); return; }
if (!avail.manifest_url) {
// Synthesize from top-level entry
setDetail({
@ -177,16 +201,22 @@ const PluginCard = ({
repo_url: avail.repo_url,
discord_thread: avail.discord_thread,
registry_url: avail.registry_url,
versions: avail.latest_version ? [{
version: avail.latest_version,
url: avail.latest_url,
checksum_sha256: avail.latest_sha256,
min_dispatcharr_version: avail.min_dispatcharr_version,
max_dispatcharr_version: avail.max_dispatcharr_version,
build_timestamp: avail.last_updated,
size: avail.latest_size,
}] : [],
latest: avail.latest_version ? { version: avail.latest_version } : null,
versions: avail.latest_version
? [
{
version: avail.latest_version,
url: avail.latest_url,
checksum_sha256: avail.latest_sha256,
min_dispatcharr_version: avail.min_dispatcharr_version,
max_dispatcharr_version: avail.max_dispatcharr_version,
build_timestamp: avail.last_updated,
size: avail.latest_size,
},
]
: [],
latest: avail.latest_version
? { version: avail.latest_version }
: null,
},
signature_verified: avail.signature_verified ?? null,
_avail: avail,
@ -197,7 +227,10 @@ const PluginCard = ({
}
setDetailLoading(true);
try {
const result = await API.getPluginDetailManifest(avail.repo_id, avail.manifest_url);
const result = await API.getPluginDetailManifest(
avail.repo_id,
avail.manifest_url
);
if (result) {
setDetail({ ...result, _avail: avail });
if (result.manifest?.versions?.length) {
@ -326,7 +359,8 @@ const PluginCard = ({
if (!pendingInstallParams) return;
const params = pendingInstallParams;
const selVer = params.version;
const isDown = plugin.version && compareVersions(selVer, plugin.version) < 0;
const isDown =
plugin.version && compareVersions(selVer, plugin.version) < 0;
const action = isDown ? 'downgrade' : 'update';
setInstallConfirmOpen(false);
setPendingInstallParams(null);
@ -367,7 +401,12 @@ const PluginCard = ({
>
{/* Header: avatar, name/author, badges, toggle */}
<Group justify="space-between" mb="xs" align="flex-start" wrap="nowrap">
<Group gap="sm" align="flex-start" wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
<Group
gap="sm"
align="flex-start"
wrap="nowrap"
style={{ minWidth: 0, flex: 1 }}
>
<Avatar
src={plugin.logo_url}
radius="sm"
@ -395,7 +434,11 @@ const PluginCard = ({
c="dimmed"
truncate
onClick={isManaged ? () => openModal('details') : undefined}
style={{ minWidth: 0, maxWidth: '100%', ...(isManaged ? { cursor: 'pointer' } : {}) }}
style={{
minWidth: 0,
maxWidth: '100%',
...(isManaged ? { cursor: 'pointer' } : {}),
}}
>
{plugin.author}
</Text>
@ -415,12 +458,26 @@ const PluginCard = ({
</Group>
<Group gap={6} wrap="nowrap" align="center" style={{ flexShrink: 0 }}>
{plugin.is_managed && plugin.installed_version_is_prerelease ? (
<Tooltip label={plugin.deprecated ? 'Prerelease installed (deprecated), click for details' : 'Prerelease installed, click for details'}>
<Tooltip
label={
plugin.deprecated
? 'Prerelease installed (deprecated), click for details'
: 'Prerelease installed, click for details'
}
>
<Badge
size="xs"
variant="light"
color={plugin.deprecated ? 'red' : 'violet'}
leftSection={detailLoading ? <Loader size={8} /> : plugin.deprecated ? <Ban size={8} /> : <FlaskConical size={8} />}
leftSection={
detailLoading ? (
<Loader size={8} />
) : plugin.deprecated ? (
<Ban size={8} />
) : (
<FlaskConical size={8} />
)
}
style={{ cursor: 'pointer' }}
onClick={() => openModal('details')}
>
@ -428,12 +485,26 @@ const PluginCard = ({
</Badge>
</Tooltip>
) : plugin.update_available ? (
<Tooltip label={plugin.deprecated ? `Update available: v${plugin.latest_version} (deprecated)` : `Update available: v${plugin.latest_version}`}>
<Tooltip
label={
plugin.deprecated
? `Update available: v${plugin.latest_version} (deprecated)`
: `Update available: v${plugin.latest_version}`
}
>
<Badge
size="xs"
variant="light"
color={plugin.deprecated ? 'red' : 'yellow'}
leftSection={detailLoading ? <Loader size={8} /> : plugin.deprecated ? <Ban size={8} /> : <RefreshCw size={8} />}
leftSection={
detailLoading ? (
<Loader size={8} />
) : plugin.deprecated ? (
<Ban size={8} />
) : (
<RefreshCw size={8} />
)
}
style={{ cursor: 'pointer' }}
onClick={() => openModal('details')}
>
@ -441,12 +512,26 @@ const PluginCard = ({
</Badge>
</Tooltip>
) : plugin.is_managed ? (
<Tooltip label={plugin.deprecated ? 'Installed (deprecated), click for details' : 'View plugin details'}>
<Tooltip
label={
plugin.deprecated
? 'Installed (deprecated), click for details'
: 'View plugin details'
}
>
<Badge
size="xs"
variant="light"
color={plugin.deprecated ? 'orange' : 'green'}
leftSection={detailLoading ? <Loader size={8} /> : plugin.deprecated ? <Ban size={8} /> : <Check size={8} />}
leftSection={
detailLoading ? (
<Loader size={8} />
) : plugin.deprecated ? (
<Ban size={8} />
) : (
<Check size={8} />
)
}
style={{ cursor: 'pointer' }}
onClick={() => openModal('details')}
>
@ -489,8 +574,8 @@ const PluginCard = ({
<Stack gap={2} mt="auto" pt={4} style={{ flexShrink: 0 }}>
<Group gap="xs" wrap="wrap">
<Badge size="xs" variant="default">
<span style={{ opacity: 0.5, marginRight: 4 }}>VERSION</span>
v{plugin.version || '1.0.0'}
<span style={{ opacity: 0.5, marginRight: 4 }}>VERSION</span>v
{plugin.version || '1.0.0'}
</Badge>
{plugin.is_managed && plugin.source_repo_name && (
<Badge size="xs" variant="default">
@ -542,7 +627,13 @@ const PluginCard = ({
onClose={() => setModalOpen(false)}
title={
<Group gap="xs" align="center">
<Avatar src={plugin.logo_url} radius="sm" size={28} alt={`${plugin.name} logo`} imageProps={{ draggable: false }}>
<Avatar
src={plugin.logo_url}
radius="sm"
size={28}
alt={`${plugin.name} logo`}
imageProps={{ draggable: false }}
>
{plugin.name?.[0]?.toUpperCase()}
</Avatar>
<Text fw={600}>{plugin.name}</Text>
@ -550,11 +641,29 @@ const PluginCard = ({
}
size="lg"
>
<Tabs value={modalTab} onChange={(tab) => { setModalTab(tab); if (tab === 'details') fetchDetail(); }}>
<Tabs
value={modalTab}
onChange={(tab) => {
setModalTab(tab);
if (tab === 'details') fetchDetail();
}}
>
<Tabs.List>
{isManaged && <Tabs.Tab value="details" leftSection={<Info size={14} />}>Details</Tabs.Tab>}
{hasFields && <Tabs.Tab value="settings" leftSection={<Settings size={14} />}>Settings</Tabs.Tab>}
{hasActions && <Tabs.Tab value="actions" leftSection={<Zap size={14} />}>Actions</Tabs.Tab>}
{isManaged && (
<Tabs.Tab value="details" leftSection={<Info size={14} />}>
Details
</Tabs.Tab>
)}
{hasFields && (
<Tabs.Tab value="settings" leftSection={<Settings size={14} />}>
Settings
</Tabs.Tab>
)}
{hasActions && (
<Tabs.Tab value="actions" leftSection={<Zap size={14} />}>
Actions
</Tabs.Tab>
)}
</Tabs.List>
{isManaged && (
@ -565,7 +674,9 @@ const PluginCard = ({
selectedVersion={selectedVersion}
onVersionChange={setSelectedVersion}
installedVersion={plugin.version}
installedVersionIsPrerelease={!!plugin.installed_version_is_prerelease}
installedVersionIsPrerelease={
!!plugin.installed_version_is_prerelease
}
appVersion={appVersion}
installing={installing}
uninstalling={uninstalling}
@ -631,7 +742,10 @@ const PluginCard = ({
{/* Install confirmation modal */}
{(() => {
const selVer = pendingInstallParams?.version;
const isDown = plugin.version && selVer && compareVersions(selVer, plugin.version) < 0;
const isDown =
plugin.version &&
selVer &&
compareVersions(selVer, plugin.version) < 0;
const actionLabel = isDown ? 'Downgrade' : 'Update';
return (
<Modal
@ -643,9 +757,11 @@ const PluginCard = ({
zIndex={300}
title={
<Group gap="xs" align="center">
{isDown
? <Download size={18} color="var(--mantine-color-orange-6)" />
: <Download size={18} />}
{isDown ? (
<Download size={18} color="var(--mantine-color-orange-6)" />
) : (
<Download size={18} />
)}
<Text fw={600}>Confirm {actionLabel}</Text>
</Group>
}
@ -653,14 +769,15 @@ const PluginCard = ({
>
<Stack gap="md">
<Text size="sm">
You are about to {actionLabel.toLowerCase()} <b>{plugin.name}</b>{' '}
from <b>v{plugin.version}</b> to <b>v{selVer}</b>.
You are about to {actionLabel.toLowerCase()}{' '}
<b>{plugin.name}</b> from <b>v{plugin.version}</b> to{' '}
<b>v{selVer}</b>.
</Text>
<PluginSecurityWarning>
Plugins run server-side code with full access to your Dispatcharr
instance and its data. Only install plugins from developers you
trust. Malicious plugins could read or modify data, call internal
APIs, or perform unwanted actions.
Plugins run server-side code with full access to your
Dispatcharr instance and its data. Only install plugins from
developers you trust. Malicious plugins could read or modify
data, call internal APIs, or perform unwanted actions.
</PluginSecurityWarning>
<PluginSupportDisclaimer />
{isDown && (
@ -668,7 +785,9 @@ const PluginCard = ({
Downgrading may cause issues with saved settings or data.
</PluginDowngradeWarning>
)}
<Text size="sm" fw={500}>Are you sure you want to proceed?</Text>
<Text size="sm" fw={500}>
Are you sure you want to proceed?
</Text>
<Group justify="flex-end" gap="xs">
<Button
size="xs"

View file

@ -54,7 +54,6 @@ import {
import useVideoStore from '../../store/useVideoStore';
import { buildLiveStreamUrl } from '../../utils/components/FloatingVideoUtils.js';
// Create a separate component for each channel card to properly handle the hook
const StreamConnectionCard = ({
channel,
@ -74,7 +73,6 @@ const StreamConnectionCard = ({
const [data, setData] = useState([]);
const [previewedStream, setPreviewedStream] = useState(null);
const theme = useMantineTheme();
// Get M3U account data from the playlists store

File diff suppressed because it is too large Load diff

View file

@ -178,7 +178,9 @@ describe('PluginCard', () => {
render(<PluginCard {...defaultProps} plugin={missingPlugin} />);
expect(
screen.getByText('Missing plugin files. Re-import or delete this entry.')
screen.getByText(
'Missing plugin files. Re-import or delete this entry.'
)
).toBeInTheDocument();
});
@ -187,7 +189,9 @@ describe('PluginCard', () => {
render(<PluginCard {...defaultProps} plugin={legacyPlugin} />);
expect(
screen.getByText('Please update or ask the developer to add plugin.json.')
screen.getByText(
'Please update or ask the developer to add plugin.json.'
)
).toBeInTheDocument();
});
});
@ -226,12 +230,19 @@ describe('PluginCard', () => {
fireEvent.click(switchElement);
await waitFor(() => {
expect(defaultProps.onToggleEnabled).toHaveBeenCalledWith('test-plugin', false);
expect(defaultProps.onToggleEnabled).toHaveBeenCalledWith(
'test-plugin',
false
);
});
});
it('should require trust for first-time enable', async () => {
const firstTimePlugin = { ...mockPlugin, enabled: false, ever_enabled: false };
const firstTimePlugin = {
...mockPlugin,
enabled: false,
ever_enabled: false,
};
defaultProps.onRequireTrust.mockResolvedValue(true);
defaultProps.onToggleEnabled.mockResolvedValue({ success: true });
@ -241,13 +252,22 @@ describe('PluginCard', () => {
fireEvent.click(switchElement);
await waitFor(() => {
expect(defaultProps.onRequireTrust).toHaveBeenCalledWith(firstTimePlugin);
expect(defaultProps.onToggleEnabled).toHaveBeenCalledWith('test-plugin', true);
expect(defaultProps.onRequireTrust).toHaveBeenCalledWith(
firstTimePlugin
);
expect(defaultProps.onToggleEnabled).toHaveBeenCalledWith(
'test-plugin',
true
);
});
});
it('should not enable if trust is denied', async () => {
const firstTimePlugin = { ...mockPlugin, enabled: false, ever_enabled: false };
const firstTimePlugin = {
...mockPlugin,
enabled: false,
ever_enabled: false,
};
defaultProps.onRequireTrust.mockResolvedValue(false);
render(<PluginCard {...defaultProps} plugin={firstTimePlugin} />);
@ -354,7 +374,7 @@ describe('PluginCard', () => {
defaultProps.onSaveSettings.mockResolvedValue(true);
defaultProps.onRunAction.mockResolvedValue({
success: true,
result: { message: 'Action completed' }
result: { message: 'Action completed' },
});
render(<PluginCard {...defaultProps} />);
@ -364,7 +384,10 @@ describe('PluginCard', () => {
fireEvent.click(actionButton);
await waitFor(() => {
expect(defaultProps.onRunAction).toHaveBeenCalledWith('test-plugin', 'action1');
expect(defaultProps.onRunAction).toHaveBeenCalledWith(
'test-plugin',
'action1'
);
expect(notificationUtils.showNotification).toHaveBeenCalledWith({
title: 'Test Plugin',
message: 'Action completed',
@ -443,7 +466,6 @@ describe('PluginCard', () => {
});
});
it('should render event triggers badges', () => {
const pluginWithEvents = {
...mockPlugin,
@ -500,4 +522,4 @@ describe('PluginCard', () => {
expect(screen.getByText('Settings')).toBeInTheDocument();
});
});
});
});

View file

@ -51,12 +51,11 @@ const RedisStatus = ({ tls }) => {
: 'The connection is encrypted but the server identity is not verified';
}
let mtlsColor = 'gray';
let mtlsTooltip = 'Mutual authentication is not active';
if (enabled && mtls) {
mtlsColor = 'green';
mtlsTooltip = 'Both client and server verify each other with certificates';
}
const mtlsColor = enabled && mtls ? 'green' : 'gray';
const mtlsTooltip =
enabled && mtls
? 'Both client and server verify each other with certificates'
: 'Mutual authentication is not active';
return (
<TlsServiceCard serviceName="Redis">
@ -94,11 +93,10 @@ const PostgresStatus = ({ tls }) => {
const sslMode = tls?.ssl_mode;
const mtls = tls?.mtls ?? false;
const modeBadge = enabled && sslMode ? sslMode : 'Off';
let modeColor = 'gray';
let modeTooltip = 'Verification mode is not active — TLS is disabled';
let modeBadge = 'Off';
if (enabled && sslMode) {
modeBadge = sslMode;
if (sslMode === 'verify-full') {
modeColor = 'green';
modeTooltip = 'Server certificate and hostname are both verified';
@ -112,12 +110,11 @@ const PostgresStatus = ({ tls }) => {
}
}
let mtlsColor = 'gray';
let mtlsTooltip = 'Mutual authentication is not active';
if (enabled && mtls) {
mtlsColor = 'green';
mtlsTooltip = 'Both client and server verify each other with certificates';
}
const mtlsColor = enabled && mtls ? 'green' : 'gray';
const mtlsTooltip =
enabled && mtls
? 'Both client and server verify each other with certificates'
: 'Mutual authentication is not active';
return (
<TlsServiceCard serviceName="PostgreSQL">

View file

@ -1,14 +1,7 @@
import useSettingsStore from '../../../store/settings.jsx';
import React, { useEffect, useState } from 'react';
import { useForm } from '@mantine/form';
import {
Alert,
Button,
Flex,
NumberInput,
Stack,
Text,
} from '@mantine/core';
import { Alert, Button, Flex, NumberInput, Stack, Text } from '@mantine/core';
import { EPG_SETTINGS_OPTIONS } from '../../../constants.js';
import {
getChangedSettings,
@ -35,7 +28,7 @@ const EpgSettingsForm = React.memo(({ active }) => {
const parsed = parseSettings(settings);
form.setFieldValue(
'xmltv_prev_days_override',
parsed.xmltv_prev_days_override ?? 0,
parsed.xmltv_prev_days_override ?? 0
);
}
}, [settings]);

View file

@ -2,15 +2,7 @@ import useSettingsStore from '../../../store/settings.jsx';
import React, { useEffect, useState } from 'react';
import { useForm } from '@mantine/form';
import { updateSetting } from '../../../utils/pages/SettingsUtils.js';
import {
Alert,
Button,
Flex,
NumberInput,
Stack,
TextInput,
Checkbox,
} from '@mantine/core';
import { Alert, Button, Flex, Stack, Checkbox } from '@mantine/core';
import { USER_LIMITS_OPTIONS } from '../../../constants.js';
const USER_LIMIT_DEFAULTS = Object.keys(USER_LIMITS_OPTIONS).reduce(
@ -77,20 +69,14 @@ const UserLimitsForm = React.memo(({ active }) => {
></Alert>
)}
{Object.keys(USER_LIMITS_OPTIONS).reduce((acc, key) => {
const option = USER_LIMITS_OPTIONS[key];
acc.push(
<Checkbox
key={key}
label={option.label}
description={option.description}
{...userLimitSettingsForm.getInputProps(key, {
type: 'checkbox',
})}
/>
);
return acc;
}, [])}
{Object.entries(USER_LIMITS_OPTIONS).map(([key, option]) => (
<Checkbox
key={key}
label={option.label}
description={option.description}
{...userLimitSettingsForm.getInputProps(key, { type: 'checkbox' })}
/>
))}
<Flex mih={50} gap="xs" justify="space-between" align="flex-end">
<Button

View file

@ -0,0 +1,592 @@
import { render, screen, within } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ConnectionSecurityPanel from '../ConnectionSecurityPanel';
// Store mock
vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() }));
// Mantine core
vi.mock('@mantine/core', () => ({
Badge: ({ children, color, variant, size }) => (
<span
data-testid="badge"
data-color={color}
data-variant={variant}
data-size={size}
>
{children}
</span>
),
Group: ({ children, gap, align }) => (
<div data-gap={gap} data-align={align}>
{children}
</div>
),
Paper: ({ children, p, withBorder }) => (
<div data-testid="paper" data-p={p} data-with-border={String(withBorder)}>
{children}
</div>
),
SimpleGrid: ({ children, spacing }) => (
<div data-testid="simple-grid" data-spacing={spacing}>
{children}
</div>
),
Stack: ({ children, gap }) => <div data-gap={gap}>{children}</div>,
Text: ({ children, size, c, fw }) => (
<span data-size={size} data-color={c} data-fw={fw}>
{children}
</span>
),
Tooltip: ({ children, label }) => (
<div data-testid="tooltip" data-tooltip={label}>
{children}
</div>
),
}));
import useSettingsStore from '../../../../store/settings.jsx';
// Helpers
const setupStore = (environment = {}) => {
vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ environment }));
};
/** Find the service card (Paper) whose subtree contains a Text matching `name` */
const getServiceCard = (name) => {
const papers = screen.getAllByTestId('paper');
return papers.find((p) => within(p).queryByText(name));
};
/** Get all badges within a named service card */
const getBadgesIn = (serviceName) =>
within(getServiceCard(serviceName)).getAllByTestId('badge');
/** Get all tooltips within a named service card */
const getTooltipsIn = (serviceName) =>
within(getServiceCard(serviceName)).getAllByTestId('tooltip');
/** Find a badge by its exact text content within a service card */
const getBadgeByText = (serviceName, text) =>
getBadgesIn(serviceName).find((b) => b.textContent === text);
//
describe('ConnectionSecurityPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Top-level rendering
describe('top-level rendering', () => {
it('renders the introductory description text', () => {
setupStore();
render(<ConnectionSecurityPanel />);
expect(
screen.getByText(
/Encrypt connections to Redis and PostgreSQL using environment variables/i
)
).toBeInTheDocument();
});
it('renders both Redis and PostgreSQL service cards', () => {
setupStore();
render(<ConnectionSecurityPanel />);
expect(getServiceCard('Redis')).toBeTruthy();
expect(getServiceCard('PostgreSQL')).toBeTruthy();
});
});
// RedisStatus TLS disabled
describe('RedisStatus — TLS disabled (default)', () => {
beforeEach(() => {
setupStore({ redis_tls: { enabled: false } });
render(<ConnectionSecurityPanel />);
});
it('renders all three option labels', () => {
const card = getServiceCard('Redis');
expect(within(card).getByText('Encryption')).toBeInTheDocument();
expect(within(card).getByText('Server Verification')).toBeInTheDocument();
expect(within(card).getByText('Mutual TLS')).toBeInTheDocument();
});
it('shows Encryption badge as "Disabled" with gray color', () => {
const badge = getBadgeByText('Redis', 'Disabled');
expect(badge).toBeTruthy();
expect(badge.dataset.color).toBe('gray');
});
it('shows Server Verification badge as "Off" with gray color', () => {
const card = getServiceCard('Redis');
const offBadge = within(card)
.getAllByTestId('badge')
.find((b) => b.textContent === 'Off');
expect(offBadge).toBeTruthy();
expect(offBadge.dataset.color).toBe('gray');
});
it('shows Mutual TLS badge as "Inactive" with gray color', () => {
const badge = getBadgeByText('Redis', 'Inactive');
expect(badge).toBeTruthy();
expect(badge.dataset.color).toBe('gray');
});
it('shows "not encrypted" tooltip for Encryption', () => {
const tooltips = getTooltipsIn('Redis');
expect(
tooltips.find(
(t) => t.dataset.tooltip === 'The connection is not encrypted'
)
).toBeTruthy();
});
it('shows "Verification is not active" tooltip for Server Verification', () => {
const tooltips = getTooltipsIn('Redis');
expect(
tooltips.find((t) =>
t.dataset.tooltip?.includes('Verification is not active')
)
).toBeTruthy();
});
it('shows "Mutual authentication is not active" tooltip for mTLS', () => {
const tooltips = getTooltipsIn('Redis');
expect(
tooltips.find((t) =>
t.dataset.tooltip?.includes('Mutual authentication is not active')
)
).toBeTruthy();
});
});
// RedisStatus TLS enabled, verify=true, mtls=false
describe('RedisStatus — TLS enabled, verify=true, mtls=false', () => {
beforeEach(() => {
setupStore({ redis_tls: { enabled: true, verify: true, mtls: false } });
render(<ConnectionSecurityPanel />);
});
it('shows Encryption badge as "Enabled" with green color', () => {
const badge = getBadgeByText('Redis', 'Enabled');
expect(badge).toBeTruthy();
expect(badge.dataset.color).toBe('green');
});
it('shows Server Verification badge as "On" with green color', () => {
const card = getServiceCard('Redis');
const onBadge = within(card)
.getAllByTestId('badge')
.find((b) => b.textContent === 'On');
expect(onBadge).toBeTruthy();
expect(onBadge.dataset.color).toBe('green');
});
it('shows Mutual TLS badge as "Inactive" with gray color', () => {
const badge = getBadgeByText('Redis', 'Inactive');
expect(badge).toBeTruthy();
expect(badge.dataset.color).toBe('gray');
});
it('shows "connection is encrypted" tooltip for Encryption', () => {
const tooltips = getTooltipsIn('Redis');
expect(
tooltips.find(
(t) => t.dataset.tooltip === 'The connection is encrypted'
)
).toBeTruthy();
});
it('shows "server identity is verified" tooltip for Server Verification', () => {
const tooltips = getTooltipsIn('Redis');
expect(
tooltips.find((t) =>
t.dataset.tooltip?.includes("server's identity is verified")
)
).toBeTruthy();
});
});
// RedisStatus TLS enabled, verify=false
describe('RedisStatus — TLS enabled, verify=false', () => {
beforeEach(() => {
setupStore({ redis_tls: { enabled: true, verify: false, mtls: false } });
render(<ConnectionSecurityPanel />);
});
it('shows Server Verification badge as "Off" with yellow color', () => {
const card = getServiceCard('Redis');
const offBadge = within(card)
.getAllByTestId('badge')
.find((b) => b.textContent === 'Off');
expect(offBadge).toBeTruthy();
expect(offBadge.dataset.color).toBe('yellow');
});
it('shows "encrypted but server identity is not verified" tooltip', () => {
const tooltips = getTooltipsIn('Redis');
expect(
tooltips.find((t) =>
t.dataset.tooltip?.includes('server identity is not verified')
)
).toBeTruthy();
});
});
// RedisStatus TLS enabled, mtls=true
describe('RedisStatus — TLS enabled, mtls=true', () => {
beforeEach(() => {
setupStore({ redis_tls: { enabled: true, verify: true, mtls: true } });
render(<ConnectionSecurityPanel />);
});
it('shows Mutual TLS badge as "Active" with green color', () => {
const badge = getBadgeByText('Redis', 'Active');
expect(badge).toBeTruthy();
expect(badge.dataset.color).toBe('green');
});
it('shows "Both client and server verify each other" tooltip for mTLS', () => {
const tooltips = getTooltipsIn('Redis');
expect(
tooltips.find((t) =>
t.dataset.tooltip?.includes(
'Both client and server verify each other'
)
)
).toBeTruthy();
});
});
// RedisStatus redis_tls undefined
describe('RedisStatus — redis_tls undefined', () => {
it('falls back to defaults (Disabled/Off/Inactive, all gray)', () => {
setupStore({});
render(<ConnectionSecurityPanel />);
expect(getBadgeByText('Redis', 'Disabled').dataset.color).toBe('gray');
expect(
within(getServiceCard('Redis'))
.getAllByTestId('badge')
.find((b) => b.textContent === 'Off').dataset.color
).toBe('gray');
expect(getBadgeByText('Redis', 'Inactive').dataset.color).toBe('gray');
});
});
// PostgresStatus TLS disabled
describe('PostgresStatus — TLS disabled (default)', () => {
beforeEach(() => {
setupStore({ postgres_tls: { enabled: false } });
render(<ConnectionSecurityPanel />);
});
it('renders all three option labels', () => {
const card = getServiceCard('PostgreSQL');
expect(within(card).getByText('Encryption')).toBeInTheDocument();
expect(within(card).getByText('Verification Mode')).toBeInTheDocument();
expect(within(card).getByText('Mutual TLS')).toBeInTheDocument();
});
it('shows Encryption badge as "Disabled" with gray color', () => {
const badge = getBadgeByText('PostgreSQL', 'Disabled');
expect(badge).toBeTruthy();
expect(badge.dataset.color).toBe('gray');
});
it('shows Verification Mode badge as "Off" with gray color', () => {
const card = getServiceCard('PostgreSQL');
const offBadge = within(card)
.getAllByTestId('badge')
.find((b) => b.textContent === 'Off');
expect(offBadge).toBeTruthy();
expect(offBadge.dataset.color).toBe('gray');
});
it('shows Mutual TLS badge as "Inactive" with gray color', () => {
const badge = getBadgeByText('PostgreSQL', 'Inactive');
expect(badge).toBeTruthy();
expect(badge.dataset.color).toBe('gray');
});
it('shows "Verification mode is not active" tooltip when TLS is disabled', () => {
const tooltips = getTooltipsIn('PostgreSQL');
expect(
tooltips.find((t) =>
t.dataset.tooltip?.includes('Verification mode is not active')
)
).toBeTruthy();
});
});
// PostgresStatus ssl_mode=verify-full
describe('PostgresStatus — ssl_mode=verify-full', () => {
beforeEach(() => {
setupStore({
postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: false },
});
render(<ConnectionSecurityPanel />);
});
it('shows Verification Mode badge as "verify-full" with green color', () => {
const badge = getBadgeByText('PostgreSQL', 'verify-full');
expect(badge).toBeTruthy();
expect(badge.dataset.color).toBe('green');
});
it('shows "Server certificate and hostname are both verified" tooltip', () => {
const tooltips = getTooltipsIn('PostgreSQL');
expect(
tooltips.find((t) =>
t.dataset.tooltip?.includes(
'Server certificate and hostname are both verified'
)
)
).toBeTruthy();
});
});
// PostgresStatus ssl_mode=verify-ca
describe('PostgresStatus — ssl_mode=verify-ca', () => {
beforeEach(() => {
setupStore({
postgres_tls: { enabled: true, ssl_mode: 'verify-ca', mtls: false },
});
render(<ConnectionSecurityPanel />);
});
it('shows Verification Mode badge as "verify-ca" with yellow color', () => {
const badge = getBadgeByText('PostgreSQL', 'verify-ca');
expect(badge).toBeTruthy();
expect(badge.dataset.color).toBe('yellow');
});
it('shows "hostname is not checked" tooltip', () => {
const tooltips = getTooltipsIn('PostgreSQL');
expect(
tooltips.find((t) =>
t.dataset.tooltip?.includes('hostname is not checked')
)
).toBeTruthy();
});
});
// PostgresStatus ssl_mode=require (other/fallback)
describe('PostgresStatus — ssl_mode=require (other)', () => {
beforeEach(() => {
setupStore({
postgres_tls: { enabled: true, ssl_mode: 'require', mtls: false },
});
render(<ConnectionSecurityPanel />);
});
it('shows Verification Mode badge as "require" with yellow color', () => {
const badge = getBadgeByText('PostgreSQL', 'require');
expect(badge).toBeTruthy();
expect(badge.dataset.color).toBe('yellow');
});
it('shows "encrypted but the server is not verified" tooltip', () => {
const tooltips = getTooltipsIn('PostgreSQL');
expect(
tooltips.find((t) =>
t.dataset.tooltip?.includes('server is not verified')
)
).toBeTruthy();
});
});
// PostgresStatus TLS enabled, ssl_mode absent
describe('PostgresStatus — TLS enabled, ssl_mode absent', () => {
it('shows Verification Mode badge as "Off" with gray color', () => {
setupStore({ postgres_tls: { enabled: true, mtls: false } });
render(<ConnectionSecurityPanel />);
const card = getServiceCard('PostgreSQL');
const offBadge = within(card)
.getAllByTestId('badge')
.find((b) => b.textContent === 'Off');
expect(offBadge).toBeTruthy();
expect(offBadge.dataset.color).toBe('gray');
});
});
// PostgresStatus mtls=true
describe('PostgresStatus — mtls=true', () => {
beforeEach(() => {
setupStore({
postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: true },
});
render(<ConnectionSecurityPanel />);
});
it('shows Mutual TLS badge as "Active" with green color', () => {
const badge = getBadgeByText('PostgreSQL', 'Active');
expect(badge).toBeTruthy();
expect(badge.dataset.color).toBe('green');
});
it('shows "Both client and server verify each other" tooltip for mTLS', () => {
const tooltips = getTooltipsIn('PostgreSQL');
expect(
tooltips.find((t) =>
t.dataset.tooltip?.includes(
'Both client and server verify each other'
)
)
).toBeTruthy();
});
});
// PostgresStatus mtls=false while TLS enabled
describe('PostgresStatus — mtls=false while TLS enabled', () => {
it('shows Mutual TLS badge as "Inactive" with gray color', () => {
setupStore({
postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: false },
});
render(<ConnectionSecurityPanel />);
const badge = getBadgeByText('PostgreSQL', 'Inactive');
expect(badge).toBeTruthy();
expect(badge.dataset.color).toBe('gray');
});
it('shows "Mutual authentication is not active" tooltip', () => {
setupStore({
postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: false },
});
render(<ConnectionSecurityPanel />);
const tooltips = getTooltipsIn('PostgreSQL');
expect(
tooltips.find((t) =>
t.dataset.tooltip?.includes('Mutual authentication is not active')
)
).toBeTruthy();
});
});
// PostgresStatus postgres_tls undefined
describe('PostgresStatus — postgres_tls undefined', () => {
it('falls back to all defaults (Disabled/Off/Inactive, all gray)', () => {
setupStore({});
render(<ConnectionSecurityPanel />);
expect(getBadgeByText('PostgreSQL', 'Disabled').dataset.color).toBe(
'gray'
);
expect(
within(getServiceCard('PostgreSQL'))
.getAllByTestId('badge')
.find((b) => b.textContent === 'Off').dataset.color
).toBe('gray');
expect(getBadgeByText('PostgreSQL', 'Inactive').dataset.color).toBe(
'gray'
);
});
});
// Both services independent state
describe('both services — independent badge state', () => {
it('Redis and PostgreSQL badges reflect their own configs independently', () => {
setupStore({
redis_tls: { enabled: true, verify: false, mtls: true },
postgres_tls: { enabled: false },
});
render(<ConnectionSecurityPanel />);
// Redis: enabled green Enabled, verify=false yellow Off, mtls=true green Active
expect(getBadgeByText('Redis', 'Enabled').dataset.color).toBe('green');
expect(
within(getServiceCard('Redis'))
.getAllByTestId('badge')
.find((b) => b.textContent === 'Off').dataset.color
).toBe('yellow');
expect(getBadgeByText('Redis', 'Active').dataset.color).toBe('green');
// Postgres: disabled all gray/Off/Inactive
expect(getBadgeByText('PostgreSQL', 'Disabled').dataset.color).toBe(
'gray'
);
expect(
within(getServiceCard('PostgreSQL'))
.getAllByTestId('badge')
.find((b) => b.textContent === 'Off').dataset.color
).toBe('gray');
expect(getBadgeByText('PostgreSQL', 'Inactive').dataset.color).toBe(
'gray'
);
});
});
// TlsOption description text
describe('TlsOption description text', () => {
beforeEach(() => {
setupStore({
redis_tls: { enabled: true, verify: true, mtls: true },
postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: true },
});
render(<ConnectionSecurityPanel />);
});
it('renders Redis Encryption description', () => {
expect(
within(getServiceCard('Redis')).getByText(
'Encrypt traffic between Dispatcharr and Redis.'
)
).toBeInTheDocument();
});
it('renders Redis Server Verification description', () => {
expect(
within(getServiceCard('Redis')).getByText(
"Verify the Redis server's identity using a CA certificate."
)
).toBeInTheDocument();
});
it('renders Redis Mutual TLS description', () => {
expect(
within(getServiceCard('Redis')).getByText(
'Authenticate Dispatcharr to Redis using a client certificate.'
)
).toBeInTheDocument();
});
it('renders PostgreSQL Encryption description', () => {
expect(
within(getServiceCard('PostgreSQL')).getByText(
'Encrypt traffic between Dispatcharr and PostgreSQL.'
)
).toBeInTheDocument();
});
it('renders PostgreSQL Verification Mode description', () => {
expect(
within(getServiceCard('PostgreSQL')).getByText(
"How strictly to verify the PostgreSQL server's identity."
)
).toBeInTheDocument();
});
it('renders PostgreSQL Mutual TLS description', () => {
expect(
within(getServiceCard('PostgreSQL')).getByText(
'Authenticate Dispatcharr to PostgreSQL using a client certificate.'
)
).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,417 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import EpgSettingsForm from '../EpgSettingsForm';
// Store mock
vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() }));
// @mantine/form mock
vi.mock('@mantine/form', () => ({ useForm: vi.fn() }));
// @mantine/core mock
vi.mock('@mantine/core', () => ({
Alert: ({ title, color, variant }) => (
<div data-testid="alert" data-color={color} data-variant={variant}>
{title}
</div>
),
Button: ({ children, type, disabled }) => (
<button type={type} disabled={disabled}>
{children}
</button>
),
Flex: ({ children, justify }) => <div data-justify={justify}>{children}</div>,
NumberInput: ({ label, description, min, max, value, onChange }) => (
<div>
{label && <label data-testid="number-input-label">{label}</label>}
{description && (
<span data-testid="number-input-description">{description}</span>
)}
<input
data-testid="number-input"
type="number"
min={min}
max={max}
value={value ?? 0}
onChange={(e) => onChange?.(Number(e.target.value))}
/>
</div>
),
Stack: ({ children, gap }) => <div data-gap={gap}>{children}</div>,
Text: ({ children, size, c }) => (
<span data-testid="text" data-size={size} data-color={c}>
{children}
</span>
),
}));
// SettingsUtils mock
vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({
getChangedSettings: vi.fn(),
parseSettings: vi.fn(),
saveChangedSettings: vi.fn(),
}));
// EpgSettingsFormUtils mock
vi.mock('../../../../utils/forms/settings/EpgSettingsFormUtils.js', () => ({
getEpgSettingsFormInitialValues: vi.fn(() => ({
xmltv_prev_days_override: 0,
})),
}));
//
// Imports after mocks
//
import useSettingsStore from '../../../../store/settings.jsx';
import { useForm } from '@mantine/form';
import * as SettingsUtils from '../../../../utils/pages/SettingsUtils.js';
import { getEpgSettingsFormInitialValues } from '../../../../utils/forms/settings/EpgSettingsFormUtils.js';
import { EPG_SETTINGS_OPTIONS } from '../../../../constants.js';
// Form mock factory
let mockForm;
const createMockForm = (initialValues = { xmltv_prev_days_override: 0 }) => {
const state = { ...initialValues };
return {
values: state,
setFieldValue: vi.fn((field, value) => {
state[field] = value;
}),
getInputProps: vi.fn((field) => ({
value: state[field],
onChange: vi.fn((val) => {
state[field] = val;
}),
})),
getValues: vi.fn(() => ({ ...state })),
onSubmit: vi.fn((handler) => (e) => {
e?.preventDefault?.();
return handler();
}),
submitting: false,
};
};
// Settings factories
const makeSettings = (epgValue = {}) => ({
epg_settings: { id: 1, value: epgValue },
});
//
describe('EpgSettingsForm', () => {
beforeEach(() => {
vi.clearAllMocks();
mockForm = createMockForm();
vi.mocked(useForm).mockReturnValue(mockForm);
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: null })
);
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({
xmltv_prev_days_override: 0,
});
vi.mocked(SettingsUtils.getChangedSettings).mockReturnValue({});
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
});
// Rendering
describe('rendering', () => {
it('renders without crashing', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.getByTestId('number-input')).toBeInTheDocument();
});
it('renders the NumberInput label from EPG_SETTINGS_OPTIONS', () => {
render(<EpgSettingsForm active={true} />);
expect(
screen.getByText(EPG_SETTINGS_OPTIONS.xmltv_prev_days_override.label)
).toBeInTheDocument();
});
it('renders the NumberInput description from EPG_SETTINGS_OPTIONS', () => {
render(<EpgSettingsForm active={true} />);
expect(
screen.getByText(
EPG_SETTINGS_OPTIONS.xmltv_prev_days_override.description
)
).toBeInTheDocument();
});
it('renders the disclaimer text about per-user defaults', () => {
render(<EpgSettingsForm active={true} />);
expect(
screen.getByText(
/Per-user defaults and URL parameters still override this global value/i
)
).toBeInTheDocument();
});
it('renders the EPG channel matching hint', () => {
render(<EpgSettingsForm active={true} />);
expect(
screen.getByText(
/EPG channel matching options are configured from the Channels page/i
)
).toBeInTheDocument();
});
it('renders a Save button of type="submit"', () => {
render(<EpgSettingsForm active={true} />);
const btn = screen.getByText('Save');
expect(btn).toBeInTheDocument();
expect(btn).toHaveAttribute('type', 'submit');
});
it('does not show the "Saved Successfully" alert initially', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('NumberInput has min=0', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.getByTestId('number-input')).toHaveAttribute('min', '0');
});
it('NumberInput has max=30', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.getByTestId('number-input')).toHaveAttribute('max', '30');
});
it('NumberInput starts with value 0 from initial form values', () => {
render(<EpgSettingsForm active={true} />);
expect(screen.getByTestId('number-input')).toHaveValue(0);
});
});
// Initialization
describe('initialization', () => {
it('calls getEpgSettingsFormInitialValues to seed useForm', () => {
render(<EpgSettingsForm active={true} />);
expect(getEpgSettingsFormInitialValues).toHaveBeenCalled();
});
it('passes initial values from getEpgSettingsFormInitialValues to useForm', () => {
vi.mocked(getEpgSettingsFormInitialValues).mockReturnValue({
xmltv_prev_days_override: 5,
});
render(<EpgSettingsForm active={true} />);
expect(vi.mocked(useForm)).toHaveBeenCalledWith(
expect.objectContaining({
initialValues: { xmltv_prev_days_override: 5 },
})
);
});
it('calls useForm with mode="controlled"', () => {
render(<EpgSettingsForm active={true} />);
expect(vi.mocked(useForm)).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'controlled' })
);
});
});
// Settings effect
describe('settings effect', () => {
it('calls parseSettings when settings are provided', () => {
const settings = makeSettings({ xmltv_prev_days_override: 7 });
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings })
);
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({
xmltv_prev_days_override: 7,
});
render(<EpgSettingsForm active={true} />);
expect(SettingsUtils.parseSettings).toHaveBeenCalledWith(settings);
});
it('calls setFieldValue with parsed xmltv_prev_days_override', () => {
const settings = makeSettings({ xmltv_prev_days_override: 14 });
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings })
);
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({
xmltv_prev_days_override: 14,
});
render(<EpgSettingsForm active={true} />);
expect(mockForm.setFieldValue).toHaveBeenCalledWith(
'xmltv_prev_days_override',
14
);
});
it('calls setFieldValue with 0 when parsed value is undefined', () => {
const settings = makeSettings({});
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings })
);
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({});
render(<EpgSettingsForm active={true} />);
expect(mockForm.setFieldValue).toHaveBeenCalledWith(
'xmltv_prev_days_override',
0
);
});
it('does not call parseSettings when settings is null', () => {
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: null })
);
render(<EpgSettingsForm active={true} />);
expect(SettingsUtils.parseSettings).not.toHaveBeenCalled();
});
});
// active prop effect
describe('active prop effect', () => {
it('resets the saved alert when active changes to false', async () => {
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
const { rerender } = render(<EpgSettingsForm active={true} />);
// Trigger a successful save to get saved=true
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
// Switching active to false should dismiss the alert
rerender(<EpgSettingsForm active={false} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('does not show alert when active starts as false', () => {
render(<EpgSettingsForm active={false} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
});
// Submit success
describe('submit — success', () => {
beforeEach(() => {
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
});
it('calls getChangedSettings with form values and settings on submit', async () => {
const settings = makeSettings({ xmltv_prev_days_override: 3 });
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings })
);
render(<EpgSettingsForm active={true} />);
// Set the value after render so the settings useEffect (which resets the
// field to the parseSettings result) has already run and won't overwrite it.
mockForm.values.xmltv_prev_days_override = 5;
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(SettingsUtils.getChangedSettings).toHaveBeenCalledWith(
expect.objectContaining({ xmltv_prev_days_override: 5 }),
settings
);
});
});
it('calls saveChangedSettings with settings and changedSettings on submit', async () => {
const settings = makeSettings({ xmltv_prev_days_override: 3 });
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings })
);
const changed = { xmltv_prev_days_override: 7 };
vi.mocked(SettingsUtils.getChangedSettings).mockReturnValue(changed);
render(<EpgSettingsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(SettingsUtils.saveChangedSettings).toHaveBeenCalledWith(
settings,
changed
);
});
});
it('shows "Saved Successfully" alert after a successful save', async () => {
render(<EpgSettingsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
expect(screen.getByText('Saved Successfully')).toBeInTheDocument();
});
});
it('clears saved state before re-submitting', async () => {
render(<EpgSettingsForm active={true} />);
// First save
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
// Second save saved resets to false then true again
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
});
});
// Submit error
describe('submit — error', () => {
it('does not show alert when saveChangedSettings throws', async () => {
vi.mocked(SettingsUtils.saveChangedSettings).mockRejectedValue(
new Error('network error')
);
render(<EpgSettingsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(SettingsUtils.saveChangedSettings).toHaveBeenCalled();
});
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('does not throw when saveChangedSettings rejects', async () => {
vi.mocked(SettingsUtils.saveChangedSettings).mockRejectedValue(
new Error('fail')
);
render(<EpgSettingsForm active={true} />);
await expect(
waitFor(() => fireEvent.click(screen.getByText('Save')))
).resolves.not.toThrow();
});
});
// getInputProps wiring
describe('getInputProps wiring', () => {
it('calls form.getInputProps with xmltv_prev_days_override', () => {
render(<EpgSettingsForm active={true} />);
expect(mockForm.getInputProps).toHaveBeenCalledWith(
'xmltv_prev_days_override'
);
});
});
});

View file

@ -401,7 +401,9 @@ describe('ProxySettingsForm', () => {
expect(
screen.getByTestId('number-input-Channel Initialization Timeout')
).toBeInTheDocument();
expect(screen.getByTestId('number-input-Buffer Chunk TTL')).toBeInTheDocument();
expect(
screen.getByTestId('number-input-Buffer Chunk TTL')
).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,428 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import UserLimitsForm from '../UserLimitsForm';
// Store mock
vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() }));
// @mantine/form mock
vi.mock('@mantine/form', () => ({ useForm: vi.fn() }));
// @mantine/core mock
vi.mock('@mantine/core', () => ({
Alert: ({ title, color, variant }) => (
<div data-testid="alert" data-color={color} data-variant={variant}>
{title}
</div>
),
Button: ({ children, type, disabled, variant, color, onClick }) => (
<button
type={type}
disabled={disabled}
data-variant={variant}
data-color={color}
onClick={onClick}
>
{children}
</button>
),
Checkbox: ({ label, description, checked, onChange }) => (
<div data-testid="checkbox-wrapper">
{label && <label data-testid="checkbox-label">{label}</label>}
{description && (
<span data-testid="checkbox-description">{description}</span>
)}
<input
data-testid={`checkbox-${label}`}
type="checkbox"
checked={checked ?? false}
onChange={(e) => onChange?.(e.currentTarget.checked)}
/>
</div>
),
Flex: ({ children, mih, gap, justify, align }) => (
<div
data-mih={mih}
data-gap={gap}
data-justify={justify}
data-align={align}
>
{children}
</div>
),
Stack: ({ children, gap }) => <div data-gap={gap}>{children}</div>,
}));
// SettingsUtils mock
vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({
updateSetting: vi.fn(),
}));
//
// Imports after mocks
//
import useSettingsStore from '../../../../store/settings.jsx';
import { useForm } from '@mantine/form';
import { updateSetting } from '../../../../utils/pages/SettingsUtils.js';
import { USER_LIMITS_OPTIONS } from '../../../../constants.js';
// Derived constants (mirrors the component)
const USER_LIMIT_DEFAULTS = Object.keys(USER_LIMITS_OPTIONS).reduce(
(acc, key) => {
acc[key] = USER_LIMITS_OPTIONS[key].default;
return acc;
},
{}
);
// Form mock factory
let mockForm;
const createMockForm = (initialValues = { ...USER_LIMIT_DEFAULTS }) => {
const state = { ...initialValues };
return {
values: state,
setValues: vi.fn((vals) => {
Object.assign(state, vals);
}),
getInputProps: vi.fn((field, opts) => {
if (opts?.type === 'checkbox') {
return {
checked: state[field],
onChange: vi.fn((val) => {
state[field] = val;
}),
};
}
return { value: state[field], onChange: vi.fn() };
}),
getValues: vi.fn(() => ({ ...state })),
onSubmit: vi.fn((handler) => (e) => {
e?.preventDefault?.();
return handler();
}),
submitting: false,
};
};
// Settings factories
const makeSettings = (userLimitValue = {}) => ({
user_limit_settings: { id: 1, value: userLimitValue },
});
//
describe('UserLimitsForm', () => {
beforeEach(() => {
vi.clearAllMocks();
mockForm = createMockForm();
vi.mocked(useForm).mockReturnValue(mockForm);
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: null })
);
vi.mocked(updateSetting).mockResolvedValue({ id: 1 });
});
// Rendering
describe('rendering', () => {
it('renders without crashing', () => {
render(<UserLimitsForm active={true} />);
expect(screen.getAllByTestId('checkbox-wrapper')).toHaveLength(
Object.keys(USER_LIMITS_OPTIONS).length
);
});
it('renders a Checkbox for every USER_LIMITS_OPTIONS entry', () => {
render(<UserLimitsForm active={true} />);
Object.values(USER_LIMITS_OPTIONS).forEach((opt) => {
expect(screen.getByText(opt.label)).toBeInTheDocument();
});
});
it('renders every checkbox description', () => {
render(<UserLimitsForm active={true} />);
Object.values(USER_LIMITS_OPTIONS).forEach((opt) => {
expect(screen.getByText(opt.description)).toBeInTheDocument();
});
});
it('renders a Save button of type="submit"', () => {
render(<UserLimitsForm active={true} />);
const btn = screen.getByText('Save');
expect(btn).toHaveAttribute('type', 'submit');
});
it('renders a "Reset to Defaults" button', () => {
render(<UserLimitsForm active={true} />);
expect(screen.getByText('Reset to Defaults')).toBeInTheDocument();
});
it('does not show the "Saved Successfully" alert initially', () => {
render(<UserLimitsForm active={true} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
});
// Initialization
describe('initialization', () => {
it('calls useForm with mode="controlled"', () => {
render(<UserLimitsForm active={true} />);
expect(vi.mocked(useForm)).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'controlled' })
);
});
it('seeds useForm with USER_LIMIT_DEFAULTS as initialValues', () => {
render(<UserLimitsForm active={true} />);
expect(vi.mocked(useForm)).toHaveBeenCalledWith(
expect.objectContaining({ initialValues: USER_LIMIT_DEFAULTS })
);
});
it('calls getInputProps with type:"checkbox" for each option key', () => {
render(<UserLimitsForm active={true} />);
Object.keys(USER_LIMITS_OPTIONS).forEach((key) => {
expect(mockForm.getInputProps).toHaveBeenCalledWith(key, {
type: 'checkbox',
});
});
});
});
// Settings effect
describe('settings effect', () => {
it('calls setValues with merged defaults + stored values when settings provided', () => {
const stored = {
terminate_on_limit_exceeded: false,
ignore_same_channel_connections: true,
};
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: makeSettings(stored) })
);
render(<UserLimitsForm active={true} />);
expect(mockForm.setValues).toHaveBeenCalledWith({
...USER_LIMIT_DEFAULTS,
...stored,
});
});
it('does not call setValues when settings is null', () => {
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: null })
);
render(<UserLimitsForm active={true} />);
expect(mockForm.setValues).not.toHaveBeenCalled();
});
it('does not call setValues when user_limit_settings.value is absent', () => {
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: { user_limit_settings: { id: 1 } } })
);
render(<UserLimitsForm active={true} />);
expect(mockForm.setValues).not.toHaveBeenCalled();
});
});
// active prop effect
describe('active prop effect', () => {
it('resets the saved alert when active changes to false', async () => {
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: makeSettings({}) })
);
const { rerender } = render(<UserLimitsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
rerender(<UserLimitsForm active={false} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('does not show alert when active starts as false', () => {
render(<UserLimitsForm active={false} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
});
// Reset to Defaults
describe('"Reset to Defaults" button', () => {
it('calls form.setValues with USER_LIMIT_DEFAULTS when clicked', () => {
render(<UserLimitsForm active={true} />);
fireEvent.click(screen.getByText('Reset to Defaults'));
expect(mockForm.setValues).toHaveBeenCalledWith(USER_LIMIT_DEFAULTS);
});
it('can be clicked multiple times without error', () => {
render(<UserLimitsForm active={true} />);
fireEvent.click(screen.getByText('Reset to Defaults'));
fireEvent.click(screen.getByText('Reset to Defaults'));
expect(mockForm.setValues).toHaveBeenCalledTimes(2);
});
});
// Submit success
describe('submit — success', () => {
it('calls updateSetting with merged user_limit_settings + current values', async () => {
const stored = { id: 42, key: 'user_limit_settings', value: {} };
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: { user_limit_settings: stored } })
);
render(<UserLimitsForm active={true} />);
// Set values after render to avoid settings effect overwriting them
Object.assign(mockForm.values, { terminate_on_limit_exceeded: false });
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(updateSetting).toHaveBeenCalledWith({
...stored,
value: expect.objectContaining({
terminate_on_limit_exceeded: false,
}),
});
});
});
it('shows "Saved Successfully" alert when updateSetting returns a truthy result', async () => {
vi.mocked(updateSetting).mockResolvedValue({ id: 1 });
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: makeSettings({}) })
);
render(<UserLimitsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
expect(screen.getByText('Saved Successfully')).toBeInTheDocument();
});
});
it('does NOT show alert when updateSetting returns null/falsy', async () => {
vi.mocked(updateSetting).mockResolvedValue(null);
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: makeSettings({}) })
);
render(<UserLimitsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(updateSetting).toHaveBeenCalled();
});
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('passes getValues() result as the value to updateSetting', async () => {
const currentValues = {
terminate_on_limit_exceeded: false,
prioritize_single_client_channels: true,
ignore_same_channel_connections: true,
terminate_oldest: false,
};
mockForm.getValues.mockReturnValue(currentValues);
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: makeSettings({}) })
);
render(<UserLimitsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(updateSetting).toHaveBeenCalledWith(
expect.objectContaining({ value: currentValues })
);
});
});
it('clears saved=false at start of each submit before setting it true', async () => {
vi.mocked(updateSetting).mockResolvedValue({ id: 1 });
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: makeSettings({}) })
);
render(<UserLimitsForm active={true} />);
// First save
fireEvent.click(screen.getByText('Save'));
await waitFor(() =>
expect(screen.getByTestId('alert')).toBeInTheDocument()
);
// Second save alert should still appear after re-submit
fireEvent.click(screen.getByText('Save'));
await waitFor(() =>
expect(screen.getByTestId('alert')).toBeInTheDocument()
);
});
});
// Submit error
describe('submit — error', () => {
it('does not show alert when updateSetting throws', async () => {
vi.mocked(updateSetting).mockRejectedValue(new Error('network error'));
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: makeSettings({}) })
);
render(<UserLimitsForm active={true} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(updateSetting).toHaveBeenCalled();
});
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('does not throw to the caller when updateSetting rejects', async () => {
vi.mocked(updateSetting).mockRejectedValue(new Error('fail'));
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: makeSettings({}) })
);
render(<UserLimitsForm active={true} />);
await expect(
waitFor(() => fireEvent.click(screen.getByText('Save')))
).resolves.not.toThrow();
});
});
// Submit disabled state
describe('Save button disabled state', () => {
it('is disabled when form.submitting is true', () => {
mockForm.submitting = true;
render(<UserLimitsForm active={true} />);
expect(screen.getByText('Save')).toBeDisabled();
});
it('is not disabled when form.submitting is false', () => {
mockForm.submitting = false;
render(<UserLimitsForm active={true} />);
expect(screen.getByText('Save')).not.toBeDisabled();
});
});
});

View file

@ -1,25 +0,0 @@
/**
* Compare two semver-like version strings.
* Returns negative if a < b, 0 if equal, positive if a > b.
*
* If either version is a prerelease (any dot-segment contains non-digit
* characters), numeric ordering is meaningless. Fall back to exact string
* equality: 0 if identical, non-zero otherwise.
*/
export function compareVersions(a, b) {
if (!a || !b) return 0;
const normalize = (v) => v.replace(/^v/, '');
const na = normalize(a);
const nb = normalize(b);
const isPrerelease = (v) => v.split('.').some((p) => !/^\d+$/.test(p));
if (isPrerelease(na) || isPrerelease(nb)) {
return na === nb ? 0 : 1;
}
const pa = na.split('.').map(Number);
const pb = nb.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}

View file

@ -34,7 +34,7 @@ import useSettingsStore from '../store/settings.jsx';
import AvailablePluginCard from '../components/cards/AvailablePluginCard.jsx';
import { showNotification } from '../utils/notificationUtils.js';
import { reloadPlugins } from '../utils/pages/PluginsUtils.js';
import { compareVersions } from '../components/pluginUtils.js';
import { compareVersions } from '../utils/components/pluginUtils.js';
export default function PluginBrowsePage() {
const repos = usePluginStore((s) => s.repos);

View file

@ -0,0 +1,474 @@
import { describe, it, expect } from 'vitest';
import {
compareVersions,
buildCompatibilityTooltip,
buildVersionSelectItems,
getInstallInfo,
} from '../pluginUtils.js';
describe('pluginUtils', () => {
// ───────────────────────────────────────────────────────────────────────────
// compareVersions
// ───────────────────────────────────────────────────────────────────────────
describe('compareVersions', () => {
// ── basic ordering ──────────────────────────────────────────────────────────
describe('numeric ordering', () => {
it('returns 0 for identical versions', () => {
expect(compareVersions('1.2.3', '1.2.3')).toBe(0);
});
it('returns positive when a > b (major)', () => {
expect(compareVersions('2.0.0', '1.9.9')).toBeGreaterThan(0);
});
it('returns negative when a < b (major)', () => {
expect(compareVersions('1.0.0', '2.0.0')).toBeLessThan(0);
});
it('returns positive when a > b (minor)', () => {
expect(compareVersions('1.3.0', '1.2.9')).toBeGreaterThan(0);
});
it('returns negative when a < b (minor)', () => {
expect(compareVersions('1.2.0', '1.3.0')).toBeLessThan(0);
});
it('returns positive when a > b (patch)', () => {
expect(compareVersions('1.0.2', '1.0.1')).toBeGreaterThan(0);
});
it('returns negative when a < b (patch)', () => {
expect(compareVersions('1.0.1', '1.0.2')).toBeLessThan(0);
});
it('handles versions with different segment counts', () => {
expect(compareVersions('1.2', '1.2.0')).toBe(0);
expect(compareVersions('1.3', '1.2.9')).toBeGreaterThan(0);
});
});
// ── v-prefix stripping ──────────────────────────────────────────────────────
describe('v-prefix stripping', () => {
it('strips leading "v" before comparing', () => {
expect(compareVersions('v1.2.3', '1.2.3')).toBe(0);
expect(compareVersions('v2.0.0', 'v1.0.0')).toBeGreaterThan(0);
expect(compareVersions('v1.0.0', 'v2.0.0')).toBeLessThan(0);
});
});
// ── prerelease fallback ─────────────────────────────────────────────────────
describe('prerelease fallback (string equality)', () => {
it('returns 0 for identical prerelease strings', () => {
expect(compareVersions('1.0.0-beta.1', '1.0.0-beta.1')).toBe(0);
});
it('returns non-zero for different prerelease strings', () => {
expect(compareVersions('1.0.0-beta.1', '1.0.0-beta.2')).not.toBe(0);
});
it('returns non-zero when one side is prerelease and the other is not', () => {
expect(compareVersions('1.0.0-beta', '1.0.0')).not.toBe(0);
});
it('falls back to string equality even when one is numerically "larger"', () => {
// '2.0.0-rc1' contains a non-digit segment so the prerelease path is taken
expect(compareVersions('2.0.0-rc1', '1.0.0')).not.toBe(0);
});
});
// ── null / undefined guards ─────────────────────────────────────────────────
describe('null / undefined guards', () => {
it('returns 0 when a is null', () => {
expect(compareVersions(null, '1.0.0')).toBe(0);
});
it('returns 0 when b is null', () => {
expect(compareVersions('1.0.0', null)).toBe(0);
});
it('returns 0 when both are null', () => {
expect(compareVersions(null, null)).toBe(0);
});
it('returns 0 when a is undefined', () => {
expect(compareVersions(undefined, '1.0.0')).toBe(0);
});
it('returns 0 when b is undefined', () => {
expect(compareVersions('1.0.0', undefined)).toBe(0);
});
});
});
// ───────────────────────────────────────────────────────────────────────────
// buildCompatibilityTooltip
// ───────────────────────────────────────────────────────────────────────────
describe('buildCompatibilityTooltip', () => {
const vd = {
min_dispatcharr_version: '2.0.0',
max_dispatcharr_version: '3.0.0',
};
it('returns min constraint when only min is not met', () => {
expect(buildCompatibilityTooltip(false, vd, true)).toBe('2.0.0 or newer');
});
it('returns max constraint when only max is not met', () => {
expect(buildCompatibilityTooltip(true, vd, false)).toBe('3.0.0 or older');
});
it('returns both constraints joined by " and " when neither is met', () => {
expect(buildCompatibilityTooltip(false, vd, false)).toBe(
'2.0.0 or newer and 3.0.0 or older'
);
});
it('returns an empty string when both constraints are met', () => {
expect(buildCompatibilityTooltip(true, vd, true)).toBe('');
});
it('uses the actual version values from selectedVersionData', () => {
const custom = {
min_dispatcharr_version: '1.5.0',
max_dispatcharr_version: '4.0.0',
};
expect(buildCompatibilityTooltip(false, custom, false)).toBe(
'1.5.0 or newer and 4.0.0 or older'
);
});
});
// ───────────────────────────────────────────────────────────────────────────
// buildVersionSelectItems
// ───────────────────────────────────────────────────────────────────────────
describe('buildVersionSelectItems', () => {
// ── basic label building ────────────────────────────────────────────────────
describe('label building', () => {
it('prefixes every version value with "v" in the label', () => {
const items = buildVersionSelectItems(
[{ version: '1.0.0', prerelease: false }],
'1.0.0',
null,
false
);
expect(items[0].label).toMatch(/^v1\.0\.0/);
});
it('appends "(latest)" for the latest version', () => {
const items = buildVersionSelectItems(
[{ version: '2.0.0', prerelease: false }],
'2.0.0',
null,
false
);
expect(items[0].label).toContain('(latest)');
});
it('does not append "(latest)" for non-latest versions', () => {
const items = buildVersionSelectItems(
[
{ version: '2.0.0', prerelease: false },
{ version: '1.0.0', prerelease: false },
],
'2.0.0',
null,
false
);
expect(items[1].label).not.toContain('(latest)');
});
it('appends "(installed)" for the currently installed version', () => {
const items = buildVersionSelectItems(
[
{ version: '2.0.0', prerelease: false },
{ version: '1.0.0', prerelease: false },
],
'2.0.0',
'1.0.0',
false
);
const installed = items.find((i) => i.value === '1.0.0');
expect(installed.label).toContain('(installed)');
});
it('does not append "(installed)" when installedVersion is null', () => {
const items = buildVersionSelectItems(
[{ version: '1.0.0', prerelease: false }],
'1.0.0',
null,
false
);
expect(items[0].label).not.toContain('(installed)');
});
it('appends "(prerelease)" for prerelease versions', () => {
const items = buildVersionSelectItems(
[{ version: '2.0.0-beta', prerelease: true }],
null,
null,
false
);
expect(items[0].label).toContain('(prerelease)');
});
it('does not append "(prerelease)" for stable versions', () => {
const items = buildVersionSelectItems(
[{ version: '1.0.0', prerelease: false }],
null,
null,
false
);
expect(items[0].label).not.toContain('(prerelease)');
});
it('can combine multiple suffixes on one item (installed + latest)', () => {
const items = buildVersionSelectItems(
[{ version: '1.0.0', prerelease: false }],
'1.0.0',
'1.0.0',
false
);
expect(items[0].label).toContain('(latest)');
expect(items[0].label).toContain('(installed)');
});
it('all regular items have disabled: false', () => {
const items = buildVersionSelectItems(
[
{ version: '2.0.0', prerelease: false },
{ version: '1.0.0', prerelease: false },
],
'2.0.0',
null,
false
);
items.forEach((item) => expect(item.disabled).toBe(false));
});
it('value property equals the raw version string (no "v" prefix)', () => {
const items = buildVersionSelectItems(
[{ version: '1.2.3', prerelease: false }],
null,
null,
false
);
expect(items[0].value).toBe('1.2.3');
});
});
// ── sort order (installedVersionIsPrerelease flag) ──────────────────────────
describe('sort order', () => {
const versions = [
{ version: '2.0.0', prerelease: false },
{ version: '2.0.0-beta', prerelease: true },
{ version: '1.0.0', prerelease: false },
];
it('preserves manifest order when installedVersionIsPrerelease is false', () => {
const items = buildVersionSelectItems(versions, '2.0.0', null, false);
expect(items.map((i) => i.value)).toEqual([
'2.0.0',
'2.0.0-beta',
'1.0.0',
]);
});
it('floats prereleases to the top when installedVersionIsPrerelease is true', () => {
const items = buildVersionSelectItems(versions, '2.0.0', null, true);
expect(items[0].value).toBe('2.0.0-beta');
});
it('stable versions follow all prereleases when installedVersionIsPrerelease is true', () => {
const items = buildVersionSelectItems(versions, '2.0.0', null, true);
const stableValues = items
.filter((i) => !i.label.includes('(prerelease)'))
.map((i) => i.value);
expect(stableValues).toEqual(['2.0.0', '1.0.0']);
});
});
// ── ghost item (installed version missing from manifest) ───────────────────
describe('ghost item for missing installed version', () => {
it('inserts a disabled ghost item when installed version is absent from manifest', () => {
const items = buildVersionSelectItems(
[
{ version: '2.0.0', prerelease: false },
{ version: '1.0.0', prerelease: false },
],
'2.0.0',
'1.5.0',
false
);
const ghost = items.find((i) => i.value === '1.5.0');
expect(ghost).toBeDefined();
expect(ghost.disabled).toBe(true);
});
it('ghost item label is "v<version> (installed)"', () => {
const items = buildVersionSelectItems(
[
{ version: '2.0.0', prerelease: false },
{ version: '1.0.0', prerelease: false },
],
'2.0.0',
'1.5.0',
false
);
const ghost = items.find((i) => i.value === '1.5.0');
expect(ghost.label).toBe('v1.5.0 (installed)');
});
it('ghost item is inserted between the first newer and first older item', () => {
const items = buildVersionSelectItems(
[
{ version: '2.0.0', prerelease: false },
{ version: '1.0.0', prerelease: false },
],
'2.0.0',
'1.5.0',
false
);
const values = items.map((i) => i.value);
const ghostIdx = values.indexOf('1.5.0');
const newerIdx = values.indexOf('2.0.0');
const olderIdx = values.indexOf('1.0.0');
expect(ghostIdx).toBeGreaterThan(newerIdx);
expect(ghostIdx).toBeLessThan(olderIdx);
});
it('ghost item is appended at the end when installed is older than all manifest versions', () => {
const items = buildVersionSelectItems(
[
{ version: '3.0.0', prerelease: false },
{ version: '2.0.0', prerelease: false },
],
'3.0.0',
'1.0.0',
false
);
const values = items.map((i) => i.value);
expect(values[values.length - 1]).toBe('1.0.0');
});
it('does not insert a ghost item when installed version IS in the manifest', () => {
const items = buildVersionSelectItems(
[
{ version: '2.0.0', prerelease: false },
{ version: '1.0.0', prerelease: false },
],
'2.0.0',
'1.0.0',
false
);
expect(items.filter((i) => i.disabled)).toHaveLength(0);
});
it('does not insert a ghost item when installedVersion is null', () => {
const items = buildVersionSelectItems(
[{ version: '1.0.0', prerelease: false }],
'1.0.0',
null,
false
);
expect(items.filter((i) => i.disabled)).toHaveLength(0);
});
});
// ── edge cases ──────────────────────────────────────────────────────────────
describe('edge cases', () => {
it('returns an empty array when versions is empty', () => {
expect(buildVersionSelectItems([], null, null, false)).toEqual([]);
});
it('handles latestVersion being null — no "(latest)" label appended', () => {
const items = buildVersionSelectItems(
[{ version: '1.0.0', prerelease: false }],
null,
null,
false
);
expect(items[0].label).not.toContain('(latest)');
});
it('does not mutate the original versions array', () => {
const versions = [
{ version: '2.0.0', prerelease: false },
{ version: '2.0.0-beta', prerelease: true },
];
const original = [...versions];
buildVersionSelectItems(versions, '2.0.0', null, true);
expect(versions).toEqual(original);
});
it('returns one item per version when no ghost is needed', () => {
const versions = [
{ version: '3.0.0', prerelease: false },
{ version: '2.0.0', prerelease: false },
{ version: '1.0.0', prerelease: false },
];
const items = buildVersionSelectItems(
versions,
'3.0.0',
'2.0.0',
false
);
expect(items).toHaveLength(3);
});
it('returns versions.length + 1 items when a ghost is inserted', () => {
const versions = [
{ version: '2.0.0', prerelease: false },
{ version: '1.0.0', prerelease: false },
];
const items = buildVersionSelectItems(
versions,
'2.0.0',
'1.5.0',
false
);
expect(items).toHaveLength(3);
});
});
});
// ───────────────────────────────────────────────────────────────────────────
// getInstallInfo
// ───────────────────────────────────────────────────────────────────────────
describe('getInstallInfo', () => {
it('returns isDowngrade true when pendingInstall is lower than installed_version', () => {
const pendingInstall = { version: '1.0.0' };
const plugin = { installed_version: '2.0.0', signature_verified: true };
const info = getInstallInfo(pendingInstall, plugin);
expect(info.isDowngrade).toBe(true);
expect(info.isUpdate).toBe(false);
expect(info.isBadSig).toBe(false);
});
it('returns isUpdate true when pendingInstall is higher than installed_version', () => {
const pendingInstall = { version: '3.0.0' };
const plugin = { installed_version: '2.0.0', signature_verified: true };
const info = getInstallInfo(pendingInstall, plugin);
expect(info.isDowngrade).toBe(false);
expect(info.isUpdate).toBe(true);
expect(info.isBadSig).toBe(false);
});
it('returns isBadSig true when signature_verified is false', () => {
const pendingInstall = { version: '2.0.0' };
const plugin = { installed_version: '2.0.0', signature_verified: false };
const info = getInstallInfo(pendingInstall, plugin);
expect(info.isDowngrade).toBe(false);
expect(info.isUpdate).toBe(false);
expect(info.isBadSig).toBe(true);
});
it('returns all false when no conditions are met', () => {
const pendingInstall = { version: '2.0.0' };
const plugin = { installed_version: '2.0.0', signature_verified: true };
const info = getInstallInfo(pendingInstall, plugin);
expect(info.isDowngrade).toBe(false);
expect(info.isUpdate).toBe(false);
expect(info.isBadSig).toBe(false);
});
});
});

View file

@ -0,0 +1,61 @@
// Convert 24h time string to 12h format with period
import API from '../../../api.js';
export function to12Hour(time24) {
if (!time24) return { time: '12:00', period: 'AM' };
const [hours, minutes] = time24.split(':').map(Number);
const period = hours >= 12 ? 'PM' : 'AM';
const hours12 = hours % 12 || 12;
return {
time: `${hours12}:${String(minutes).padStart(2, '0')}`,
period,
};
}
// Convert 12h time + period to 24h format
export function to24Hour(time12, period) {
if (!time12) return '00:00';
const [hours, minutes] = time12.split(':').map(Number);
let hours24 = hours;
if (period === 'PM' && hours !== 12) {
hours24 = hours + 12;
} else if (period === 'AM' && hours === 12) {
hours24 = 0;
}
return `${String(hours24).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
}
export const DAYS_OF_WEEK = [
{ value: '0', label: 'Sunday' },
{ value: '1', label: 'Monday' },
{ value: '2', label: 'Tuesday' },
{ value: '3', label: 'Wednesday' },
{ value: '4', label: 'Thursday' },
{ value: '5', label: 'Friday' },
{ value: '6', label: 'Saturday' },
];
export const listBackups = () => {
return API.listBackups();
};
export const getBackupSchedule = () => {
return API.getBackupSchedule();
};
export const updateBackupSchedule = (settings) => {
return API.updateBackupSchedule(settings);
};
export const createBackup = () => {
return API.createBackup();
};
export const uploadBackup = (file) => {
return API.uploadBackup(file);
};
export const downloadBackup = (filename) => {
return API.downloadBackup(filename);
};
export const restoreBackup = (filename, onProgress) => {
return API.restoreBackup(filename, onProgress);
};
export const deleteBackup = (filename) => {
return API.deleteBackup(filename);
};

View file

@ -0,0 +1,327 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── API mock ───────────────────────────────────────────────────────────────────
vi.mock('../../../../api.js', () => ({
default: {
listBackups: vi.fn(),
getBackupSchedule: vi.fn(),
updateBackupSchedule: vi.fn(),
createBackup: vi.fn(),
uploadBackup: vi.fn(),
downloadBackup: vi.fn(),
restoreBackup: vi.fn(),
deleteBackup: vi.fn(),
},
}));
import API from '../../../../api.js';
import {
to12Hour,
to24Hour,
DAYS_OF_WEEK,
listBackups,
getBackupSchedule,
updateBackupSchedule,
createBackup,
uploadBackup,
downloadBackup,
restoreBackup,
deleteBackup,
} from '../BackupManagerUtils.js';
// ──────────────────────────────────────────────────────────────────────────────
describe('BackupManagerUtils', () => {
describe('to12Hour', () => {
it('converts midnight (00:00) to 12:00 AM', () => {
expect(to12Hour('00:00')).toEqual({ time: '12:00', period: 'AM' });
});
it('converts noon (12:00) to 12:00 PM', () => {
expect(to12Hour('12:00')).toEqual({ time: '12:00', period: 'PM' });
});
it('converts 13:00 to 1:00 PM', () => {
expect(to12Hour('13:00')).toEqual({ time: '1:00', period: 'PM' });
});
it('converts 09:05 to 9:05 AM', () => {
expect(to12Hour('09:05')).toEqual({ time: '9:05', period: 'AM' });
});
it('converts 23:59 to 11:59 PM', () => {
expect(to12Hour('23:59')).toEqual({ time: '11:59', period: 'PM' });
});
it('converts 01:30 to 1:30 AM', () => {
expect(to12Hour('01:30')).toEqual({ time: '1:30', period: 'AM' });
});
it('converts 12:45 to 12:45 PM', () => {
expect(to12Hour('12:45')).toEqual({ time: '12:45', period: 'PM' });
});
it('returns default when called with null', () => {
expect(to12Hour(null)).toEqual({ time: '12:00', period: 'AM' });
});
it('returns default when called with undefined', () => {
expect(to12Hour(undefined)).toEqual({ time: '12:00', period: 'AM' });
});
it('returns default when called with empty string', () => {
expect(to12Hour('')).toEqual({ time: '12:00', period: 'AM' });
});
it('pads single-digit minutes correctly', () => {
expect(to12Hour('14:05')).toEqual({ time: '2:05', period: 'PM' });
});
});
// ──────────────────────────────────────────────────────────────────────────────
describe('to24Hour', () => {
it('converts 12:00 AM to 00:00', () => {
expect(to24Hour('12:00', 'AM')).toBe('00:00');
});
it('converts 12:00 PM to 12:00', () => {
expect(to24Hour('12:00', 'PM')).toBe('12:00');
});
it('converts 1:00 PM to 13:00', () => {
expect(to24Hour('1:00', 'PM')).toBe('13:00');
});
it('converts 11:59 PM to 23:59', () => {
expect(to24Hour('11:59', 'PM')).toBe('23:59');
});
it('converts 9:05 AM to 09:05', () => {
expect(to24Hour('9:05', 'AM')).toBe('09:05');
});
it('converts 12:30 AM to 00:30', () => {
expect(to24Hour('12:30', 'AM')).toBe('00:30');
});
it('converts 12:45 PM to 12:45', () => {
expect(to24Hour('12:45', 'PM')).toBe('12:45');
});
it('converts 1:00 AM to 01:00', () => {
expect(to24Hour('1:00', 'AM')).toBe('01:00');
});
it('returns 00:00 when time12 is null', () => {
expect(to24Hour(null, 'AM')).toBe('00:00');
});
it('returns 00:00 when time12 is undefined', () => {
expect(to24Hour(undefined, 'PM')).toBe('00:00');
});
it('returns 00:00 when time12 is empty string', () => {
expect(to24Hour('', 'PM')).toBe('00:00');
});
it('pads hours and minutes to two digits', () => {
expect(to24Hour('2:05', 'PM')).toBe('14:05');
});
});
// ──────────────────────────────────────────────────────────────────────────────
describe('to12Hour / to24Hour roundtrip', () => {
const cases = [
'00:00',
'00:30',
'01:00',
'09:05',
'11:59',
'12:00',
'12:01',
'13:00',
'14:05',
'23:59',
];
it.each(cases)('round-trips %s', (time24) => {
const { time, period } = to12Hour(time24);
expect(to24Hour(time, period)).toBe(time24);
});
});
// ──────────────────────────────────────────────────────────────────────────────
describe('DAYS_OF_WEEK', () => {
it('has 7 entries', () => {
expect(DAYS_OF_WEEK).toHaveLength(7);
});
it('starts with Sunday (value "0")', () => {
expect(DAYS_OF_WEEK[0]).toEqual({ value: '0', label: 'Sunday' });
});
it('ends with Saturday (value "6")', () => {
expect(DAYS_OF_WEEK[6]).toEqual({ value: '6', label: 'Saturday' });
});
it('contains Monday through Friday in order', () => {
const labels = DAYS_OF_WEEK.map((d) => d.label);
expect(labels).toEqual([
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
]);
});
it('has string values "0""6"', () => {
DAYS_OF_WEEK.forEach((day, i) => {
expect(day.value).toBe(String(i));
});
});
});
// ──────────────────────────────────────────────────────────────────────────────
describe('API proxy functions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('listBackups', () => {
it('delegates to API.listBackups and returns its result', async () => {
const data = [{ filename: 'backup1.zip' }];
vi.mocked(API.listBackups).mockResolvedValue(data);
await expect(listBackups()).resolves.toEqual(data);
expect(API.listBackups).toHaveBeenCalledTimes(1);
});
it('propagates rejection from API.listBackups', async () => {
vi.mocked(API.listBackups).mockRejectedValue(new Error('network'));
await expect(listBackups()).rejects.toThrow('network');
});
});
describe('getBackupSchedule', () => {
it('delegates to API.getBackupSchedule and returns its result', async () => {
const schedule = { enabled: true, time: '02:00' };
vi.mocked(API.getBackupSchedule).mockResolvedValue(schedule);
await expect(getBackupSchedule()).resolves.toEqual(schedule);
expect(API.getBackupSchedule).toHaveBeenCalledTimes(1);
});
it('propagates rejection from API.getBackupSchedule', async () => {
vi.mocked(API.getBackupSchedule).mockRejectedValue(new Error('fail'));
await expect(getBackupSchedule()).rejects.toThrow('fail');
});
});
describe('updateBackupSchedule', () => {
it('passes settings through to API.updateBackupSchedule', async () => {
const settings = { enabled: false, time: '03:00' };
const updated = { ...settings, id: 1 };
vi.mocked(API.updateBackupSchedule).mockResolvedValue(updated);
await expect(updateBackupSchedule(settings)).resolves.toEqual(updated);
expect(API.updateBackupSchedule).toHaveBeenCalledWith(settings);
});
it('propagates rejection', async () => {
vi.mocked(API.updateBackupSchedule).mockRejectedValue(new Error('err'));
await expect(updateBackupSchedule({})).rejects.toThrow('err');
});
});
describe('createBackup', () => {
it('delegates to API.createBackup and returns its result', async () => {
const result = { filename: 'new-backup.zip' };
vi.mocked(API.createBackup).mockResolvedValue(result);
await expect(createBackup()).resolves.toEqual(result);
expect(API.createBackup).toHaveBeenCalledTimes(1);
});
it('propagates rejection', async () => {
vi.mocked(API.createBackup).mockRejectedValue(new Error('disk full'));
await expect(createBackup()).rejects.toThrow('disk full');
});
});
describe('uploadBackup', () => {
it('passes file to API.uploadBackup', async () => {
const file = new File(['data'], 'backup.zip');
const result = { filename: 'backup.zip' };
vi.mocked(API.uploadBackup).mockResolvedValue(result);
await expect(uploadBackup(file)).resolves.toEqual(result);
expect(API.uploadBackup).toHaveBeenCalledWith(file);
});
it('propagates rejection', async () => {
vi.mocked(API.uploadBackup).mockRejectedValue(
new Error('upload error')
);
await expect(uploadBackup(new File([], 'x.zip'))).rejects.toThrow(
'upload error'
);
});
});
describe('downloadBackup', () => {
it('passes filename to API.downloadBackup', async () => {
const filename = 'backup-2024.zip';
vi.mocked(API.downloadBackup).mockResolvedValue({ filename });
await expect(downloadBackup(filename)).resolves.toEqual({ filename });
expect(API.downloadBackup).toHaveBeenCalledWith(filename);
});
it('propagates rejection', async () => {
vi.mocked(API.downloadBackup).mockRejectedValue(new Error('not found'));
await expect(downloadBackup('missing.zip')).rejects.toThrow(
'not found'
);
});
});
describe('restoreBackup', () => {
it('passes filename and onProgress to API.restoreBackup', async () => {
const filename = 'backup.zip';
const onProgress = vi.fn();
const result = { success: true };
vi.mocked(API.restoreBackup).mockResolvedValue(result);
await expect(restoreBackup(filename, onProgress)).resolves.toEqual(
result
);
expect(API.restoreBackup).toHaveBeenCalledWith(filename, onProgress);
});
it('propagates rejection', async () => {
vi.mocked(API.restoreBackup).mockRejectedValue(
new Error('restore failed')
);
await expect(restoreBackup('backup.zip', vi.fn())).rejects.toThrow(
'restore failed'
);
});
});
describe('deleteBackup', () => {
it('passes filename to API.deleteBackup', async () => {
const filename = 'old-backup.zip';
vi.mocked(API.deleteBackup).mockResolvedValue(undefined);
await expect(deleteBackup(filename)).resolves.toBeUndefined();
expect(API.deleteBackup).toHaveBeenCalledWith(filename);
});
it('propagates rejection', async () => {
vi.mocked(API.deleteBackup).mockRejectedValue(
new Error('delete error')
);
await expect(deleteBackup('old.zip')).rejects.toThrow('delete error');
});
});
});
});

View file

@ -0,0 +1,99 @@
/**
* Compare two semver-like version strings.
* Returns negative if a < b, 0 if equal, positive if a > b.
*
* If either version is a prerelease (any dot-segment contains non-digit
* characters), numeric ordering is meaningless. Fall back to exact string
* equality: 0 if identical, non-zero otherwise.
*/
export function compareVersions(a, b) {
if (!a || !b) return 0;
const normalize = (v) => v.replace(/^v/, '');
const na = normalize(a);
const nb = normalize(b);
const isPrerelease = (v) => v.split('.').some((p) => !/^\d+$/.test(p));
if (isPrerelease(na) || isPrerelease(nb)) {
return na === nb ? 0 : 1;
}
const pa = na.split('.').map(Number);
const pb = nb.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}
export const buildCompatibilityTooltip = (
selMeetsMin,
selectedVersionData,
selMeetsMax
) => {
const parts = [];
if (!selMeetsMin)
parts.push(`${selectedVersionData.min_dispatcharr_version} or newer`);
if (!selMeetsMax)
parts.push(`${selectedVersionData.max_dispatcharr_version} or older`);
return parts.join(' and ');
};
export function buildVersionSelectItems(
versions,
latestVersion,
installedVersion,
installedVersionIsPrerelease
) {
const buildLabel = (v) =>
`v${v.version}` +
(v.prerelease ? ' (prerelease)' : '') +
(v.version === latestVersion ? ' (latest)' : '') +
(installedVersion && compareVersions(v.version, installedVersion) === 0
? ' (installed)'
: '');
let sorted = [...versions];
if (installedVersionIsPrerelease) {
sorted = [
...sorted.filter((v) => v.prerelease),
...sorted.filter((v) => !v.prerelease),
];
}
const items = sorted.map((v) => ({
value: v.version,
label: buildLabel(v),
disabled: false,
}));
const installedMissing =
installedVersion &&
!versions.some((v) => compareVersions(v.version, installedVersion) === 0);
if (installedMissing) {
const ghost = {
value: installedVersion,
label: `v${installedVersion} (installed)`,
disabled: true,
};
const idx = items.findIndex(
(item) => compareVersions(installedVersion, item.value) > 0
);
idx === -1 ? items.push(ghost) : items.splice(idx, 0, ghost);
}
return items;
}
export const getInstallInfo = (pendingInstall, plugin) => {
const isDowngrade =
pendingInstall &&
plugin.installed_version &&
compareVersions(pendingInstall.version, plugin.installed_version) < 0;
const isUpdate =
pendingInstall &&
plugin.installed_version &&
!isDowngrade &&
compareVersions(pendingInstall.version, plugin.installed_version) > 0;
const isBadSig = plugin.signature_verified === false;
return { isDowngrade, isUpdate, isBadSig };
};

View file

@ -9,7 +9,11 @@ export const runPluginAction = async (key, actionId) => {
export const setPluginEnabled = async (key, next) => {
return await API.setPluginEnabled(key, next);
};
export const importPlugin = async (importFile, overwrite = false, silent = false) => {
export const importPlugin = async (
importFile,
overwrite = false,
silent = false
) => {
return await API.importPlugin(importFile, overwrite, silent);
};
export const reloadPlugins = async () => {
@ -18,3 +22,6 @@ export const reloadPlugins = async () => {
export const deletePluginByKey = (key) => {
return API.deletePlugin(key);
};
export const getPluginDetailManifest = (repoId, manifestUrl) => {
return API.getPluginDetailManifest(repoId, manifestUrl);
};

View file

@ -10,6 +10,7 @@ vi.mock('../../../api.js', () => ({
importPlugin: vi.fn(),
reloadPlugins: vi.fn(),
deletePlugin: vi.fn(),
getPluginDetailManifest: vi.fn(),
},
}));
@ -298,4 +299,49 @@ describe('PluginsUtils', () => {
expect(API.deletePlugin).toHaveBeenCalledWith(null);
});
});
describe('getPluginDetailManifest', () => {
it('should call API getPluginDetailManifest with repoId and manifestUrl', () => {
const repoId = 'test-repo';
const manifestUrl = 'https://example.com/manifest.json';
PluginsUtils.getPluginDetailManifest(repoId, manifestUrl);
expect(API.getPluginDetailManifest).toHaveBeenCalledWith(
repoId,
manifestUrl
);
expect(API.getPluginDetailManifest).toHaveBeenCalledTimes(1);
});
it('should return API response', () => {
const repoId = 'test-repo';
const manifestUrl = 'https://example.com/manifest.json';
const mockResponse = { name: 'Test Plugin', version: '1.0.0' };
API.getPluginDetailManifest.mockReturnValue(mockResponse);
const result = PluginsUtils.getPluginDetailManifest(repoId, manifestUrl);
expect(result).toEqual(mockResponse);
});
it('should handle empty string repoId and manifestUrl', () => {
const repoId = '';
const manifestUrl = '';
PluginsUtils.getPluginDetailManifest(repoId, manifestUrl);
expect(API.getPluginDetailManifest).toHaveBeenCalledWith('', '');
});
it('should handle null repoId and manifestUrl', () => {
const repoId = null;
const manifestUrl = null;
PluginsUtils.getPluginDetailManifest(repoId, manifestUrl);
expect(API.getPluginDetailManifest).toHaveBeenCalledWith(null, null);
});
});
});