mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Merge pull request #1424 from nick4810:tests/frontend-unit-tests
Tests/frontend unit tests
This commit is contained in:
commit
d56680cad8
35 changed files with 6765 additions and 416 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
318
frontend/src/components/__tests__/AboutModal.test.jsx
Normal file
318
frontend/src/components/__tests__/AboutModal.test.jsx
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import AboutModal from '../AboutModal';
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../store/settings', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Image mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../images/logo.png', () => ({ default: 'mocked-logo.png' }));
|
||||
|
||||
// ── Custom icons mock ──────────────────────────────────────────────────────────
|
||||
vi.mock('../icons.jsx', () => ({
|
||||
DiscordIcon: ({ size }) => (
|
||||
<svg data-testid="discord-icon" data-size={size} />
|
||||
),
|
||||
GitHubIcon: ({ size }) => <svg data-testid="github-icon" data-size={size} />,
|
||||
}));
|
||||
|
||||
// ── lucide-react mock ──────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
BookOpen: ({ size }) => <svg data-testid="icon-book-open" data-size={size} />,
|
||||
Heart: ({ size }) => <svg data-testid="icon-heart" data-size={size} />,
|
||||
Users: ({ size }) => <svg data-testid="icon-users" data-size={size} />,
|
||||
}));
|
||||
|
||||
// ── Mantine core mock ──────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', async () => ({
|
||||
Box: ({ children, style }) => <div style={style}>{children}</div>,
|
||||
Button: ({ children, href, target, rel, variant, color, leftSection }) => (
|
||||
<a
|
||||
data-testid="button"
|
||||
href={href}
|
||||
target={target}
|
||||
rel={rel}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
>
|
||||
{leftSection}
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
Divider: () => <hr data-testid="divider" />,
|
||||
Group: ({ children, justify }) => (
|
||||
<div data-justify={justify}>{children}</div>
|
||||
),
|
||||
Modal: ({ children, opened, onClose, title, size }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal" data-size={size}>
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
SimpleGrid: ({ children, cols }) => <div data-cols={cols}>{children}</div>,
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children, fw, size, c, span }) =>
|
||||
span ? (
|
||||
<span data-fw={fw} data-size={size} data-color={c}>
|
||||
{children}
|
||||
</span>
|
||||
) : (
|
||||
<p data-fw={fw} data-size={size} data-color={c}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
Tooltip: ({ children, label, position }) => (
|
||||
<div data-tooltip={label} data-position={position}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Imports after mocks
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
import useSettingsStore from '../../store/settings';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const setupStore = (version = { version: '1.2.3', timestamp: '20240601' }) => {
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ version }));
|
||||
};
|
||||
|
||||
describe('AboutModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Visibility ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders modal content when isOpen is true', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render modal content when isOpen is false', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={false} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
setupStore();
|
||||
const onClose = vi.fn();
|
||||
render(<AboutModal isOpen={true} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders with the correct modal title', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'About Dispatcharr'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Version string ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('version string', () => {
|
||||
it('displays version with timestamp when both are present', () => {
|
||||
setupStore({ version: '2.0.0', timestamp: '20240601' });
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('v2.0.0-20240601')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays version without timestamp when timestamp is null', () => {
|
||||
setupStore({ version: '1.5.0', timestamp: null });
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('v1.5.0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays version without timestamp when timestamp is undefined', () => {
|
||||
setupStore({ version: '1.5.0' });
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('v1.5.0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to v0.0.0 when version is undefined', () => {
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ version: undefined })
|
||||
);
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('v0.0.0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to v0.0.0 when version object has no version field', () => {
|
||||
setupStore({ version: '', timestamp: null });
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('v0.0.0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Logo & branding ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('logo and branding', () => {
|
||||
it('renders the Dispatcharr logo', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
const logo = screen.getByAltText('Dispatcharr');
|
||||
expect(logo).toBeInTheDocument();
|
||||
expect(logo).toHaveAttribute('src', 'mocked-logo.png');
|
||||
});
|
||||
|
||||
it('renders the app name "Dispatcharr"', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('Dispatcharr')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Action buttons ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('action buttons', () => {
|
||||
it('renders the Documentation button with correct href', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('Documentation').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'https://dispatcharr.github.io/Dispatcharr-Docs/'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the Discord button with correct href', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('Discord').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'https://discord.gg/Sp45V5BcxU'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the GitHub button with correct href', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('GitHub').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/Dispatcharr/Dispatcharr'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the Donate button with correct href', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('Donate').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'https://opencollective.com/dispatcharr/contribute'
|
||||
);
|
||||
});
|
||||
|
||||
it('all external buttons open in a new tab', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
const buttons = screen.getAllByTestId('button');
|
||||
buttons.forEach((btn) => {
|
||||
expect(btn).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
});
|
||||
|
||||
it('all external buttons have noopener noreferrer rel', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
const buttons = screen.getAllByTestId('button');
|
||||
buttons.forEach((btn) => {
|
||||
expect(btn).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders 4 action buttons', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getAllByTestId('button')).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Icons ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('icons', () => {
|
||||
it('renders BookOpen icon for Documentation button', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('icon-book-open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders DiscordIcon for Discord button', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('discord-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders GitHubIcon for GitHub button', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('github-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Heart icon for Donate button', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('icon-heart')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Users icon in Contributors section', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('icon-users')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Contributors section ─────────────────────────────────────────────────────
|
||||
|
||||
describe('contributors section', () => {
|
||||
it('renders the Contributors heading', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('Contributors')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the contributors description text', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Dispatcharr is built by the community, for the community/i
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Memorial section ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('memorial section', () => {
|
||||
it('renders the memorial text for Jesse Mann', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByText(/In memory of/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Jesse Mann name in the memorial', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('Jesse Mann')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the memorial tooltip with correct label', () => {
|
||||
setupStore();
|
||||
render(<AboutModal isOpen={true} onClose={vi.fn()} />);
|
||||
const tooltip = screen
|
||||
.getByText('Jesse Mann')
|
||||
.closest('[data-tooltip="Remembering Jesse Mann"]');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
863
frontend/src/components/__tests__/PluginDetailPanel.test.jsx
Normal file
863
frontend/src/components/__tests__/PluginDetailPanel.test.jsx
Normal file
|
|
@ -0,0 +1,863 @@
|
|||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import PluginDetailPanel from '../PluginDetailPanel';
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../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');
|
||||
});
|
||||
});
|
||||
});
|
||||
266
frontend/src/components/__tests__/PluginWarnings.test.jsx
Normal file
266
frontend/src/components/__tests__/PluginWarnings.test.jsx
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
PluginSecurityWarning,
|
||||
PluginSupportDisclaimer,
|
||||
PluginDowngradeWarning,
|
||||
PluginInfoNote,
|
||||
PluginRestartWarning,
|
||||
} from '../PluginWarnings';
|
||||
|
||||
// ── Image mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../images/logo.png', () => ({ default: 'mocked-logo.png' }));
|
||||
|
||||
// ── lucide-react mock ──────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
AlertTriangle: ({ size }) => (
|
||||
<svg data-testid="icon-alert-triangle" data-size={size} />
|
||||
),
|
||||
Info: ({ size }) => <svg data-testid="icon-info" data-size={size} />,
|
||||
OctagonAlert: ({ size }) => (
|
||||
<svg data-testid="icon-octagon-alert" data-size={size} />
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Mantine core mock ──────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Box: ({ children, style }) => <div style={style}>{children}</div>,
|
||||
Text: ({ children, size, style }) => (
|
||||
<p data-size={size} style={style}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
}));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PluginWarnings', () => {
|
||||
// ── PluginSecurityWarning ────────────────────────────────────────────────────
|
||||
|
||||
describe('PluginSecurityWarning', () => {
|
||||
it('renders children text', () => {
|
||||
render(
|
||||
<PluginSecurityWarning>This plugin is dangerous</PluginSecurityWarning>
|
||||
);
|
||||
expect(screen.getByText('This plugin is dangerous')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the OctagonAlert icon', () => {
|
||||
render(<PluginSecurityWarning>Warning</PluginSecurityWarning>);
|
||||
expect(screen.getByTestId('icon-octagon-alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render AlertTriangle or Info icons', () => {
|
||||
render(<PluginSecurityWarning>Warning</PluginSecurityWarning>);
|
||||
expect(
|
||||
screen.queryByTestId('icon-alert-triangle')
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('icon-info')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders different children correctly', () => {
|
||||
render(
|
||||
<PluginSecurityWarning>
|
||||
<strong>Critical</strong> security issue
|
||||
</PluginSecurityWarning>
|
||||
);
|
||||
expect(screen.getByText('Critical')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── PluginSupportDisclaimer ──────────────────────────────────────────────────
|
||||
|
||||
describe('PluginSupportDisclaimer', () => {
|
||||
it('renders the disclaimer text', () => {
|
||||
render(<PluginSupportDisclaimer />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Dispatcharr community support cannot assist with third-party plugin issues/i
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mentions plugin Discord thread', () => {
|
||||
render(<PluginSupportDisclaimer />);
|
||||
expect(
|
||||
screen.getByText(/use the plugin.*Discord thread/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mentions submitting an issue on the plugin repository', () => {
|
||||
render(<PluginSupportDisclaimer />);
|
||||
expect(
|
||||
screen.getByText(/submit an issue.*on the plugin.*repository/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Dispatcharr logo image', () => {
|
||||
render(<PluginSupportDisclaimer />);
|
||||
const logo = screen.getByAltText('Dispatcharr');
|
||||
expect(logo).toBeInTheDocument();
|
||||
expect(logo).toHaveAttribute('src', 'mocked-logo.png');
|
||||
});
|
||||
|
||||
it('renders logo as non-draggable', () => {
|
||||
render(<PluginSupportDisclaimer />);
|
||||
const logo = screen.getByAltText('Dispatcharr');
|
||||
expect(logo).toHaveAttribute('draggable', 'false');
|
||||
});
|
||||
|
||||
it('does not render any lucide icons', () => {
|
||||
render(<PluginSupportDisclaimer />);
|
||||
expect(
|
||||
screen.queryByTestId('icon-octagon-alert')
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('icon-alert-triangle')
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('icon-info')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── PluginDowngradeWarning ───────────────────────────────────────────────────
|
||||
|
||||
describe('PluginDowngradeWarning', () => {
|
||||
it('renders children text', () => {
|
||||
render(
|
||||
<PluginDowngradeWarning>
|
||||
Downgrading may break things
|
||||
</PluginDowngradeWarning>
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Downgrading may break things')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the AlertTriangle icon', () => {
|
||||
render(<PluginDowngradeWarning>Caution</PluginDowngradeWarning>);
|
||||
expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render OctagonAlert or Info icons', () => {
|
||||
render(<PluginDowngradeWarning>Caution</PluginDowngradeWarning>);
|
||||
expect(
|
||||
screen.queryByTestId('icon-octagon-alert')
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('icon-info')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders JSX children correctly', () => {
|
||||
render(
|
||||
<PluginDowngradeWarning>
|
||||
<span data-testid="inner">Inner content</span>
|
||||
</PluginDowngradeWarning>
|
||||
);
|
||||
expect(screen.getByTestId('inner')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── PluginInfoNote ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('PluginInfoNote', () => {
|
||||
it('renders children text', () => {
|
||||
render(<PluginInfoNote>This is an informational note.</PluginInfoNote>);
|
||||
expect(
|
||||
screen.getByText('This is an informational note.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Info icon', () => {
|
||||
render(<PluginInfoNote>Note</PluginInfoNote>);
|
||||
expect(screen.getByTestId('icon-info')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render OctagonAlert or AlertTriangle icons', () => {
|
||||
render(<PluginInfoNote>Note</PluginInfoNote>);
|
||||
expect(
|
||||
screen.queryByTestId('icon-octagon-alert')
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('icon-alert-triangle')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders JSX children correctly', () => {
|
||||
render(
|
||||
<PluginInfoNote>
|
||||
<span data-testid="info-child">details</span>
|
||||
</PluginInfoNote>
|
||||
);
|
||||
expect(screen.getByTestId('info-child')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── PluginRestartWarning ─────────────────────────────────────────────────────
|
||||
|
||||
describe('PluginRestartWarning', () => {
|
||||
it('renders the restart warning text', () => {
|
||||
render(<PluginRestartWarning />);
|
||||
expect(
|
||||
screen.getByText(/Importing a plugin may briefly restart the backend/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mentions temporary disconnect', () => {
|
||||
render(<PluginRestartWarning />);
|
||||
expect(
|
||||
screen.getByText(/you might see a.*temporary disconnect/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('mentions automatic reconnect', () => {
|
||||
render(<PluginRestartWarning />);
|
||||
expect(
|
||||
screen.getByText(/the app will.*reconnect automatically/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the AlertTriangle icon', () => {
|
||||
render(<PluginRestartWarning />);
|
||||
expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render OctagonAlert or Info icons', () => {
|
||||
render(<PluginRestartWarning />);
|
||||
expect(
|
||||
screen.queryByTestId('icon-octagon-alert')
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('icon-info')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Shared layout structure ──────────────────────────────────────────────────
|
||||
|
||||
describe('shared layout structure', () => {
|
||||
it('PluginSecurityWarning renders xs Text', () => {
|
||||
render(<PluginSecurityWarning>msg</PluginSecurityWarning>);
|
||||
expect(screen.getByText('msg')).toHaveAttribute('data-size', 'xs');
|
||||
});
|
||||
|
||||
it('PluginSupportDisclaimer renders xs Text', () => {
|
||||
render(<PluginSupportDisclaimer />);
|
||||
const text = screen.getByText(
|
||||
/Dispatcharr community support cannot assist/i
|
||||
);
|
||||
expect(text).toHaveAttribute('data-size', 'xs');
|
||||
});
|
||||
|
||||
it('PluginDowngradeWarning renders xs Text', () => {
|
||||
render(<PluginDowngradeWarning>msg</PluginDowngradeWarning>);
|
||||
expect(screen.getByText('msg')).toHaveAttribute('data-size', 'xs');
|
||||
});
|
||||
|
||||
it('PluginInfoNote renders xs Text', () => {
|
||||
render(<PluginInfoNote>msg</PluginInfoNote>);
|
||||
expect(screen.getByText('msg')).toHaveAttribute('data-size', 'xs');
|
||||
});
|
||||
|
||||
it('PluginRestartWarning renders xs Text', () => {
|
||||
render(<PluginRestartWarning />);
|
||||
expect(screen.getByText(/Importing a plugin/i)).toHaveAttribute(
|
||||
'data-size',
|
||||
'xs'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 />);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
1117
frontend/src/components/backups/__tests__/BackupManager.test.jsx
Normal file
1117
frontend/src/components/backups/__tests__/BackupManager.test.jsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1324
frontend/src/components/cards/__tests__/AvailablePluginCard.test.jsx
Normal file
1324
frontend/src/components/cards/__tests__/AvailablePluginCard.test.jsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,592 @@
|
|||
import { render, screen, within } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import ConnectionSecurityPanel from '../ConnectionSecurityPanel';
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Badge: ({ children, color, variant, size }) => (
|
||||
<span
|
||||
data-testid="badge"
|
||||
data-color={color}
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Group: ({ children, gap, align }) => (
|
||||
<div data-gap={gap} data-align={align}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Paper: ({ children, p, withBorder }) => (
|
||||
<div data-testid="paper" data-p={p} data-with-border={String(withBorder)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SimpleGrid: ({ children, spacing }) => (
|
||||
<div data-testid="simple-grid" data-spacing={spacing}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children, gap }) => <div data-gap={gap}>{children}</div>,
|
||||
Text: ({ children, size, c, fw }) => (
|
||||
<span data-size={size} data-color={c} data-fw={fw}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Tooltip: ({ children, label }) => (
|
||||
<div data-testid="tooltip" data-tooltip={label}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import useSettingsStore from '../../../../store/settings.jsx';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const setupStore = (environment = {}) => {
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ environment }));
|
||||
};
|
||||
|
||||
/** Find the service card (Paper) whose subtree contains a Text matching `name` */
|
||||
const getServiceCard = (name) => {
|
||||
const papers = screen.getAllByTestId('paper');
|
||||
return papers.find((p) => within(p).queryByText(name));
|
||||
};
|
||||
|
||||
/** Get all badges within a named service card */
|
||||
const getBadgesIn = (serviceName) =>
|
||||
within(getServiceCard(serviceName)).getAllByTestId('badge');
|
||||
|
||||
/** Get all tooltips within a named service card */
|
||||
const getTooltipsIn = (serviceName) =>
|
||||
within(getServiceCard(serviceName)).getAllByTestId('tooltip');
|
||||
|
||||
/** Find a badge by its exact text content within a service card */
|
||||
const getBadgeByText = (serviceName, text) =>
|
||||
getBadgesIn(serviceName).find((b) => b.textContent === text);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ConnectionSecurityPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Top-level rendering ──────────────────────────────────────────────────────
|
||||
|
||||
describe('top-level rendering', () => {
|
||||
it('renders the introductory description text', () => {
|
||||
setupStore();
|
||||
render(<ConnectionSecurityPanel />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Encrypt connections to Redis and PostgreSQL using environment variables/i
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders both Redis and PostgreSQL service cards', () => {
|
||||
setupStore();
|
||||
render(<ConnectionSecurityPanel />);
|
||||
expect(getServiceCard('Redis')).toBeTruthy();
|
||||
expect(getServiceCard('PostgreSQL')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── RedisStatus — TLS disabled ───────────────────────────────────────────────
|
||||
|
||||
describe('RedisStatus — TLS disabled (default)', () => {
|
||||
beforeEach(() => {
|
||||
setupStore({ redis_tls: { enabled: false } });
|
||||
render(<ConnectionSecurityPanel />);
|
||||
});
|
||||
|
||||
it('renders all three option labels', () => {
|
||||
const card = getServiceCard('Redis');
|
||||
expect(within(card).getByText('Encryption')).toBeInTheDocument();
|
||||
expect(within(card).getByText('Server Verification')).toBeInTheDocument();
|
||||
expect(within(card).getByText('Mutual TLS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Encryption badge as "Disabled" with gray color', () => {
|
||||
const badge = getBadgeByText('Redis', 'Disabled');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.dataset.color).toBe('gray');
|
||||
});
|
||||
|
||||
it('shows Server Verification badge as "Off" with gray color', () => {
|
||||
const card = getServiceCard('Redis');
|
||||
const offBadge = within(card)
|
||||
.getAllByTestId('badge')
|
||||
.find((b) => b.textContent === 'Off');
|
||||
expect(offBadge).toBeTruthy();
|
||||
expect(offBadge.dataset.color).toBe('gray');
|
||||
});
|
||||
|
||||
it('shows Mutual TLS badge as "Inactive" with gray color', () => {
|
||||
const badge = getBadgeByText('Redis', 'Inactive');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.dataset.color).toBe('gray');
|
||||
});
|
||||
|
||||
it('shows "not encrypted" tooltip for Encryption', () => {
|
||||
const tooltips = getTooltipsIn('Redis');
|
||||
expect(
|
||||
tooltips.find(
|
||||
(t) => t.dataset.tooltip === 'The connection is not encrypted'
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows "Verification is not active" tooltip for Server Verification', () => {
|
||||
const tooltips = getTooltipsIn('Redis');
|
||||
expect(
|
||||
tooltips.find((t) =>
|
||||
t.dataset.tooltip?.includes('Verification is not active')
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows "Mutual authentication is not active" tooltip for mTLS', () => {
|
||||
const tooltips = getTooltipsIn('Redis');
|
||||
expect(
|
||||
tooltips.find((t) =>
|
||||
t.dataset.tooltip?.includes('Mutual authentication is not active')
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── RedisStatus — TLS enabled, verify=true, mtls=false ──────────────────────
|
||||
|
||||
describe('RedisStatus — TLS enabled, verify=true, mtls=false', () => {
|
||||
beforeEach(() => {
|
||||
setupStore({ redis_tls: { enabled: true, verify: true, mtls: false } });
|
||||
render(<ConnectionSecurityPanel />);
|
||||
});
|
||||
|
||||
it('shows Encryption badge as "Enabled" with green color', () => {
|
||||
const badge = getBadgeByText('Redis', 'Enabled');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.dataset.color).toBe('green');
|
||||
});
|
||||
|
||||
it('shows Server Verification badge as "On" with green color', () => {
|
||||
const card = getServiceCard('Redis');
|
||||
const onBadge = within(card)
|
||||
.getAllByTestId('badge')
|
||||
.find((b) => b.textContent === 'On');
|
||||
expect(onBadge).toBeTruthy();
|
||||
expect(onBadge.dataset.color).toBe('green');
|
||||
});
|
||||
|
||||
it('shows Mutual TLS badge as "Inactive" with gray color', () => {
|
||||
const badge = getBadgeByText('Redis', 'Inactive');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.dataset.color).toBe('gray');
|
||||
});
|
||||
|
||||
it('shows "connection is encrypted" tooltip for Encryption', () => {
|
||||
const tooltips = getTooltipsIn('Redis');
|
||||
expect(
|
||||
tooltips.find(
|
||||
(t) => t.dataset.tooltip === 'The connection is encrypted'
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows "server identity is verified" tooltip for Server Verification', () => {
|
||||
const tooltips = getTooltipsIn('Redis');
|
||||
expect(
|
||||
tooltips.find((t) =>
|
||||
t.dataset.tooltip?.includes("server's identity is verified")
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── RedisStatus — TLS enabled, verify=false ──────────────────────────────────
|
||||
|
||||
describe('RedisStatus — TLS enabled, verify=false', () => {
|
||||
beforeEach(() => {
|
||||
setupStore({ redis_tls: { enabled: true, verify: false, mtls: false } });
|
||||
render(<ConnectionSecurityPanel />);
|
||||
});
|
||||
|
||||
it('shows Server Verification badge as "Off" with yellow color', () => {
|
||||
const card = getServiceCard('Redis');
|
||||
const offBadge = within(card)
|
||||
.getAllByTestId('badge')
|
||||
.find((b) => b.textContent === 'Off');
|
||||
expect(offBadge).toBeTruthy();
|
||||
expect(offBadge.dataset.color).toBe('yellow');
|
||||
});
|
||||
|
||||
it('shows "encrypted but server identity is not verified" tooltip', () => {
|
||||
const tooltips = getTooltipsIn('Redis');
|
||||
expect(
|
||||
tooltips.find((t) =>
|
||||
t.dataset.tooltip?.includes('server identity is not verified')
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── RedisStatus — TLS enabled, mtls=true ─────────────────────────────────────
|
||||
|
||||
describe('RedisStatus — TLS enabled, mtls=true', () => {
|
||||
beforeEach(() => {
|
||||
setupStore({ redis_tls: { enabled: true, verify: true, mtls: true } });
|
||||
render(<ConnectionSecurityPanel />);
|
||||
});
|
||||
|
||||
it('shows Mutual TLS badge as "Active" with green color', () => {
|
||||
const badge = getBadgeByText('Redis', 'Active');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.dataset.color).toBe('green');
|
||||
});
|
||||
|
||||
it('shows "Both client and server verify each other" tooltip for mTLS', () => {
|
||||
const tooltips = getTooltipsIn('Redis');
|
||||
expect(
|
||||
tooltips.find((t) =>
|
||||
t.dataset.tooltip?.includes(
|
||||
'Both client and server verify each other'
|
||||
)
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── RedisStatus — redis_tls undefined ────────────────────────────────────────
|
||||
|
||||
describe('RedisStatus — redis_tls undefined', () => {
|
||||
it('falls back to defaults (Disabled/Off/Inactive, all gray)', () => {
|
||||
setupStore({});
|
||||
render(<ConnectionSecurityPanel />);
|
||||
expect(getBadgeByText('Redis', 'Disabled').dataset.color).toBe('gray');
|
||||
expect(
|
||||
within(getServiceCard('Redis'))
|
||||
.getAllByTestId('badge')
|
||||
.find((b) => b.textContent === 'Off').dataset.color
|
||||
).toBe('gray');
|
||||
expect(getBadgeByText('Redis', 'Inactive').dataset.color).toBe('gray');
|
||||
});
|
||||
});
|
||||
|
||||
// ── PostgresStatus — TLS disabled ───────────────────────────────────────────
|
||||
|
||||
describe('PostgresStatus — TLS disabled (default)', () => {
|
||||
beforeEach(() => {
|
||||
setupStore({ postgres_tls: { enabled: false } });
|
||||
render(<ConnectionSecurityPanel />);
|
||||
});
|
||||
|
||||
it('renders all three option labels', () => {
|
||||
const card = getServiceCard('PostgreSQL');
|
||||
expect(within(card).getByText('Encryption')).toBeInTheDocument();
|
||||
expect(within(card).getByText('Verification Mode')).toBeInTheDocument();
|
||||
expect(within(card).getByText('Mutual TLS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Encryption badge as "Disabled" with gray color', () => {
|
||||
const badge = getBadgeByText('PostgreSQL', 'Disabled');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.dataset.color).toBe('gray');
|
||||
});
|
||||
|
||||
it('shows Verification Mode badge as "Off" with gray color', () => {
|
||||
const card = getServiceCard('PostgreSQL');
|
||||
const offBadge = within(card)
|
||||
.getAllByTestId('badge')
|
||||
.find((b) => b.textContent === 'Off');
|
||||
expect(offBadge).toBeTruthy();
|
||||
expect(offBadge.dataset.color).toBe('gray');
|
||||
});
|
||||
|
||||
it('shows Mutual TLS badge as "Inactive" with gray color', () => {
|
||||
const badge = getBadgeByText('PostgreSQL', 'Inactive');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.dataset.color).toBe('gray');
|
||||
});
|
||||
|
||||
it('shows "Verification mode is not active" tooltip when TLS is disabled', () => {
|
||||
const tooltips = getTooltipsIn('PostgreSQL');
|
||||
expect(
|
||||
tooltips.find((t) =>
|
||||
t.dataset.tooltip?.includes('Verification mode is not active')
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── PostgresStatus — ssl_mode=verify-full ───────────────────────────────────
|
||||
|
||||
describe('PostgresStatus — ssl_mode=verify-full', () => {
|
||||
beforeEach(() => {
|
||||
setupStore({
|
||||
postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: false },
|
||||
});
|
||||
render(<ConnectionSecurityPanel />);
|
||||
});
|
||||
|
||||
it('shows Verification Mode badge as "verify-full" with green color', () => {
|
||||
const badge = getBadgeByText('PostgreSQL', 'verify-full');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.dataset.color).toBe('green');
|
||||
});
|
||||
|
||||
it('shows "Server certificate and hostname are both verified" tooltip', () => {
|
||||
const tooltips = getTooltipsIn('PostgreSQL');
|
||||
expect(
|
||||
tooltips.find((t) =>
|
||||
t.dataset.tooltip?.includes(
|
||||
'Server certificate and hostname are both verified'
|
||||
)
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── PostgresStatus — ssl_mode=verify-ca ─────────────────────────────────────
|
||||
|
||||
describe('PostgresStatus — ssl_mode=verify-ca', () => {
|
||||
beforeEach(() => {
|
||||
setupStore({
|
||||
postgres_tls: { enabled: true, ssl_mode: 'verify-ca', mtls: false },
|
||||
});
|
||||
render(<ConnectionSecurityPanel />);
|
||||
});
|
||||
|
||||
it('shows Verification Mode badge as "verify-ca" with yellow color', () => {
|
||||
const badge = getBadgeByText('PostgreSQL', 'verify-ca');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.dataset.color).toBe('yellow');
|
||||
});
|
||||
|
||||
it('shows "hostname is not checked" tooltip', () => {
|
||||
const tooltips = getTooltipsIn('PostgreSQL');
|
||||
expect(
|
||||
tooltips.find((t) =>
|
||||
t.dataset.tooltip?.includes('hostname is not checked')
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── PostgresStatus — ssl_mode=require (other/fallback) ──────────────────────
|
||||
|
||||
describe('PostgresStatus — ssl_mode=require (other)', () => {
|
||||
beforeEach(() => {
|
||||
setupStore({
|
||||
postgres_tls: { enabled: true, ssl_mode: 'require', mtls: false },
|
||||
});
|
||||
render(<ConnectionSecurityPanel />);
|
||||
});
|
||||
|
||||
it('shows Verification Mode badge as "require" with yellow color', () => {
|
||||
const badge = getBadgeByText('PostgreSQL', 'require');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.dataset.color).toBe('yellow');
|
||||
});
|
||||
|
||||
it('shows "encrypted but the server is not verified" tooltip', () => {
|
||||
const tooltips = getTooltipsIn('PostgreSQL');
|
||||
expect(
|
||||
tooltips.find((t) =>
|
||||
t.dataset.tooltip?.includes('server is not verified')
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── PostgresStatus — TLS enabled, ssl_mode absent ───────────────────────────
|
||||
|
||||
describe('PostgresStatus — TLS enabled, ssl_mode absent', () => {
|
||||
it('shows Verification Mode badge as "Off" with gray color', () => {
|
||||
setupStore({ postgres_tls: { enabled: true, mtls: false } });
|
||||
render(<ConnectionSecurityPanel />);
|
||||
const card = getServiceCard('PostgreSQL');
|
||||
const offBadge = within(card)
|
||||
.getAllByTestId('badge')
|
||||
.find((b) => b.textContent === 'Off');
|
||||
expect(offBadge).toBeTruthy();
|
||||
expect(offBadge.dataset.color).toBe('gray');
|
||||
});
|
||||
});
|
||||
|
||||
// ── PostgresStatus — mtls=true ───────────────────────────────────────────────
|
||||
|
||||
describe('PostgresStatus — mtls=true', () => {
|
||||
beforeEach(() => {
|
||||
setupStore({
|
||||
postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: true },
|
||||
});
|
||||
render(<ConnectionSecurityPanel />);
|
||||
});
|
||||
|
||||
it('shows Mutual TLS badge as "Active" with green color', () => {
|
||||
const badge = getBadgeByText('PostgreSQL', 'Active');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.dataset.color).toBe('green');
|
||||
});
|
||||
|
||||
it('shows "Both client and server verify each other" tooltip for mTLS', () => {
|
||||
const tooltips = getTooltipsIn('PostgreSQL');
|
||||
expect(
|
||||
tooltips.find((t) =>
|
||||
t.dataset.tooltip?.includes(
|
||||
'Both client and server verify each other'
|
||||
)
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── PostgresStatus — mtls=false while TLS enabled ───────────────────────────
|
||||
|
||||
describe('PostgresStatus — mtls=false while TLS enabled', () => {
|
||||
it('shows Mutual TLS badge as "Inactive" with gray color', () => {
|
||||
setupStore({
|
||||
postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: false },
|
||||
});
|
||||
render(<ConnectionSecurityPanel />);
|
||||
const badge = getBadgeByText('PostgreSQL', 'Inactive');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.dataset.color).toBe('gray');
|
||||
});
|
||||
|
||||
it('shows "Mutual authentication is not active" tooltip', () => {
|
||||
setupStore({
|
||||
postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: false },
|
||||
});
|
||||
render(<ConnectionSecurityPanel />);
|
||||
const tooltips = getTooltipsIn('PostgreSQL');
|
||||
expect(
|
||||
tooltips.find((t) =>
|
||||
t.dataset.tooltip?.includes('Mutual authentication is not active')
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── PostgresStatus — postgres_tls undefined ──────────────────────────────────
|
||||
|
||||
describe('PostgresStatus — postgres_tls undefined', () => {
|
||||
it('falls back to all defaults (Disabled/Off/Inactive, all gray)', () => {
|
||||
setupStore({});
|
||||
render(<ConnectionSecurityPanel />);
|
||||
expect(getBadgeByText('PostgreSQL', 'Disabled').dataset.color).toBe(
|
||||
'gray'
|
||||
);
|
||||
expect(
|
||||
within(getServiceCard('PostgreSQL'))
|
||||
.getAllByTestId('badge')
|
||||
.find((b) => b.textContent === 'Off').dataset.color
|
||||
).toBe('gray');
|
||||
expect(getBadgeByText('PostgreSQL', 'Inactive').dataset.color).toBe(
|
||||
'gray'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Both services — independent state ────────────────────────────────────────
|
||||
|
||||
describe('both services — independent badge state', () => {
|
||||
it('Redis and PostgreSQL badges reflect their own configs independently', () => {
|
||||
setupStore({
|
||||
redis_tls: { enabled: true, verify: false, mtls: true },
|
||||
postgres_tls: { enabled: false },
|
||||
});
|
||||
render(<ConnectionSecurityPanel />);
|
||||
|
||||
// Redis: enabled → green Enabled, verify=false → yellow Off, mtls=true → green Active
|
||||
expect(getBadgeByText('Redis', 'Enabled').dataset.color).toBe('green');
|
||||
expect(
|
||||
within(getServiceCard('Redis'))
|
||||
.getAllByTestId('badge')
|
||||
.find((b) => b.textContent === 'Off').dataset.color
|
||||
).toBe('yellow');
|
||||
expect(getBadgeByText('Redis', 'Active').dataset.color).toBe('green');
|
||||
|
||||
// Postgres: disabled → all gray/Off/Inactive
|
||||
expect(getBadgeByText('PostgreSQL', 'Disabled').dataset.color).toBe(
|
||||
'gray'
|
||||
);
|
||||
expect(
|
||||
within(getServiceCard('PostgreSQL'))
|
||||
.getAllByTestId('badge')
|
||||
.find((b) => b.textContent === 'Off').dataset.color
|
||||
).toBe('gray');
|
||||
expect(getBadgeByText('PostgreSQL', 'Inactive').dataset.color).toBe(
|
||||
'gray'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── TlsOption description text ────────────────────────────────────────────────
|
||||
|
||||
describe('TlsOption description text', () => {
|
||||
beforeEach(() => {
|
||||
setupStore({
|
||||
redis_tls: { enabled: true, verify: true, mtls: true },
|
||||
postgres_tls: { enabled: true, ssl_mode: 'verify-full', mtls: true },
|
||||
});
|
||||
render(<ConnectionSecurityPanel />);
|
||||
});
|
||||
|
||||
it('renders Redis Encryption description', () => {
|
||||
expect(
|
||||
within(getServiceCard('Redis')).getByText(
|
||||
'Encrypt traffic between Dispatcharr and Redis.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Redis Server Verification description', () => {
|
||||
expect(
|
||||
within(getServiceCard('Redis')).getByText(
|
||||
"Verify the Redis server's identity using a CA certificate."
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Redis Mutual TLS description', () => {
|
||||
expect(
|
||||
within(getServiceCard('Redis')).getByText(
|
||||
'Authenticate Dispatcharr to Redis using a client certificate.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders PostgreSQL Encryption description', () => {
|
||||
expect(
|
||||
within(getServiceCard('PostgreSQL')).getByText(
|
||||
'Encrypt traffic between Dispatcharr and PostgreSQL.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders PostgreSQL Verification Mode description', () => {
|
||||
expect(
|
||||
within(getServiceCard('PostgreSQL')).getByText(
|
||||
"How strictly to verify the PostgreSQL server's identity."
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders PostgreSQL Mutual TLS description', () => {
|
||||
expect(
|
||||
within(getServiceCard('PostgreSQL')).getByText(
|
||||
'Authenticate Dispatcharr to PostgreSQL using a client certificate.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import EpgSettingsForm from '../EpgSettingsForm';
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── @mantine/form mock ────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/form', () => ({ useForm: vi.fn() }));
|
||||
|
||||
// ── @mantine/core mock ────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Alert: ({ title, color, variant }) => (
|
||||
<div data-testid="alert" data-color={color} data-variant={variant}>
|
||||
{title}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, type, disabled }) => (
|
||||
<button type={type} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Flex: ({ children, justify }) => <div data-justify={justify}>{children}</div>,
|
||||
NumberInput: ({ label, description, min, max, value, onChange }) => (
|
||||
<div>
|
||||
{label && <label data-testid="number-input-label">{label}</label>}
|
||||
{description && (
|
||||
<span data-testid="number-input-description">{description}</span>
|
||||
)}
|
||||
<input
|
||||
data-testid="number-input"
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value ?? 0}
|
||||
onChange={(e) => onChange?.(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children, gap }) => <div data-gap={gap}>{children}</div>,
|
||||
Text: ({ children, size, c }) => (
|
||||
<span data-testid="text" data-size={size} data-color={c}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── SettingsUtils mock ────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({
|
||||
getChangedSettings: vi.fn(),
|
||||
parseSettings: vi.fn(),
|
||||
saveChangedSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── EpgSettingsFormUtils mock ──────────────────────────────────────────────────
|
||||
vi.mock('../../../../utils/forms/settings/EpgSettingsFormUtils.js', () => ({
|
||||
getEpgSettingsFormInitialValues: vi.fn(() => ({
|
||||
xmltv_prev_days_override: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Imports after mocks
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
import useSettingsStore from '../../../../store/settings.jsx';
|
||||
import { useForm } from '@mantine/form';
|
||||
import * as SettingsUtils from '../../../../utils/pages/SettingsUtils.js';
|
||||
import { getEpgSettingsFormInitialValues } from '../../../../utils/forms/settings/EpgSettingsFormUtils.js';
|
||||
import { EPG_SETTINGS_OPTIONS } from '../../../../constants.js';
|
||||
|
||||
// ── Form mock factory ─────────────────────────────────────────────────────────
|
||||
|
||||
let mockForm;
|
||||
|
||||
const createMockForm = (initialValues = { xmltv_prev_days_override: 0 }) => {
|
||||
const state = { ...initialValues };
|
||||
return {
|
||||
values: state,
|
||||
setFieldValue: vi.fn((field, value) => {
|
||||
state[field] = value;
|
||||
}),
|
||||
getInputProps: vi.fn((field) => ({
|
||||
value: state[field],
|
||||
onChange: vi.fn((val) => {
|
||||
state[field] = val;
|
||||
}),
|
||||
})),
|
||||
getValues: vi.fn(() => ({ ...state })),
|
||||
onSubmit: vi.fn((handler) => (e) => {
|
||||
e?.preventDefault?.();
|
||||
return handler();
|
||||
}),
|
||||
submitting: false,
|
||||
};
|
||||
};
|
||||
|
||||
// ── Settings factories ────────────────────────────────────────────────────────
|
||||
|
||||
const makeSettings = (epgValue = {}) => ({
|
||||
epg_settings: { id: 1, value: epgValue },
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('EpgSettingsForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockForm = createMockForm();
|
||||
vi.mocked(useForm).mockReturnValue(mockForm);
|
||||
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ settings: null })
|
||||
);
|
||||
|
||||
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({
|
||||
xmltv_prev_days_override: 0,
|
||||
});
|
||||
vi.mocked(SettingsUtils.getChangedSettings).mockReturnValue({});
|
||||
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(screen.getByTestId('number-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the NumberInput label from EPG_SETTINGS_OPTIONS', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(
|
||||
screen.getByText(EPG_SETTINGS_OPTIONS.xmltv_prev_days_override.label)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the NumberInput description from EPG_SETTINGS_OPTIONS', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
EPG_SETTINGS_OPTIONS.xmltv_prev_days_override.description
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the disclaimer text about per-user defaults', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Per-user defaults and URL parameters still override this global value/i
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the EPG channel matching hint', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/EPG channel matching options are configured from the Channels page/i
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a Save button of type="submit"', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
const btn = screen.getByText('Save');
|
||||
expect(btn).toBeInTheDocument();
|
||||
expect(btn).toHaveAttribute('type', 'submit');
|
||||
});
|
||||
|
||||
it('does not show the "Saved Successfully" alert initially', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('NumberInput has min=0', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(screen.getByTestId('number-input')).toHaveAttribute('min', '0');
|
||||
});
|
||||
|
||||
it('NumberInput has max=30', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(screen.getByTestId('number-input')).toHaveAttribute('max', '30');
|
||||
});
|
||||
|
||||
it('NumberInput starts with value 0 from initial form values', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(screen.getByTestId('number-input')).toHaveValue(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Initialization ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('initialization', () => {
|
||||
it('calls getEpgSettingsFormInitialValues to seed useForm', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(getEpgSettingsFormInitialValues).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes initial values from getEpgSettingsFormInitialValues to useForm', () => {
|
||||
vi.mocked(getEpgSettingsFormInitialValues).mockReturnValue({
|
||||
xmltv_prev_days_override: 5,
|
||||
});
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(vi.mocked(useForm)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialValues: { xmltv_prev_days_override: 5 },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('calls useForm with mode="controlled"', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(vi.mocked(useForm)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: 'controlled' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Settings effect ────────────────────────────────────────────────────────
|
||||
|
||||
describe('settings effect', () => {
|
||||
it('calls parseSettings when settings are provided', () => {
|
||||
const settings = makeSettings({ xmltv_prev_days_override: 7 });
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ settings })
|
||||
);
|
||||
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({
|
||||
xmltv_prev_days_override: 7,
|
||||
});
|
||||
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
|
||||
expect(SettingsUtils.parseSettings).toHaveBeenCalledWith(settings);
|
||||
});
|
||||
|
||||
it('calls setFieldValue with parsed xmltv_prev_days_override', () => {
|
||||
const settings = makeSettings({ xmltv_prev_days_override: 14 });
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ settings })
|
||||
);
|
||||
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({
|
||||
xmltv_prev_days_override: 14,
|
||||
});
|
||||
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
|
||||
expect(mockForm.setFieldValue).toHaveBeenCalledWith(
|
||||
'xmltv_prev_days_override',
|
||||
14
|
||||
);
|
||||
});
|
||||
|
||||
it('calls setFieldValue with 0 when parsed value is undefined', () => {
|
||||
const settings = makeSettings({});
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ settings })
|
||||
);
|
||||
vi.mocked(SettingsUtils.parseSettings).mockReturnValue({});
|
||||
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
|
||||
expect(mockForm.setFieldValue).toHaveBeenCalledWith(
|
||||
'xmltv_prev_days_override',
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call parseSettings when settings is null', () => {
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ settings: null })
|
||||
);
|
||||
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
|
||||
expect(SettingsUtils.parseSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── active prop effect ─────────────────────────────────────────────────────
|
||||
|
||||
describe('active prop effect', () => {
|
||||
it('resets the saved alert when active changes to false', async () => {
|
||||
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
|
||||
const { rerender } = render(<EpgSettingsForm active={true} />);
|
||||
|
||||
// Trigger a successful save to get saved=true
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Switching active to false should dismiss the alert
|
||||
rerender(<EpgSettingsForm active={false} />);
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show alert when active starts as false', () => {
|
||||
render(<EpgSettingsForm active={false} />);
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Submit — success ───────────────────────────────────────────────────────
|
||||
|
||||
describe('submit — success', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('calls getChangedSettings with form values and settings on submit', async () => {
|
||||
const settings = makeSettings({ xmltv_prev_days_override: 3 });
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ settings })
|
||||
);
|
||||
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
// Set the value after render so the settings useEffect (which resets the
|
||||
// field to the parseSettings result) has already run and won't overwrite it.
|
||||
mockForm.values.xmltv_prev_days_override = 5;
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(SettingsUtils.getChangedSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ xmltv_prev_days_override: 5 }),
|
||||
settings
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls saveChangedSettings with settings and changedSettings on submit', async () => {
|
||||
const settings = makeSettings({ xmltv_prev_days_override: 3 });
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ settings })
|
||||
);
|
||||
const changed = { xmltv_prev_days_override: 7 };
|
||||
vi.mocked(SettingsUtils.getChangedSettings).mockReturnValue(changed);
|
||||
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(SettingsUtils.saveChangedSettings).toHaveBeenCalledWith(
|
||||
settings,
|
||||
changed
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Saved Successfully" alert after a successful save', async () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Saved Successfully')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears saved state before re-submitting', async () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
|
||||
// First save
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Second save — saved resets to false then true again
|
||||
vi.mocked(SettingsUtils.saveChangedSettings).mockResolvedValue(undefined);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Submit — error ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('submit — error', () => {
|
||||
it('does not show alert when saveChangedSettings throws', async () => {
|
||||
vi.mocked(SettingsUtils.saveChangedSettings).mockRejectedValue(
|
||||
new Error('network error')
|
||||
);
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(SettingsUtils.saveChangedSettings).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not throw when saveChangedSettings rejects', async () => {
|
||||
vi.mocked(SettingsUtils.saveChangedSettings).mockRejectedValue(
|
||||
new Error('fail')
|
||||
);
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
|
||||
await expect(
|
||||
waitFor(() => fireEvent.click(screen.getByText('Save')))
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getInputProps wiring ───────────────────────────────────────────────────
|
||||
|
||||
describe('getInputProps wiring', () => {
|
||||
it('calls form.getInputProps with xmltv_prev_days_override', () => {
|
||||
render(<EpgSettingsForm active={true} />);
|
||||
expect(mockForm.getInputProps).toHaveBeenCalledWith(
|
||||
'xmltv_prev_days_override'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
474
frontend/src/utils/components/__tests__/pluginUtils.test.js
Normal file
474
frontend/src/utils/components/__tests__/pluginUtils.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
61
frontend/src/utils/components/backups/BackupManagerUtils.js
Normal file
61
frontend/src/utils/components/backups/BackupManagerUtils.js
Normal 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);
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
99
frontend/src/utils/components/pluginUtils.js
Normal file
99
frontend/src/utils/components/pluginUtils.js
Normal 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 };
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue