This commit is contained in:
SergeantPanda 2026-07-17 13:57:34 +00:00
commit 540f3d9bed
24 changed files with 5820 additions and 745 deletions

View file

@ -1,8 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
import {
Modal,
TextInput,
@ -13,27 +10,14 @@ import {
Stack,
Checkbox,
} from '@mantine/core';
const BUILT_IN_COMMANDS = [
{ value: 'ffmpeg', label: 'FFmpeg' },
{ value: '__custom__', label: 'Custom…' },
];
const COMMAND_EXAMPLES = {
ffmpeg:
'-i pipe:0 -c:v libx264 -b:v 2000k -vf scale=-2:720 -c:a copy -f mpegts pipe:1',
};
const toCommandSelection = (command) =>
BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__')
? command
: '__custom__';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
command: Yup.string().required('Command is required'),
parameters: Yup.string(),
});
import {
addOutputProfile,
BUILT_IN_COMMANDS,
COMMAND_EXAMPLES,
getResolver,
toCommandSelection,
updateOutputProfile,
} from '../../utils/forms/OutputProfileUtils';
const OutputProfile = ({ profile = null, isOpen, onClose }) => {
const [commandSelection, setCommandSelection] = useState('ffmpeg');
@ -57,7 +41,7 @@ const OutputProfile = ({ profile = null, isOpen, onClose }) => {
watch,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
resolver: getResolver(),
});
useEffect(() => {
@ -67,9 +51,9 @@ const OutputProfile = ({ profile = null, isOpen, onClose }) => {
const onSubmit = async (values) => {
if (profile?.id) {
await API.updateOutputProfile({ id: profile.id, ...values });
await updateOutputProfile({ id: profile.id, ...values });
} else {
await API.addOutputProfile(values);
await addOutputProfile(values);
}
reset();
onClose();

View file

@ -1,20 +1,13 @@
import React, { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
import { Button, Flex, Modal, TextInput } from '@mantine/core';
import {
getResolver,
updateServerGroup,
addServerGroup,
} from '../../utils/forms/ServerGroupUtils';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
});
const ServerGroupForm = ({
serverGroup = null,
isOpen,
onClose,
onSaved,
}) => {
const ServerGroupForm = ({ serverGroup = null, isOpen, onClose, onSaved }) => {
const defaultValues = useMemo(
() => ({
name: serverGroup?.name || '',
@ -29,16 +22,13 @@ const ServerGroupForm = ({
reset,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
resolver: getResolver(),
});
const onSubmit = async (values) => {
let response;
if (serverGroup?.id) {
response = await API.updateServerGroup({ id: serverGroup.id, ...values });
} else {
response = await API.addServerGroup(values);
}
const response = serverGroup?.id
? await updateServerGroup({ id: serverGroup.id, ...values })
: await addServerGroup(values);
if (response) {
onSaved?.(response);

View file

@ -0,0 +1,534 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Module-level form state
const __form = { values: {}, resetSpy: null };
// Utility mocks
vi.mock('../../../utils/forms/OutputProfileUtils', () => ({
BUILT_IN_COMMANDS: [
{ value: 'ffmpeg', label: 'FFmpeg' },
{ value: '__custom__', label: 'Custom…' },
],
COMMAND_EXAMPLES: {
ffmpeg: '-i pipe:0 -c:v libx264 -f mpegts pipe:1',
},
addOutputProfile: vi.fn(),
updateOutputProfile: vi.fn(),
getResolver: vi.fn(() => undefined),
toCommandSelection: vi.fn((cmd) =>
cmd === 'ffmpeg' ? 'ffmpeg' : '__custom__'
),
}));
// react-hook-form
vi.mock('react-hook-form', async () => {
const React = await import('react');
return {
useForm: vi.fn(({ defaultValues } = {}) => {
const [formValues, setFormValues] = React.useState(() => {
const vals = defaultValues || {};
Object.assign(__form.values, vals);
return vals;
});
const updateField = (name, value) => {
__form.values[name] = value;
setFormValues((prev) => ({ ...prev, [name]: value }));
};
const register = (name) => ({
name,
value: __form.values[name] ?? '',
onChange: (e) => updateField(name, e.target.value),
onBlur: () => {},
});
const setValue = (name, value) => updateField(name, value);
const watch = (name) => formValues[name];
const handleSubmit = (onSubmit) => (e) => {
e?.preventDefault?.();
return onSubmit({ ...__form.values });
};
const resetImpl = React.useCallback((newValues) => {
const vals = newValues || defaultValues || {};
Object.assign(__form.values, vals);
setFormValues({ ...vals });
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const resetRef = React.useRef(null);
if (!resetRef.current) {
resetRef.current = vi.fn((...args) => resetImpl(...args));
__form.resetSpy = resetRef.current;
}
return {
register,
handleSubmit,
formState: { errors: {}, isSubmitting: false },
reset: resetRef.current,
setValue,
watch,
};
}),
};
});
// @mantine/core
vi.mock('@mantine/core', () => ({
Button: ({ children, type, disabled }) => (
<button type={type} disabled={disabled}>
{children}
</button>
),
Checkbox: ({ label, checked, onChange }) => (
<div>
<label htmlFor="checkbox-is-active">{label}</label>
<input
id="checkbox-is-active"
data-testid="checkbox-is-active"
type="checkbox"
checked={checked ?? false}
onChange={(e) =>
onChange({ currentTarget: { checked: e.target.checked } })
}
/>
</div>
),
Flex: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Select: ({ label, value, onChange, data, disabled }) => (
<div>
<label>{label}</label>
<select
data-testid={`select-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
>
{(data ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Textarea: ({
label,
name,
value,
onChange,
placeholder,
description,
disabled,
...rest
}) => (
<div>
<label htmlFor={name}>{label}</label>
{description && (
<div data-testid={`desc-${label?.replace(/\s+/g, '-').toLowerCase()}`}>
{description}
</div>
)}
<textarea
id={name}
name={name}
data-testid={`textarea-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
placeholder={placeholder}
onChange={onChange}
disabled={disabled}
{...rest}
/>
</div>
),
TextInput: ({ label, name, value, onChange, error, disabled, ...rest }) => (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
data-testid={`input-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
onChange={onChange}
disabled={disabled}
{...rest}
/>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
}));
// Imports after mocks
import OutputProfile from '../OutputProfile';
import * as OutputProfileUtils from '../../../utils/forms/OutputProfileUtils';
// Shared helpers
const makeProfile = (overrides = {}) => ({
id: 1,
name: 'HD Transcode',
command: 'ffmpeg',
parameters: '-i pipe:0 -c:v copy -f mpegts pipe:1',
is_active: true,
locked: false,
...overrides,
});
const defaultProps = (overrides = {}) => ({
profile: null,
isOpen: true,
onClose: vi.fn(),
...overrides,
});
//
describe('OutputProfile', () => {
beforeEach(() => {
vi.resetAllMocks();
__form.values = {};
__form.resetSpy = null;
vi.mocked(OutputProfileUtils.addOutputProfile).mockResolvedValue(undefined);
vi.mocked(OutputProfileUtils.updateOutputProfile).mockResolvedValue(
undefined
);
vi.mocked(OutputProfileUtils.getResolver).mockReturnValue(undefined);
vi.mocked(OutputProfileUtils.toCommandSelection).mockImplementation(
(cmd) => (cmd === 'ffmpeg' ? 'ffmpeg' : '__custom__')
);
});
// Visibility
describe('visibility', () => {
it('renders the modal when isOpen is true', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render the modal when isOpen is false', () => {
render(<OutputProfile {...defaultProps({ isOpen: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('renders "Output Profile" as the modal title', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Output Profile'
);
});
it('calls onClose when the modal close button is clicked', () => {
const onClose = vi.fn();
render(<OutputProfile {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Form fields
describe('form fields', () => {
it('renders the Name input', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('input-name')).toBeInTheDocument();
});
it('renders the Command select', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('select-command')).toBeInTheDocument();
});
it('renders the Parameters textarea', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('textarea-parameters')).toBeInTheDocument();
});
it('renders the Is Active checkbox', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('checkbox-is-active')).toBeInTheDocument();
});
it('renders the Save button', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('populates the Command select with built-in options', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByText('FFmpeg')).toBeInTheDocument();
expect(screen.getAllByText('Custom…').length).toBeGreaterThan(0);
});
it('does not show Custom Command input when a built-in is selected', () => {
render(<OutputProfile {...defaultProps()} />);
expect(
screen.queryByTestId('input-custom-command')
).not.toBeInTheDocument();
});
});
// Default values
describe('default values', () => {
it('name field is empty for a new profile', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('input-name')).toHaveValue('');
});
it('command select defaults to ffmpeg', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('select-command')).toHaveValue('ffmpeg');
});
it('is_active checkbox is checked by default', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
});
it('parameters field is empty for a new profile', () => {
render(<OutputProfile {...defaultProps()} />);
expect(screen.getByTestId('textarea-parameters')).toHaveValue('');
});
});
// Profile pre-fill
describe('profile pre-fill', () => {
it('pre-fills the name from the profile', () => {
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('input-name')).toHaveValue('HD Transcode');
});
it('pre-fills the parameters from the profile', () => {
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('textarea-parameters')).toHaveValue(
'-i pipe:0 -c:v copy -f mpegts pipe:1'
);
});
it('pre-selects the command from the profile', () => {
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('select-command')).toHaveValue('ffmpeg');
});
it('is_active checkbox is unchecked when profile has is_active: false', () => {
render(
<OutputProfile
{...defaultProps({ profile: makeProfile({ is_active: false }) })}
/>
);
expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
});
it('shows Custom Command input when profile has a custom command', () => {
vi.mocked(OutputProfileUtils.toCommandSelection).mockReturnValue(
'__custom__'
);
render(
<OutputProfile
{...defaultProps({
profile: makeProfile({ command: '/usr/bin/mycmd' }),
})}
/>
);
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
});
});
// Locked profile
describe('locked profile', () => {
it('disables the Name input when profile is locked', () => {
render(
<OutputProfile
{...defaultProps({ profile: makeProfile({ locked: true }) })}
/>
);
expect(screen.getByTestId('input-name')).toBeDisabled();
});
it('disables the Command select when profile is locked', () => {
render(
<OutputProfile
{...defaultProps({ profile: makeProfile({ locked: true }) })}
/>
);
expect(screen.getByTestId('select-command')).toBeDisabled();
});
it('disables the Parameters textarea when profile is locked', () => {
render(
<OutputProfile
{...defaultProps({ profile: makeProfile({ locked: true }) })}
/>
);
expect(screen.getByTestId('textarea-parameters')).toBeDisabled();
});
it('does not disable inputs when profile is not locked', () => {
render(
<OutputProfile
{...defaultProps({ profile: makeProfile({ locked: false }) })}
/>
);
expect(screen.getByTestId('input-name')).not.toBeDisabled();
expect(screen.getByTestId('select-command')).not.toBeDisabled();
expect(screen.getByTestId('textarea-parameters')).not.toBeDisabled();
});
});
// Command selection
describe('command selection', () => {
it('shows Custom Command input when Custom… is selected', () => {
render(<OutputProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: '__custom__' },
});
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
});
it('hides Custom Command input when switching back to a built-in', () => {
render(<OutputProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: '__custom__' },
});
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: 'ffmpeg' },
});
expect(
screen.queryByTestId('input-custom-command')
).not.toBeInTheDocument();
});
it('sets command form value when switching to a built-in', () => {
render(<OutputProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: 'ffmpeg' },
});
expect(__form.values.command).toBe('ffmpeg');
});
it('clears command form value when Custom… is selected', () => {
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: '__custom__' },
});
expect(__form.values.command).toBe('');
});
it('shows parameters example in the description for ffmpeg', () => {
const { container } = render(
<OutputProfile {...defaultProps({ profile: makeProfile() })} />
);
expect(container.textContent).toMatch(/-i pipe:0/);
});
});
// Is Active checkbox
describe('Is Active checkbox', () => {
it('toggles is_active to false when unchecked', () => {
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.click(screen.getByTestId('checkbox-is-active'));
expect(__form.values.is_active).toBe(false);
});
it('toggles is_active to true when checked', () => {
render(
<OutputProfile
{...defaultProps({ profile: makeProfile({ is_active: false }) })}
/>
);
fireEvent.click(screen.getByTestId('checkbox-is-active'));
expect(__form.values.is_active).toBe(true);
});
});
// Form submission
describe('form submission', () => {
it('calls addOutputProfile when submitting a new profile', async () => {
render(<OutputProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-name'), {
target: { value: 'New Profile' },
});
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(OutputProfileUtils.addOutputProfile).toHaveBeenCalledWith(
expect.objectContaining({ name: 'New Profile', command: 'ffmpeg' })
);
});
});
it('does not call updateOutputProfile for a new profile', async () => {
render(<OutputProfile {...defaultProps()} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(OutputProfileUtils.addOutputProfile).toHaveBeenCalled();
});
expect(OutputProfileUtils.updateOutputProfile).not.toHaveBeenCalled();
});
it('calls updateOutputProfile when submitting an existing profile', async () => {
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(OutputProfileUtils.updateOutputProfile).toHaveBeenCalledWith(
expect.objectContaining({ id: 1, name: 'HD Transcode' })
);
});
});
it('does not call addOutputProfile for an existing profile', async () => {
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(OutputProfileUtils.updateOutputProfile).toHaveBeenCalled();
});
expect(OutputProfileUtils.addOutputProfile).not.toHaveBeenCalled();
});
it('calls onClose after successful submission', async () => {
const onClose = vi.fn();
render(<OutputProfile {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('calls reset after successful submission', async () => {
render(<OutputProfile {...defaultProps()} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(__form.resetSpy).toHaveBeenCalled();
});
});
it('passes is_active value to addOutputProfile', async () => {
render(<OutputProfile {...defaultProps()} />);
fireEvent.click(screen.getByTestId('checkbox-is-active')); // uncheck false
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(OutputProfileUtils.addOutputProfile).toHaveBeenCalledWith(
expect.objectContaining({ is_active: false })
);
});
});
});
});

View file

@ -0,0 +1,363 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Module-level form state
const __form = { values: {}, resetSpy: null };
// Utility mocks
vi.mock('../../../utils/forms/ServerGroupUtils', () => ({
addServerGroup: vi.fn(),
updateServerGroup: vi.fn(),
getResolver: vi.fn(() => undefined),
}));
// react-hook-form
vi.mock('react-hook-form', async () => {
const React = await import('react');
return {
useForm: vi.fn(({ defaultValues } = {}) => {
const [_formValues, setFormValues] = React.useState(() => {
const vals = defaultValues || {};
Object.assign(__form.values, vals);
return vals;
});
const updateField = (name, value) => {
__form.values[name] = value;
setFormValues((prev) => ({ ...prev, [name]: value }));
};
const register = (name) => ({
name,
value: __form.values[name] ?? '',
onChange: (e) => updateField(name, e.target.value),
onBlur: () => {},
});
const handleSubmit = (onSubmit) => (e) => {
e?.preventDefault?.();
return onSubmit({ ...__form.values });
};
const resetImpl = React.useCallback((newValues) => {
const vals = newValues || defaultValues || {};
Object.assign(__form.values, vals);
setFormValues({ ...vals });
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const resetRef = React.useRef(null);
if (!resetRef.current) {
resetRef.current = vi.fn((...args) => resetImpl(...args));
__form.resetSpy = resetRef.current;
}
return {
register,
handleSubmit,
formState: { errors: {}, isSubmitting: false },
reset: resetRef.current,
};
}),
};
});
// @mantine/core
vi.mock('@mantine/core', () => ({
Button: ({ children, type, disabled }) => (
<button type={type} disabled={disabled}>
{children}
</button>
),
Flex: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
TextInput: ({
label,
name,
value,
onChange,
error,
description,
...rest
}) => (
<div>
<label htmlFor={name}>{label}</label>
{description && <div>{description}</div>}
<input
id={name}
name={name}
data-testid={`input-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
onChange={onChange}
{...rest}
/>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
}));
// Imports after mocks
import ServerGroupForm from '../ServerGroup';
import * as ServerGroupUtils from '../../../utils/forms/ServerGroupUtils';
// Shared helpers
const makeServerGroup = (overrides = {}) => ({
id: 1,
name: 'US East',
...overrides,
});
const defaultProps = (overrides = {}) => ({
serverGroup: null,
isOpen: true,
onClose: vi.fn(),
onSaved: vi.fn(),
...overrides,
});
//
describe('ServerGroupForm', () => {
beforeEach(() => {
vi.resetAllMocks();
__form.values = {};
__form.resetSpy = null;
vi.mocked(ServerGroupUtils.addServerGroup).mockResolvedValue({
id: 2,
name: 'New Group',
});
vi.mocked(ServerGroupUtils.updateServerGroup).mockResolvedValue({
id: 1,
name: 'Updated',
});
vi.mocked(ServerGroupUtils.getResolver).mockReturnValue(undefined);
});
// Visibility
describe('visibility', () => {
it('renders the form when isOpen is true', () => {
render(<ServerGroupForm {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('renders nothing when isOpen is false', () => {
render(<ServerGroupForm {...defaultProps({ isOpen: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('renders "Server Group" as the modal title', () => {
render(<ServerGroupForm {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Server Group'
);
});
it('calls onClose when the modal close button is clicked', () => {
const onClose = vi.fn();
render(<ServerGroupForm {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Form fields
describe('form fields', () => {
it('renders the Name input', () => {
render(<ServerGroupForm {...defaultProps()} />);
expect(screen.getByTestId('input-name')).toBeInTheDocument();
});
it('renders the Submit button', () => {
render(<ServerGroupForm {...defaultProps()} />);
expect(screen.getByText('Submit')).toBeInTheDocument();
});
});
// Default values
describe('default values', () => {
it('name input is empty when no serverGroup is provided', () => {
render(<ServerGroupForm {...defaultProps()} />);
expect(screen.getByTestId('input-name')).toHaveValue('');
});
it('pre-fills the name from the serverGroup prop', () => {
render(
<ServerGroupForm
{...defaultProps({
serverGroup: makeServerGroup({ name: 'EU West' }),
})}
/>
);
expect(screen.getByTestId('input-name')).toHaveValue('EU West');
});
});
// Create (no id)
describe('create (no serverGroup.id)', () => {
it('calls addServerGroup with the form values', async () => {
render(<ServerGroupForm {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-name'), {
target: { value: 'New Group' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(ServerGroupUtils.addServerGroup).toHaveBeenCalledWith(
expect.objectContaining({ name: 'New Group' })
);
});
});
it('does not call updateServerGroup when creating', async () => {
render(<ServerGroupForm {...defaultProps()} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(ServerGroupUtils.addServerGroup).toHaveBeenCalled();
});
expect(ServerGroupUtils.updateServerGroup).not.toHaveBeenCalled();
});
it('calls onSaved with the API response when response is truthy', async () => {
const onSaved = vi.fn();
const response = { id: 5, name: 'New Group' };
vi.mocked(ServerGroupUtils.addServerGroup).mockResolvedValue(response);
render(<ServerGroupForm {...defaultProps({ onSaved })} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSaved).toHaveBeenCalledWith(response);
});
});
it('does not call onSaved when addServerGroup returns null', async () => {
vi.mocked(ServerGroupUtils.addServerGroup).mockResolvedValue(null);
const onSaved = vi.fn();
render(<ServerGroupForm {...defaultProps({ onSaved })} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(ServerGroupUtils.addServerGroup).toHaveBeenCalled();
});
expect(onSaved).not.toHaveBeenCalled();
});
it('calls onClose after submission regardless of response', async () => {
const onClose = vi.fn();
render(<ServerGroupForm {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('calls reset after submission', async () => {
render(<ServerGroupForm {...defaultProps()} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(__form.resetSpy).toHaveBeenCalled();
});
});
});
// Update (with id)
describe('update (serverGroup with id)', () => {
it('calls updateServerGroup with id and form values', async () => {
render(
<ServerGroupForm
{...defaultProps({
serverGroup: makeServerGroup({ id: 7, name: 'Old Name' }),
})}
/>
);
fireEvent.change(screen.getByTestId('input-name'), {
target: { value: 'New Name' },
});
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(ServerGroupUtils.updateServerGroup).toHaveBeenCalledWith(
expect.objectContaining({ id: 7, name: 'New Name' })
);
});
});
it('does not call addServerGroup when updating', async () => {
render(
<ServerGroupForm
{...defaultProps({ serverGroup: makeServerGroup() })}
/>
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(ServerGroupUtils.updateServerGroup).toHaveBeenCalled();
});
expect(ServerGroupUtils.addServerGroup).not.toHaveBeenCalled();
});
it('calls onSaved with the updated response', async () => {
const updated = { id: 1, name: 'Updated Name' };
vi.mocked(ServerGroupUtils.updateServerGroup).mockResolvedValue(updated);
const onSaved = vi.fn();
render(
<ServerGroupForm
{...defaultProps({ serverGroup: makeServerGroup(), onSaved })}
/>
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSaved).toHaveBeenCalledWith(updated);
});
});
it('does not call onSaved when updateServerGroup returns null', async () => {
vi.mocked(ServerGroupUtils.updateServerGroup).mockResolvedValue(null);
const onSaved = vi.fn();
render(
<ServerGroupForm
{...defaultProps({ serverGroup: makeServerGroup(), onSaved })}
/>
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(ServerGroupUtils.updateServerGroup).toHaveBeenCalled();
});
expect(onSaved).not.toHaveBeenCalled();
});
it('calls onClose after update regardless of response', async () => {
const onClose = vi.fn();
render(
<ServerGroupForm
{...defaultProps({ serverGroup: makeServerGroup(), onClose })}
/>
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
// onSaved optional
describe('onSaved optional', () => {
it('does not throw when onSaved is not provided and response is truthy', async () => {
render(<ServerGroupForm isOpen={true} onClose={vi.fn()} />);
fireEvent.click(screen.getByText('Submit'));
await expect(
waitFor(() =>
expect(ServerGroupUtils.addServerGroup).toHaveBeenCalled()
)
).resolves.not.toThrow();
});
});
});

View file

@ -4,6 +4,7 @@ import {
Stack,
Text,
Radio,
RadioGroup,
NumberInput,
Checkbox,
Group,
@ -101,11 +102,7 @@ const CreateChannelModal = ({
<Divider label="Channel Number" labelPosition="left" />
<Radio.Group
value={mode}
onChange={onModeChange}
label={numberingLabel}
>
<RadioGroup value={mode} onChange={onModeChange} label={numberingLabel}>
<Stack mt="xs" spacing="xs">
<Radio
value="provider"
@ -148,7 +145,7 @@ const CreateChannelModal = ({
}
/>
</Stack>
</Radio.Group>
</RadioGroup>
{mode === customModeValue && (
<NumberInput

View file

@ -0,0 +1,567 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Loader,
Modal,
NumberInput,
Stack,
Text,
Textarea,
TextInput,
} from '@mantine/core';
import { KeyRound, Plus, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react';
import ConfirmationDialog from '../ConfirmationDialog.jsx';
import { usePluginStore } from '../../store/plugins.jsx';
import { showNotification } from '../../utils/notificationUtils.js';
import {
getPluginRepoSettings,
previewPluginRepo,
updatePluginRepoSettings,
} from '../../utils/pages/PluginsUtils.js';
export default function ManageReposModal({ opened, onClose }) {
const repos = usePluginStore((s) => s.repos);
const reposLoading = usePluginStore((s) => s.reposLoading);
const fetchAvailablePlugins = usePluginStore((s) => s.fetchAvailablePlugins);
const refreshRepo = usePluginStore((s) => s.refreshRepo);
const addRepo = usePluginStore((s) => s.addRepo);
const removeRepo = usePluginStore((s) => s.removeRepo);
const updateRepo = usePluginStore((s) => s.updateRepo);
const [refreshInterval, setRefreshInterval] = useState(6);
const [savingInterval, setSavingInterval] = useState(false);
const saveIntervalTimer = useRef(null);
const [editingKeyRepoId, setEditingKeyRepoId] = useState(null);
const [editKeyValue, setEditKeyValue] = useState('');
const [savingKey, setSavingKey] = useState(false);
const [showAddRepo, setShowAddRepo] = useState(false);
const [newRepoUrl, setNewRepoUrl] = useState('');
const [newRepoPublicKey, setNewRepoPublicKey] = useState('');
const [addingRepo, setAddingRepo] = useState(false);
const [gpgKeyFocused, setGpgKeyFocused] = useState(false);
const [repoPreview, setRepoPreview] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const previewTimer = useRef(null);
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
const loadRepoSettings = useCallback(async () => {
const data = await getPluginRepoSettings();
if (data) setRefreshInterval(data.refresh_interval_hours ?? 6);
}, []);
const handleSaveInterval = useCallback((val) => {
const hours = val ?? 0;
setRefreshInterval(hours);
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
saveIntervalTimer.current = setTimeout(async () => {
setSavingInterval(true);
try {
await updatePluginRepoSettings({ refresh_interval_hours: hours });
} catch {
// Error notification handled by API layer
} finally {
setSavingInterval(false);
}
}, 800);
}, []);
// Debounced manifest preview
const fetchPreview = useCallback((url, publicKey) => {
if (previewTimer.current) clearTimeout(previewTimer.current);
if (!url.trim() || !url.match(/^https?:\/\/.+/i)) {
setRepoPreview(null);
setPreviewLoading(false);
return;
}
setPreviewLoading(true);
previewTimer.current = setTimeout(async () => {
const result = await previewPluginRepo(url.trim(), publicKey?.trim());
setRepoPreview(result);
setPreviewLoading(false);
}, 600);
}, []);
const handleAddRepo = useCallback(async () => {
if (!newRepoUrl.trim()) return;
setAddingRepo(true);
try {
await addRepo({
url: newRepoUrl.trim(),
public_key: newRepoPublicKey.trim(),
});
setNewRepoUrl('');
setNewRepoPublicKey('');
setRepoPreview(null);
setShowAddRepo(false);
await fetchAvailablePlugins();
showNotification({
title: 'Added',
message: 'Plugin repo added',
color: 'green',
});
} catch {
// Error notification handled by API layer
} finally {
setAddingRepo(false);
}
}, [newRepoUrl, newRepoPublicKey, addRepo, fetchAvailablePlugins]);
const handleDeleteRepo = useCallback(
async (id) => {
await removeRepo(id);
setDeleteConfirmId(null);
await fetchAvailablePlugins();
showNotification({
title: 'Removed',
message: 'Plugin repo removed',
color: 'green',
});
},
[removeRepo, fetchAvailablePlugins]
);
const handleEditKey = useCallback((repo) => {
setEditingKeyRepoId(repo.id);
setEditKeyValue(repo.public_key || '');
}, []);
const handleSaveKey = useCallback(async () => {
if (editingKeyRepoId == null) return;
setSavingKey(true);
try {
await updateRepo(editingKeyRepoId, { public_key: editKeyValue });
await refreshRepo(editingKeyRepoId);
await fetchAvailablePlugins();
showNotification({
title: 'Updated',
message: 'Public key updated',
color: 'green',
});
setEditingKeyRepoId(null);
setEditKeyValue('');
} catch {
showNotification({
title: 'Error',
message: 'Failed to update key',
color: 'red',
});
} finally {
setSavingKey(false);
}
}, [
editingKeyRepoId,
editKeyValue,
updateRepo,
refreshRepo,
fetchAvailablePlugins,
]);
// Load settings when modal opens
useEffect(() => {
if (opened) loadRepoSettings();
}, [opened, loadRepoSettings]);
// Cleanup any pending timers on unmount
useEffect(() => {
return () => {
if (previewTimer.current) clearTimeout(previewTimer.current);
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
};
}, []);
return (
<>
<Modal
opened={opened}
onClose={onClose}
title={
<Group justify="space-between" align="flex-start" w="100%">
<div style={{ flex: 1 }}>
<Text fw={600}>Plugin Repositories</Text>
<Text size="sm" c="dimmed" mt={4}>
Add third-party plugin repositories or manage existing ones.
Manifests are fetched automatically at the configured interval.
</Text>
</div>
<div style={{ textAlign: 'left', flexShrink: 0 }}>
<Text size="sm" fw={500} mb={2}>
Refresh Interval
</Text>
<NumberInput
value={refreshInterval}
onChange={handleSaveInterval}
min={0}
max={168}
size="xs"
disabled={savingInterval}
w={115}
/>
<Text size="xs" c="dimmed" mt={2}>
Hours, 0 to disable
</Text>
</div>
</Group>
}
centered
size="lg"
styles={{
title: { width: '100%' },
header: { alignItems: 'flex-start' },
}}
>
<Stack gap="md">
{reposLoading && repos.length === 0 && <Loader size="sm" />}
{repos.map((repo) => (
<React.Fragment key={repo.id}>
<Group
justify="space-between"
align="center"
wrap="nowrap"
style={{
padding: '8px 12px',
borderRadius: 8,
backgroundColor: 'var(--mantine-color-dark-6)',
}}
>
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap="xs" align="center">
<Text fw={500} size="sm" lineClamp={1}>
{repo.name}
</Text>
{repo.is_official && (
<Badge
size="xs"
variant="filled"
style={{ backgroundColor: '#14917E' }}
>
Official Repo
</Badge>
)}
{repo.signature_verified === true && (
<Badge
size="xs"
variant="light"
color="green"
leftSection={<ShieldCheck size={10} />}
>
Verified Signature
</Badge>
)}
{repo.signature_verified === false && (
<Badge
size="xs"
variant="light"
color="red"
leftSection={<ShieldAlert size={10} />}
>
Invalid Signature
</Badge>
)}
</Group>
{repo.registry_url ? (
<Text size="xs" c="dimmed" lineClamp={1}>
<a
href={repo.registry_url}
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--mantine-color-blue-4)',
textDecoration: 'none',
}}
>
{repo.registry_url}
</a>
</Text>
) : null}
<Text size="xs" c="dimmed" lineClamp={1}>
{repo.url}
</Text>
{repo.last_fetched && (
<Text
size="xs"
c={
repo.last_fetch_status &&
repo.last_fetch_status !== '200'
? 'red'
: 'dimmed'
}
>
Last fetched:{' '}
{new Date(repo.last_fetched).toLocaleString()}
{repo.last_fetch_status &&
repo.last_fetch_status !== '200'
? ` · ${repo.last_fetch_status}`
: repo.plugin_count != null
? ` · ${repo.plugin_count} plugin${repo.plugin_count !== 1 ? 's' : ''} available`
: ''}
</Text>
)}
</Box>
{!repo.is_official && (
<Stack gap={4} align="center">
<ActionIcon
variant="subtle"
color="gray"
title="Edit public key"
onClick={() => handleEditKey(repo)}
>
<KeyRound size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
title="Remove repo"
onClick={() => setDeleteConfirmId(repo.id)}
>
<Trash2 size={16} />
</ActionIcon>
</Stack>
)}
</Group>
{editingKeyRepoId === repo.id && (
<Stack gap="xs" mt="xs">
<Textarea
placeholder={
'-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nOptional: Paste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
}
value={editKeyValue}
onChange={(e) => setEditKeyValue(e.currentTarget.value)}
size="xs"
minRows={3}
autosize
/>
<Group gap="xs">
<Button
size="xs"
onClick={handleSaveKey}
loading={savingKey}
>
Save Key
</Button>
<Button
size="xs"
variant="subtle"
color="gray"
onClick={() => setEditingKeyRepoId(null)}
>
Cancel
</Button>
</Group>
</Stack>
)}
</React.Fragment>
))}
{!showAddRepo ? (
<Button
variant="light"
leftSection={<Plus size={16} />}
size="sm"
onClick={() => setShowAddRepo(true)}
>
Add Repository
</Button>
) : (
<>
<Text fw={500} size="sm" mt="sm">
Add Repository
</Text>
<Box
style={{
padding: '8px 12px',
borderRadius: 8,
minHeight: 90,
display: 'flex',
alignItems: 'center',
backgroundColor: 'var(--mantine-color-dark-6)',
border:
repoPreview && !previewLoading && !repoPreview.valid
? '1px solid var(--mantine-color-red-7)'
: 'none',
}}
>
{previewLoading ? (
<Group gap="xs" align="center">
<Loader size={14} />
<Text size="xs" c="dimmed">
Checking manifest...
</Text>
</Group>
) : repoPreview ? (
repoPreview.valid ? (
<Box>
<Group gap="xs" align="center">
<Text fw={500} size="sm">
{repoPreview.registry_name}
</Text>
{repoPreview.signature_verified === true && (
<Badge
size="xs"
variant="light"
color="green"
leftSection={<ShieldCheck size={10} />}
>
Verified Signature
</Badge>
)}
{repoPreview.signature_verified === false && (
<>
<Badge
size="xs"
variant="light"
color="gray"
leftSection={<ShieldCheck size={10} />}
>
Signed Manifest
</Badge>
<Text
size="xs"
c="var(--mantine-color-yellow-6)"
fs="italic"
>
Public key required for verification
</Text>
</>
)}
{repoPreview.signature_verified == null && (
<Badge size="xs" variant="light" color="gray">
No Signature
</Badge>
)}
</Group>
{repoPreview.registry_url ? (
<Text size="xs" c="dimmed">
<a
href={repoPreview.registry_url}
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--mantine-color-blue-4)',
textDecoration: 'none',
}}
>
{repoPreview.registry_url}
</a>
</Text>
) : null}
<Text size="xs" c="dimmed" lineClamp={1}>
{newRepoUrl.trim()}
</Text>
<Text size="xs" c="dimmed">
{repoPreview.plugin_count} plugin
{repoPreview.plugin_count !== 1 ? 's' : ''} available
</Text>
</Box>
) : (
<Text size="xs" c="red">
{repoPreview.errors?.join(' ') || 'Invalid manifest'}
</Text>
)
) : (
<Text size="xs" c="yellow">
Third-party repositories are not reviewed by the Dispatcharr
team.
<br />
Adding sources and installing plugins is done at your own
risk.
</Text>
)}
</Box>
<TextInput
placeholder="Repository Manifest URL (ending in .json)"
value={newRepoUrl}
onChange={(e) => {
setNewRepoUrl(e.currentTarget.value);
fetchPreview(e.currentTarget.value, newRepoPublicKey);
}}
size="sm"
/>
<Textarea
placeholder={
gpgKeyFocused
? '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nPaste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
: 'Optional: Paste public GPG key here'
}
value={newRepoPublicKey}
onChange={(e) => {
const value = e.currentTarget.value;
setNewRepoPublicKey(value);
fetchPreview(newRepoUrl, value);
}}
size="sm"
minRows={gpgKeyFocused || newRepoPublicKey ? 4 : 1}
maxRows={8}
autosize
onFocus={() => setGpgKeyFocused(true)}
onBlur={() => {
if (!newRepoPublicKey) setGpgKeyFocused(false);
}}
styles={
repoPreview?.valid &&
repoPreview?.signature_verified === false &&
!newRepoPublicKey.trim()
? {
input: { borderColor: 'var(--mantine-color-yellow-6)' },
}
: undefined
}
/>
<Group gap="xs" justify="flex-end">
<Button
variant="subtle"
color="gray"
size="sm"
onClick={() => {
setShowAddRepo(false);
setNewRepoUrl('');
setNewRepoPublicKey('');
setRepoPreview(null);
}}
>
Cancel
</Button>
<Button
onClick={handleAddRepo}
loading={addingRepo}
disabled={!newRepoUrl.trim()}
leftSection={<Plus size={16} />}
size="sm"
>
Add Repo
</Button>
</Group>
</>
)}
</Stack>
</Modal>
<ConfirmationDialog
opened={deleteConfirmId != null}
onClose={() => setDeleteConfirmId(null)}
onConfirm={() => handleDeleteRepo(deleteConfirmId)}
title="Remove Repository"
message={
<>
<Text size="sm">
Are you sure you want to remove this repository?
</Text>
<Text size="xs" c="dimmed" mt="xs">
Plugins installed from this repo will remain installed but become
unmanaged.
</Text>
</>
}
confirmLabel="Remove"
size="sm"
/>
</>
);
}

View file

@ -13,10 +13,18 @@ import {
} from '@mantine/core';
import { Copy, SquareMinus, SquarePen } from 'lucide-react';
import API from '../../api';
import { notifications } from '@mantine/notifications';
import { showNotification } from '../../utils/notificationUtils';
import useChannelsStore from '../../store/channels';
import { USER_LEVELS } from '../../constants';
const updateChannelProfile = (values) => {
return API.updateChannelProfile(values);
}
const duplicateChannelProfile = (profileId, newName) => {
return API.duplicateChannelProfile(profileId, newName);
}
const ProfileModal = ({ opened, onClose, mode, profile }) => {
const [profileNameInput, setProfileNameInput] = useState('');
const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId);
@ -40,7 +48,7 @@ const ProfileModal = ({ opened, onClose, mode, profile }) => {
if (!mode || !profile) return;
if (!trimmedName) {
notifications.show({
showNotification({
title: 'Profile name is required',
color: 'red.5',
});
@ -53,13 +61,13 @@ const ProfileModal = ({ opened, onClose, mode, profile }) => {
return;
}
const updatedProfile = await API.updateChannelProfile({
const updatedProfile = await updateChannelProfile({
id: profile.id,
name: trimmedName,
});
if (updatedProfile) {
notifications.show({
showNotification({
title: 'Profile renamed',
message: `${profile.name}${trimmedName}`,
color: 'green.5',
@ -69,13 +77,13 @@ const ProfileModal = ({ opened, onClose, mode, profile }) => {
}
if (mode === 'duplicate') {
const duplicatedProfile = await API.duplicateChannelProfile(
const duplicatedProfile = await duplicateChannelProfile(
profile.id,
trimmedName
);
if (duplicatedProfile) {
notifications.show({
showNotification({
title: 'Profile duplicated',
message: `${profile.name} copied to ${duplicatedProfile.name}`,
color: 'green.5',

View file

@ -0,0 +1,539 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// @mantine/core
vi.mock('@mantine/core', () => {
const React = require('react');
// Named declarations so reference equality checks (child.type === Stack) work
function Stack({ children }) {
return <div>{children}</div>;
}
function RadioItem({ value, label, description, _onChange }) {
return (
<label>
<input
type="radio"
data-testid={`radio-${value}`}
value={value}
onChange={() => _onChange?.(value)}
aria-label={label}
/>
{label}
{description && (
<span data-testid={`radio-desc-${value}`}>{description}</span>
)}
</label>
);
}
function RadioGroup({ children, onChange, label }) {
// Inject _onChange into Radio children, handling the Stack wrapper
const inject = (child) => {
if (!React.isValidElement(child)) return child;
if (child.type === RadioItem) {
return React.cloneElement(child, { _onChange: onChange });
}
if (child.type === Stack && child.props.children) {
return React.cloneElement(child, {
children: React.Children.map(child.props.children, inject),
});
}
return child;
};
return (
<div>
{label && <label>{label}</label>}
{React.Children.map(children, inject)}
</div>
);
}
return {
Button: ({ children, onClick, variant }) => (
<button onClick={onClick} data-variant={variant}>
{children}
</button>
),
Checkbox: ({ label, checked, onChange }) => (
<div>
<input
type="checkbox"
data-testid="remember-checkbox"
checked={checked ?? false}
onChange={(e) =>
onChange({ currentTarget: { checked: e.target.checked } })
}
/>
{label && <label>{label}</label>}
</div>
),
Divider: ({ label }) => <hr aria-label={label} />,
Group: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
MultiSelect: ({ label, data, value, onChange }) => {
const flat = (data ?? []).flatMap((g) =>
g.items ? g.items : [{ value: g.value, label: g.label }]
);
return (
<div>
{label && <label>{label}</label>}
{flat.map((opt) => (
<button
key={opt.value}
data-testid={`profile-option-${opt.value}`}
onClick={() => onChange([...(value ?? []), opt.value])}
>
{opt.label}
</button>
))}
</div>
);
},
NumberInput: ({
label,
value,
onChange,
min,
placeholder,
description,
}) => (
<div>
{label && <label>{label}</label>}
<input
type="number"
data-testid="number-input"
value={value ?? ''}
min={min}
placeholder={placeholder}
onChange={(e) => onChange(Number(e.target.value))}
/>
{description && <span>{description}</span>}
</div>
),
Radio: RadioItem,
RadioGroup,
Stack,
Text: ({ children, c }) => <span data-color={c}>{children}</span>,
};
});
// Imports after mocks
import CreateChannelModal from '../CreateChannelModal';
// Shared helpers
const makeProfiles = () => [
{ id: '0', name: 'All Profiles' }, // should be filtered out
{ id: '1', name: 'Profile One' },
{ id: '2', name: 'Profile Two' },
];
const defaultProps = (overrides = {}) => ({
opened: true,
onClose: vi.fn(),
mode: 'provider',
onModeChange: vi.fn(),
numberValue: '',
onNumberValueChange: vi.fn(),
rememberChoice: false,
onRememberChoiceChange: vi.fn(),
onConfirm: vi.fn(),
isBulk: false,
streamCount: 1,
streamName: 'My Stream',
selectedProfileIds: [],
onProfileIdsChange: vi.fn(),
channelProfiles: makeProfiles(),
...overrides,
});
//
describe('CreateChannelModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Visibility
describe('visibility', () => {
it('renders the modal when opened is true', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render the modal when opened is false', () => {
render(<CreateChannelModal {...defaultProps({ opened: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('calls onClose when the close button is clicked', () => {
const onClose = vi.fn();
render(<CreateChannelModal {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Title and labels
describe('title and labels', () => {
it('shows "Create Channel" title for single mode', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Create Channel'
);
});
it('shows "Create Channels Options" title for bulk mode', () => {
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Create Channels Options'
);
});
it('shows "Create Channel" confirm button for single mode', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(
screen.getByRole('button', { name: 'Create Channel' })
).toBeInTheDocument();
});
it('shows "Create Channels" confirm button for bulk mode', () => {
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
expect(screen.getByText('Create Channels')).toBeInTheDocument();
});
it('shows "Number Assignment" numbering label for single mode', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByText('Number Assignment')).toBeInTheDocument();
});
it('shows "Numbering Mode" numbering label for bulk mode', () => {
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
expect(screen.getByText('Numbering Mode')).toBeInTheDocument();
});
});
// Description text
describe('description text', () => {
it('shows the streamName in the description for single mode', () => {
render(
<CreateChannelModal {...defaultProps({ streamName: 'ESPN HD' })} />
);
expect(screen.getByText(/ESPN HD/)).toBeInTheDocument();
});
it('shows the streamCount in the description for bulk mode', () => {
render(
<CreateChannelModal
{...defaultProps({ isBulk: true, streamCount: 5 })}
/>
);
expect(screen.getByText(/5 channels/)).toBeInTheDocument();
});
});
// Radio options
describe('radio options', () => {
it('renders all four radio options', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByTestId('radio-provider')).toBeInTheDocument();
expect(screen.getByTestId('radio-auto')).toBeInTheDocument();
expect(screen.getByTestId('radio-highest')).toBeInTheDocument();
expect(screen.getByTestId('radio-specific')).toBeInTheDocument();
});
it('renders "Use Provider Number" label for single mode', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByLabelText('Use Provider Number')).toBeInTheDocument();
});
it('renders "Use Provider Numbers" label for bulk mode', () => {
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
expect(screen.getByLabelText('Use Provider Numbers')).toBeInTheDocument();
});
it('renders "Auto-Assign Next Available" label for single mode', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(
screen.getByLabelText('Auto-Assign Next Available')
).toBeInTheDocument();
});
it('renders "Auto-Assign Sequential" label for bulk mode', () => {
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
expect(
screen.getByLabelText('Auto-Assign Sequential')
).toBeInTheDocument();
});
it('renders "Use Specific Number" label for single mode', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByLabelText('Use Specific Number')).toBeInTheDocument();
});
it('renders "Start from Custom Number" label for bulk mode', () => {
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
expect(
screen.getByLabelText('Start from Custom Number')
).toBeInTheDocument();
});
it('calls onModeChange when a radio option is selected', () => {
const onModeChange = vi.fn();
render(<CreateChannelModal {...defaultProps({ onModeChange })} />);
fireEvent.click(screen.getByLabelText('Auto-Assign Next Available'));
expect(onModeChange).toHaveBeenCalledWith('auto');
});
});
// NumberInput visibility
describe('NumberInput visibility', () => {
it('does not show NumberInput when mode is "provider"', () => {
render(<CreateChannelModal {...defaultProps({ mode: 'provider' })} />);
expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
});
it('does not show NumberInput when mode is "auto"', () => {
render(<CreateChannelModal {...defaultProps({ mode: 'auto' })} />);
expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
});
it('does not show NumberInput when mode is "highest"', () => {
render(<CreateChannelModal {...defaultProps({ mode: 'highest' })} />);
expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
});
it('shows NumberInput when mode is "specific" in single mode', () => {
render(<CreateChannelModal {...defaultProps({ mode: 'specific' })} />);
expect(screen.getByTestId('number-input')).toBeInTheDocument();
});
it('shows NumberInput when mode is "custom" in bulk mode', () => {
render(
<CreateChannelModal
{...defaultProps({ isBulk: true, mode: 'custom' })}
/>
);
expect(screen.getByTestId('number-input')).toBeInTheDocument();
});
it('does not show NumberInput when mode is "specific" but isBulk is true', () => {
render(
<CreateChannelModal
{...defaultProps({ isBulk: true, mode: 'specific' })}
/>
);
expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
});
it('calls onNumberValueChange when NumberInput value changes', () => {
const onNumberValueChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({
mode: 'specific',
numberValue: 5,
onNumberValueChange,
})}
/>
);
fireEvent.change(screen.getByTestId('number-input'), {
target: { value: '10' },
});
expect(onNumberValueChange).toHaveBeenCalledWith(10);
});
it('shows "Channel Number" label in single mode', () => {
render(<CreateChannelModal {...defaultProps({ mode: 'specific' })} />);
expect(screen.getByText('Channel Number')).toBeInTheDocument();
});
it('shows "Starting Channel Number" label in bulk mode', () => {
render(
<CreateChannelModal
{...defaultProps({ isBulk: true, mode: 'custom' })}
/>
);
expect(screen.getByText('Starting Channel Number')).toBeInTheDocument();
});
});
// Remember choice checkbox
describe('remember choice checkbox', () => {
it('renders unchecked when rememberChoice is false', () => {
render(
<CreateChannelModal {...defaultProps({ rememberChoice: false })} />
);
expect(screen.getByTestId('remember-checkbox')).not.toBeChecked();
});
it('renders checked when rememberChoice is true', () => {
render(
<CreateChannelModal {...defaultProps({ rememberChoice: true })} />
);
expect(screen.getByTestId('remember-checkbox')).toBeChecked();
});
it('calls onRememberChoiceChange with true when checked', () => {
const onRememberChoiceChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({ rememberChoice: false, onRememberChoiceChange })}
/>
);
fireEvent.click(screen.getByTestId('remember-checkbox'));
expect(onRememberChoiceChange).toHaveBeenCalledWith(true);
});
it('calls onRememberChoiceChange with false when unchecked', () => {
const onRememberChoiceChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({ rememberChoice: true, onRememberChoiceChange })}
/>
);
fireEvent.click(screen.getByTestId('remember-checkbox'));
expect(onRememberChoiceChange).toHaveBeenCalledWith(false);
});
});
// Action buttons
describe('action buttons', () => {
it('calls onConfirm when the confirm button is clicked', () => {
const onConfirm = vi.fn();
render(<CreateChannelModal {...defaultProps({ onConfirm })} />);
fireEvent.click(screen.getByRole('button', { name: 'Create Channel' }));
expect(onConfirm).toHaveBeenCalled();
});
it('calls onClose when the Cancel button is clicked', () => {
const onClose = vi.fn();
render(<CreateChannelModal {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByText('Cancel'));
expect(onClose).toHaveBeenCalled();
});
});
// Channel profiles
describe('channel profiles', () => {
it('renders the "All Profiles" special option', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByTestId('profile-option-all')).toBeInTheDocument();
});
it('renders the "No Profiles" special option', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByTestId('profile-option-none')).toBeInTheDocument();
});
it('renders channel profile options (excluding id "0")', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByTestId('profile-option-1')).toBeInTheDocument();
expect(screen.getByTestId('profile-option-2')).toBeInTheDocument();
});
it('does not render the profile with id "0"', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.queryByTestId('profile-option-0')).not.toBeInTheDocument();
});
it('renders "All Profiles" and "No Profiles" labels', () => {
render(<CreateChannelModal {...defaultProps()} />);
expect(screen.getByText('All Profiles')).toBeInTheDocument();
expect(screen.getByText('No Profiles')).toBeInTheDocument();
});
});
// handleProfileChange logic
describe('handleProfileChange', () => {
it('selects only "all" when "All Profiles" is clicked', () => {
const onProfileIdsChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({ selectedProfileIds: [], onProfileIdsChange })}
/>
);
fireEvent.click(screen.getByTestId('profile-option-all'));
expect(onProfileIdsChange).toHaveBeenCalledWith(['all']);
});
it('selects only "none" when "No Profiles" is clicked', () => {
const onProfileIdsChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({ selectedProfileIds: [], onProfileIdsChange })}
/>
);
fireEvent.click(screen.getByTestId('profile-option-none'));
expect(onProfileIdsChange).toHaveBeenCalledWith(['none']);
});
it('removes "all" when a specific profile is added while "all" is selected', () => {
const onProfileIdsChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({ selectedProfileIds: ['all'], onProfileIdsChange })}
/>
);
fireEvent.click(screen.getByTestId('profile-option-1'));
expect(onProfileIdsChange).toHaveBeenCalledWith(['1']);
});
it('removes "none" when a specific profile is added while "none" is selected', () => {
const onProfileIdsChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({
selectedProfileIds: ['none'],
onProfileIdsChange,
})}
/>
);
fireEvent.click(screen.getByTestId('profile-option-2'));
expect(onProfileIdsChange).toHaveBeenCalledWith(['2']);
});
it('allows selecting multiple specific profiles', () => {
const onProfileIdsChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({ selectedProfileIds: ['1'], onProfileIdsChange })}
/>
);
fireEvent.click(screen.getByTestId('profile-option-2'));
expect(onProfileIdsChange).toHaveBeenCalledWith(['1', '2']);
});
it('replaces existing "all" selection when "none" is clicked last', () => {
const onProfileIdsChange = vi.fn();
render(
<CreateChannelModal
{...defaultProps({ selectedProfileIds: ['all'], onProfileIdsChange })}
/>
);
// Mock passes ['all', 'none'] to onChange, making lastSelected = 'none'
fireEvent.click(screen.getByTestId('profile-option-none'));
expect(onProfileIdsChange).toHaveBeenCalledWith(['none']);
});
});
});

View file

@ -0,0 +1,597 @@
import {
render,
screen,
fireEvent,
waitFor,
act,
} from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Store mock
vi.mock('../../../store/plugins.jsx', () => ({
usePluginStore: vi.fn(),
}));
// Utility mocks
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('../../../utils/pages/PluginsUtils.js', () => ({
getPluginRepoSettings: vi.fn(),
previewPluginRepo: vi.fn(),
updatePluginRepoSettings: vi.fn(),
}));
// ConfirmationDialog mock
vi.mock('../../ConfirmationDialog.jsx', () => ({
default: ({ opened, onClose, onConfirm, title, confirmLabel }) =>
opened ? (
<div data-testid="confirmation-dialog">
<div data-testid="dialog-title">{title}</div>
<button data-testid="dialog-confirm" onClick={onConfirm}>
{confirmLabel || 'Confirm'}
</button>
<button data-testid="dialog-close" onClick={onClose}>
Cancel
</button>
</div>
) : null,
}));
// lucide-react
vi.mock('lucide-react', () => ({
KeyRound: () => <svg data-testid="icon-key-round" />,
Plus: () => <svg data-testid="icon-plus" />,
ShieldAlert: () => <svg data-testid="icon-shield-alert" />,
ShieldCheck: () => <svg data-testid="icon-shield-check" />,
Trash2: () => <svg data-testid="icon-trash-2" />,
}));
// @mantine/core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, title, color, variant }) => (
<button
data-testid="action-icon"
title={title}
data-color={color}
data-variant={variant}
onClick={onClick}
>
{children}
</button>
),
Badge: ({ children, color, variant, leftSection }) => (
<span data-testid="badge" data-color={color} data-variant={variant}>
{leftSection}
{children}
</span>
),
Box: ({ children }) => <div>{children}</div>,
Button: ({
children,
onClick,
loading,
disabled,
variant,
color,
leftSection,
}) => (
<button
onClick={onClick}
disabled={disabled || loading}
data-loading={loading}
data-variant={variant}
data-color={color}
>
{leftSection}
{children}
</button>
),
Group: ({ children }) => <div>{children}</div>,
Loader: ({ size }) => <div data-testid="loader" data-size={size} />,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-header">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
NumberInput: ({ value, onChange, min, max, disabled }) => (
<input
type="number"
data-testid="refresh-interval-input"
value={value ?? ''}
onChange={(e) => onChange(Number(e.target.value))}
min={min}
max={max}
disabled={disabled}
/>
),
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, fw, size, c }) => (
<span data-fw={fw} data-size={size} data-color={c}>
{children}
</span>
),
Textarea: ({ value, onChange, placeholder, onFocus, onBlur }) => (
<textarea
data-testid="textarea"
value={value ?? ''}
onChange={onChange}
placeholder={placeholder}
onFocus={onFocus}
onBlur={onBlur}
/>
),
TextInput: ({ value, onChange, placeholder }) => (
<input
data-testid="repo-url-input"
value={value ?? ''}
onChange={onChange}
placeholder={placeholder}
/>
),
}));
// Imports after mocks
import ManageReposModal from '../ManageReposModal';
import { usePluginStore } from '../../../store/plugins.jsx';
import { showNotification } from '../../../utils/notificationUtils.js';
import {
getPluginRepoSettings,
previewPluginRepo,
updatePluginRepoSettings,
} from '../../../utils/pages/PluginsUtils.js';
// Shared helpers
const makeRepo = (overrides = {}) => ({
id: 1,
name: 'Main Repo',
url: 'https://example.com/manifest.json',
is_official: false,
signature_verified: null,
registry_url: null,
last_fetched: null,
last_fetch_status: null,
plugin_count: null,
public_key: '',
...overrides,
});
let mockFetchAvailablePlugins;
let mockRefreshRepo;
let mockAddRepo;
let mockRemoveRepo;
let mockUpdateRepo;
const setupStore = ({ repos = [], reposLoading = false } = {}) => {
mockFetchAvailablePlugins = vi.fn().mockResolvedValue(undefined);
mockRefreshRepo = vi.fn().mockResolvedValue(undefined);
mockAddRepo = vi.fn().mockResolvedValue(undefined);
mockRemoveRepo = vi.fn().mockResolvedValue(undefined);
mockUpdateRepo = vi.fn().mockResolvedValue(undefined);
vi.mocked(usePluginStore).mockImplementation((sel) =>
sel({
repos,
reposLoading,
fetchAvailablePlugins: mockFetchAvailablePlugins,
refreshRepo: mockRefreshRepo,
addRepo: mockAddRepo,
removeRepo: mockRemoveRepo,
updateRepo: mockUpdateRepo,
})
);
};
const defaultProps = (overrides = {}) => ({
opened: true,
onClose: vi.fn(),
...overrides,
});
//
describe('ManageReposModal', () => {
beforeEach(() => {
vi.clearAllMocks();
setupStore();
vi.mocked(getPluginRepoSettings).mockResolvedValue({
refresh_interval_hours: 6,
});
vi.mocked(updatePluginRepoSettings).mockResolvedValue(undefined);
vi.mocked(previewPluginRepo).mockResolvedValue(null);
});
// Visibility
describe('visibility', () => {
it('renders the modal when opened is true', () => {
render(<ManageReposModal {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render the modal when opened is false', () => {
render(<ManageReposModal {...defaultProps({ opened: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('calls onClose when the close button is clicked', () => {
const onClose = vi.fn();
render(<ManageReposModal {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Settings load
describe('settings load', () => {
it('calls getPluginRepoSettings when opened', async () => {
render(<ManageReposModal {...defaultProps()} />);
await waitFor(() => {
expect(getPluginRepoSettings).toHaveBeenCalledTimes(1);
});
});
it('does not call getPluginRepoSettings when closed', async () => {
render(<ManageReposModal {...defaultProps({ opened: false })} />);
expect(getPluginRepoSettings).not.toHaveBeenCalled();
});
it('loads the refresh interval from settings', async () => {
vi.mocked(getPluginRepoSettings).mockResolvedValue({
refresh_interval_hours: 24,
});
render(<ManageReposModal {...defaultProps()} />);
await waitFor(() => {
expect(screen.getByTestId('refresh-interval-input')).toHaveValue(24);
});
});
});
// Repos list
describe('repos list', () => {
it('renders a row for each repo', () => {
setupStore({
repos: [
makeRepo({ id: 1, name: 'Repo A' }),
makeRepo({ id: 2, name: 'Repo B' }),
],
});
render(<ManageReposModal {...defaultProps()} />);
expect(screen.getByText('Repo A')).toBeInTheDocument();
expect(screen.getByText('Repo B')).toBeInTheDocument();
});
it('shows loader when loading and no repos are present', () => {
setupStore({ reposLoading: true, repos: [] });
render(<ManageReposModal {...defaultProps()} />);
expect(screen.getByTestId('loader')).toBeInTheDocument();
});
it('does not show loader when repos are present', () => {
setupStore({ reposLoading: true, repos: [makeRepo()] });
render(<ManageReposModal {...defaultProps()} />);
expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
});
it('shows "Official Repo" badge for official repos', () => {
setupStore({ repos: [makeRepo({ is_official: true })] });
render(<ManageReposModal {...defaultProps()} />);
expect(screen.getByText('Official Repo')).toBeInTheDocument();
});
it('shows "Verified Signature" badge when signature_verified is true', () => {
setupStore({ repos: [makeRepo({ signature_verified: true })] });
render(<ManageReposModal {...defaultProps()} />);
expect(screen.getByText('Verified Signature')).toBeInTheDocument();
});
it('shows "Invalid Signature" badge when signature_verified is false', () => {
setupStore({ repos: [makeRepo({ signature_verified: false })] });
render(<ManageReposModal {...defaultProps()} />);
expect(screen.getByText('Invalid Signature')).toBeInTheDocument();
});
it('shows edit and delete buttons only for non-official repos', () => {
setupStore({ repos: [makeRepo({ is_official: false })] });
render(<ManageReposModal {...defaultProps()} />);
expect(screen.getByTitle('Edit public key')).toBeInTheDocument();
expect(screen.getByTitle('Remove repo')).toBeInTheDocument();
});
it('does not show edit and delete buttons for official repos', () => {
setupStore({ repos: [makeRepo({ is_official: true })] });
render(<ManageReposModal {...defaultProps()} />);
expect(screen.queryByTitle('Edit public key')).not.toBeInTheDocument();
expect(screen.queryByTitle('Remove repo')).not.toBeInTheDocument();
});
});
// Refresh interval
describe('refresh interval', () => {
it('calls updatePluginRepoSettings (debounced) when interval changes', async () => {
vi.useFakeTimers();
render(<ManageReposModal {...defaultProps()} />);
await act(async () => {
await vi.runAllTimersAsync(); // flush loadRepoSettings
});
fireEvent.change(screen.getByTestId('refresh-interval-input'), {
target: { value: '12' },
});
await act(async () => {
await vi.advanceTimersByTimeAsync(900);
});
expect(updatePluginRepoSettings).toHaveBeenCalledWith({
refresh_interval_hours: 12,
});
vi.useRealTimers();
});
it('does not call updatePluginRepoSettings before the debounce delay', async () => {
vi.useFakeTimers();
render(<ManageReposModal {...defaultProps()} />);
await act(async () => {
await vi.runAllTimersAsync();
});
fireEvent.change(screen.getByTestId('refresh-interval-input'), {
target: { value: '12' },
});
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(updatePluginRepoSettings).not.toHaveBeenCalled();
vi.useRealTimers();
});
});
// Edit public key
describe('edit public key', () => {
it('shows key editor when Edit button is clicked', () => {
setupStore({ repos: [makeRepo({ id: 1 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Edit public key'));
expect(screen.getByTestId('textarea')).toBeInTheDocument();
expect(screen.getByText('Save Key')).toBeInTheDocument();
});
it('pre-fills key editor with existing public key', () => {
setupStore({ repos: [makeRepo({ id: 1, public_key: 'existing-key' })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Edit public key'));
expect(screen.getByTestId('textarea')).toHaveValue('existing-key');
});
it('hides key editor when Cancel is clicked', () => {
setupStore({ repos: [makeRepo({ id: 1 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Edit public key'));
fireEvent.click(screen.getByText('Cancel'));
expect(screen.queryByText('Save Key')).not.toBeInTheDocument();
});
it('calls updateRepo, refreshRepo, and fetchAvailablePlugins on Save Key', async () => {
setupStore({ repos: [makeRepo({ id: 7 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Edit public key'));
fireEvent.change(screen.getByTestId('textarea'), {
target: { value: 'new-key' },
});
fireEvent.click(screen.getByText('Save Key'));
await waitFor(() => {
expect(mockUpdateRepo).toHaveBeenCalledWith(7, {
public_key: 'new-key',
});
expect(mockRefreshRepo).toHaveBeenCalledWith(7);
expect(mockFetchAvailablePlugins).toHaveBeenCalled();
});
});
it('shows "Updated" notification after successful key save', async () => {
setupStore({ repos: [makeRepo({ id: 1 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Edit public key'));
fireEvent.click(screen.getByText('Save Key'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Updated', color: 'green' })
);
});
});
it('shows error notification when key save fails', async () => {
mockUpdateRepo = vi.fn().mockRejectedValue(new Error('Network error'));
vi.mocked(usePluginStore).mockImplementation((sel) =>
sel({
repos: [makeRepo({ id: 1 })],
reposLoading: false,
fetchAvailablePlugins: mockFetchAvailablePlugins,
refreshRepo: mockRefreshRepo,
addRepo: mockAddRepo,
removeRepo: mockRemoveRepo,
updateRepo: mockUpdateRepo,
})
);
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Edit public key'));
fireEvent.click(screen.getByText('Save Key'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Error', color: 'red' })
);
});
});
it('closes key editor after successful save', async () => {
setupStore({ repos: [makeRepo({ id: 1 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Edit public key'));
fireEvent.click(screen.getByText('Save Key'));
await waitFor(() => {
expect(screen.queryByText('Save Key')).not.toBeInTheDocument();
});
});
});
// Delete repo
describe('delete repo', () => {
it('opens ConfirmationDialog when delete button is clicked', () => {
setupStore({ repos: [makeRepo({ id: 1 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Remove repo'));
expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
expect(screen.getByTestId('dialog-title')).toHaveTextContent(
'Remove Repository'
);
});
it('closes ConfirmationDialog when cancelled', () => {
setupStore({ repos: [makeRepo({ id: 1 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Remove repo'));
fireEvent.click(screen.getByTestId('dialog-close'));
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
it('calls removeRepo and fetchAvailablePlugins when confirmed', async () => {
setupStore({ repos: [makeRepo({ id: 5 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Remove repo'));
fireEvent.click(screen.getByTestId('dialog-confirm'));
await waitFor(() => {
expect(mockRemoveRepo).toHaveBeenCalledWith(5);
expect(mockFetchAvailablePlugins).toHaveBeenCalled();
});
});
it('shows "Removed" notification after delete', async () => {
setupStore({ repos: [makeRepo({ id: 1 })] });
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByTitle('Remove repo'));
fireEvent.click(screen.getByTestId('dialog-confirm'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Removed', color: 'green' })
);
});
});
});
// Add repository
describe('add repository', () => {
it('shows "Add Repository" button initially', () => {
render(<ManageReposModal {...defaultProps()} />);
expect(screen.getByText('Add Repository')).toBeInTheDocument();
});
it('shows the add form when "Add Repository" is clicked', () => {
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add Repository'));
expect(screen.getByTestId('repo-url-input')).toBeInTheDocument();
expect(screen.getByText('Add Repo')).toBeInTheDocument();
});
it('hides the form and resets when Cancel is clicked', () => {
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add Repository'));
fireEvent.click(screen.getByText('Cancel'));
expect(screen.queryByTestId('repo-url-input')).not.toBeInTheDocument();
expect(screen.getByText('Add Repository')).toBeInTheDocument();
});
it('"Add Repo" button is disabled when URL is empty', () => {
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add Repository'));
expect(screen.getByText('Add Repo')).toBeDisabled();
});
it('"Add Repo" button is enabled when URL is entered', () => {
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add Repository'));
fireEvent.change(screen.getByTestId('repo-url-input'), {
target: { value: 'https://example.com/manifest.json' },
});
expect(screen.getByText('Add Repo')).not.toBeDisabled();
});
it('calls addRepo and fetchAvailablePlugins when submitted', async () => {
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add Repository'));
fireEvent.change(screen.getByTestId('repo-url-input'), {
target: { value: 'https://example.com/manifest.json' },
});
fireEvent.click(screen.getByText('Add Repo'));
await waitFor(() => {
expect(mockAddRepo).toHaveBeenCalledWith(
expect.objectContaining({ url: 'https://example.com/manifest.json' })
);
expect(mockFetchAvailablePlugins).toHaveBeenCalled();
});
});
it('shows "Added" notification after successful add', async () => {
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add Repository'));
fireEvent.change(screen.getByTestId('repo-url-input'), {
target: { value: 'https://example.com/manifest.json' },
});
fireEvent.click(screen.getByText('Add Repo'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Added', color: 'green' })
);
});
});
it('closes the form after a successful add', async () => {
render(<ManageReposModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add Repository'));
fireEvent.change(screen.getByTestId('repo-url-input'), {
target: { value: 'https://example.com/manifest.json' },
});
fireEvent.click(screen.getByText('Add Repo'));
await waitFor(() => {
expect(screen.queryByTestId('repo-url-input')).not.toBeInTheDocument();
});
});
it('triggers previewPluginRepo (debounced) when a valid URL is entered', async () => {
vi.useFakeTimers();
render(<ManageReposModal {...defaultProps()} />);
await act(async () => {
await vi.runAllTimersAsync();
});
fireEvent.click(screen.getByText('Add Repository'));
fireEvent.change(screen.getByTestId('repo-url-input'), {
target: { value: 'https://example.com/manifest.json' },
});
await act(async () => {
await vi.advanceTimersByTimeAsync(700);
});
expect(previewPluginRepo).toHaveBeenCalledWith(
'https://example.com/manifest.json',
''
);
vi.useRealTimers();
});
});
});

View file

@ -0,0 +1,743 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// API mock
vi.mock('../../../api', () => ({
default: {
updateChannelProfile: vi.fn(),
duplicateChannelProfile: vi.fn(),
},
}));
// Notification mock
vi.mock('../../../utils/notificationUtils', () => ({
showNotification: vi.fn(),
}));
// Store mock
vi.mock('../../../store/channels', () => ({
default: vi.fn(),
}));
// Constants mock
vi.mock('../../../constants', () => ({
USER_LEVELS: { STREAMER: 0, STANDARD: 1, ADMIN: 10 },
}));
// lucide-react
vi.mock('lucide-react', () => ({
Copy: () => <svg data-testid="icon-copy" />,
SquareMinus: () => <svg data-testid="icon-square-minus" />,
SquarePen: () => <svg data-testid="icon-square-pen" />,
}));
// @mantine/core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, disabled }) => (
<button data-testid="action-icon" onClick={onClick} disabled={disabled}>
{children}
</button>
),
Alert: ({ children, title, color }) => (
<div data-testid="alert" data-color={color}>
{title && <div data-testid="alert-title">{title}</div>}
{children}
</div>
),
Box: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, variant, size }) => (
<button onClick={onClick} data-variant={variant} data-size={size}>
{children}
</button>
),
Group: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, size }) => <span data-size={size}>{children}</span>,
TextInput: ({ label, value, onChange, placeholder }) => (
<div>
{label && <label>{label}</label>}
<input
data-testid="profile-name-input"
value={value ?? ''}
onChange={onChange}
placeholder={placeholder}
/>
</div>
),
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
}));
// Imports after mocks
import ProfileModal, { renderProfileOption } from '../ProfileModal';
import API from '../../../api';
import useChannelsStore from '../../../store/channels';
import { showNotification } from '../../../utils/notificationUtils';
// Shared helpers
const makeProfile = (overrides = {}) => ({
id: 1,
name: 'My Profile',
...overrides,
});
const defaultProps = (overrides = {}) => ({
opened: true,
onClose: vi.fn(),
mode: 'edit',
profile: makeProfile(),
...overrides,
});
//
describe('ProfileModal', () => {
let mockSetSelectedProfileId;
beforeEach(() => {
vi.resetAllMocks();
mockSetSelectedProfileId = vi.fn();
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({ setSelectedProfileId: mockSetSelectedProfileId })
);
vi.mocked(API.updateChannelProfile).mockResolvedValue({
id: 1,
name: 'Updated',
});
vi.mocked(API.duplicateChannelProfile).mockResolvedValue({
id: 2,
name: 'My Profile Copy',
});
});
// Visibility
describe('visibility', () => {
it('renders the modal when opened is true', () => {
render(<ProfileModal {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render the modal when opened is false', () => {
render(<ProfileModal {...defaultProps({ opened: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
});
// Title
describe('title', () => {
it('shows "Rename Profile: {name}" title in edit mode', () => {
render(
<ProfileModal
{...defaultProps({
mode: 'edit',
profile: makeProfile({ name: 'Work Profile' }),
})}
/>
);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Rename Profile: Work Profile'
);
});
it('shows "Duplicate Profile: {name}" title in duplicate mode', () => {
render(
<ProfileModal
{...defaultProps({
mode: 'duplicate',
profile: makeProfile({ name: 'Work Profile' }),
})}
/>
);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Duplicate Profile: Work Profile'
);
});
});
// Warning alert
describe('warning alert', () => {
it('shows the warning alert in edit mode', () => {
render(<ProfileModal {...defaultProps({ mode: 'edit' })} />);
expect(screen.getByTestId('alert')).toBeInTheDocument();
});
it('does not show the warning alert in duplicate mode', () => {
render(<ProfileModal {...defaultProps({ mode: 'duplicate' })} />);
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
});
// Initial name value
describe('initial name value', () => {
it('pre-fills the input with the profile name in edit mode', () => {
render(
<ProfileModal
{...defaultProps({
mode: 'edit',
profile: makeProfile({ name: 'My Profile' }),
})}
/>
);
expect(screen.getByTestId('profile-name-input')).toHaveValue(
'My Profile'
);
});
it('pre-fills the input with "{name} Copy" in duplicate mode', () => {
render(
<ProfileModal
{...defaultProps({
mode: 'duplicate',
profile: makeProfile({ name: 'My Profile' }),
})}
/>
);
expect(screen.getByTestId('profile-name-input')).toHaveValue(
'My Profile Copy'
);
});
});
// Button labels
describe('button labels', () => {
it('shows "Save" button in edit mode', () => {
render(<ProfileModal {...defaultProps({ mode: 'edit' })} />);
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('shows "Duplicate" button in duplicate mode', () => {
render(<ProfileModal {...defaultProps({ mode: 'duplicate' })} />);
expect(screen.getByText('Duplicate')).toBeInTheDocument();
});
});
// Cancel / close
describe('cancel / close', () => {
it('calls onClose when Cancel is clicked', () => {
const onClose = vi.fn();
render(<ProfileModal {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByText('Cancel'));
expect(onClose).toHaveBeenCalled();
});
it('calls onClose when the modal close button is clicked', () => {
const onClose = vi.fn();
render(<ProfileModal {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Submit validation
describe('submit validation', () => {
it('shows notification when name is empty', () => {
render(<ProfileModal {...defaultProps()} />);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Save'));
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Profile name is required',
color: 'red.5',
})
);
});
it('shows notification when name is only whitespace', () => {
render(<ProfileModal {...defaultProps()} />);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: ' ' },
});
fireEvent.click(screen.getByText('Save'));
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Profile name is required' })
);
});
it('does not call API when name is empty', () => {
render(<ProfileModal {...defaultProps()} />);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: '' },
});
fireEvent.click(screen.getByText('Save'));
expect(API.updateChannelProfile).not.toHaveBeenCalled();
});
it('returns early without notification or API call when profile is undefined', () => {
render(<ProfileModal {...defaultProps({ profile: undefined })} />);
fireEvent.click(screen.getByText('Save'));
expect(API.updateChannelProfile).not.toHaveBeenCalled();
expect(showNotification).not.toHaveBeenCalled();
});
});
// Edit mode submission
describe('edit mode submission', () => {
it('closes without calling API when name is unchanged', async () => {
const onClose = vi.fn();
render(
<ProfileModal
{...defaultProps({
onClose,
mode: 'edit',
profile: makeProfile({ name: 'My Profile' }),
})}
/>
);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
expect(API.updateChannelProfile).not.toHaveBeenCalled();
});
it('calls updateChannelProfile with id and new name when name changes', async () => {
render(
<ProfileModal
{...defaultProps({
mode: 'edit',
profile: makeProfile({ id: 7, name: 'Old Name' }),
})}
/>
);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: 'New Name' },
});
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(API.updateChannelProfile).toHaveBeenCalledWith({
id: 7,
name: 'New Name',
});
});
});
it('trims whitespace from the name before submitting', async () => {
render(
<ProfileModal
{...defaultProps({
mode: 'edit',
profile: makeProfile({ id: 1, name: 'Old' }),
})}
/>
);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: ' Trimmed Name ' },
});
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(API.updateChannelProfile).toHaveBeenCalledWith({
id: 1,
name: 'Trimmed Name',
});
});
});
it('shows "Profile renamed" notification on success', async () => {
render(
<ProfileModal
{...defaultProps({
mode: 'edit',
profile: makeProfile({ name: 'Old Name' }),
})}
/>
);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: 'New Name' },
});
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Profile renamed',
color: 'green.5',
})
);
});
});
it('calls onClose after a successful rename', async () => {
const onClose = vi.fn();
render(
<ProfileModal
{...defaultProps({
onClose,
mode: 'edit',
profile: makeProfile({ name: 'Old' }),
})}
/>
);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: 'New' },
});
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('does not notify or close when updateChannelProfile returns null', async () => {
vi.mocked(API.updateChannelProfile).mockResolvedValue(null);
const onClose = vi.fn();
render(
<ProfileModal
{...defaultProps({
onClose,
mode: 'edit',
profile: makeProfile({ name: 'Old' }),
})}
/>
);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: 'New' },
});
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(API.updateChannelProfile).toHaveBeenCalled();
});
expect(showNotification).not.toHaveBeenCalledWith(
expect.objectContaining({ title: 'Profile renamed' })
);
expect(onClose).not.toHaveBeenCalled();
});
});
// Duplicate mode submission
describe('duplicate mode submission', () => {
it('calls duplicateChannelProfile with profile id and default copy name', async () => {
render(
<ProfileModal
{...defaultProps({
mode: 'duplicate',
profile: makeProfile({ id: 5, name: 'My Profile' }),
})}
/>
);
fireEvent.click(screen.getByText('Duplicate'));
await waitFor(() => {
expect(API.duplicateChannelProfile).toHaveBeenCalledWith(
5,
'My Profile Copy'
);
});
});
it('uses the custom name entered by the user', async () => {
render(
<ProfileModal
{...defaultProps({
mode: 'duplicate',
profile: makeProfile({ id: 3, name: 'Base' }),
})}
/>
);
fireEvent.change(screen.getByTestId('profile-name-input'), {
target: { value: 'Custom Copy Name' },
});
fireEvent.click(screen.getByText('Duplicate'));
await waitFor(() => {
expect(API.duplicateChannelProfile).toHaveBeenCalledWith(
3,
'Custom Copy Name'
);
});
});
it('shows "Profile duplicated" notification on success', async () => {
render(
<ProfileModal
{...defaultProps({ mode: 'duplicate', profile: makeProfile() })}
/>
);
fireEvent.click(screen.getByText('Duplicate'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Profile duplicated',
color: 'green.5',
})
);
});
});
it('calls setSelectedProfileId with the string id of the duplicated profile', async () => {
vi.mocked(API.duplicateChannelProfile).mockResolvedValue({
id: 99,
name: 'Copy',
});
render(
<ProfileModal
{...defaultProps({ mode: 'duplicate', profile: makeProfile() })}
/>
);
fireEvent.click(screen.getByText('Duplicate'));
await waitFor(() => {
expect(mockSetSelectedProfileId).toHaveBeenCalledWith('99');
});
});
it('calls onClose after a successful duplication', async () => {
const onClose = vi.fn();
render(
<ProfileModal
{...defaultProps({
onClose,
mode: 'duplicate',
profile: makeProfile(),
})}
/>
);
fireEvent.click(screen.getByText('Duplicate'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('does not call setSelectedProfileId or close when duplicateChannelProfile returns null', async () => {
vi.mocked(API.duplicateChannelProfile).mockResolvedValue(null);
const onClose = vi.fn();
render(
<ProfileModal
{...defaultProps({
onClose,
mode: 'duplicate',
profile: makeProfile(),
})}
/>
);
fireEvent.click(screen.getByText('Duplicate'));
await waitFor(() => {
expect(API.duplicateChannelProfile).toHaveBeenCalled();
});
expect(mockSetSelectedProfileId).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
});
});
//
describe('renderProfileOption', () => {
const mockTheme = {
tailwind: {
yellow: { 3: '#f59e0b' },
green: { 5: '#22c55e' },
red: { 6: '#dc2626' },
},
};
const adminUser = { user_level: 10 };
const nonAdminUser = { user_level: 1 };
const makeRenderArgs = (overrides = {}) => ({
theme: mockTheme,
profiles: [],
onEditProfile: vi.fn(),
onDeleteProfile: vi.fn(),
authUser: adminUser,
...overrides,
});
const renderOption = (optionValue, optionLabel, args) => {
const renderFn = renderProfileOption(
args.theme,
args.profiles,
args.onEditProfile,
args.onDeleteProfile,
args.authUser
);
const { container } = render(
renderFn({ option: { value: optionValue, label: optionLabel } })
);
return container;
};
beforeEach(() => {
vi.clearAllMocks();
});
// Rendering
describe('rendering', () => {
it('renders the option label', () => {
renderOption('1', 'Profile One', makeRenderArgs());
expect(screen.getByText('Profile One')).toBeInTheDocument();
});
it('shows action icons when option value is not "0"', () => {
renderOption('1', 'Profile One', makeRenderArgs());
expect(screen.getByTestId('icon-square-pen')).toBeInTheDocument();
expect(screen.getByTestId('icon-copy')).toBeInTheDocument();
expect(screen.getByTestId('icon-square-minus')).toBeInTheDocument();
});
it('does not show action icons when option value is "0"', () => {
renderOption('0', 'All Profiles', makeRenderArgs());
expect(screen.queryByTestId('icon-square-pen')).not.toBeInTheDocument();
expect(screen.queryByTestId('icon-copy')).not.toBeInTheDocument();
expect(screen.queryByTestId('icon-square-minus')).not.toBeInTheDocument();
});
});
// Rename action
describe('rename action', () => {
it('calls onEditProfile with "edit" and option value when rename is clicked', () => {
const args = makeRenderArgs();
renderOption('3', 'Profile Three', args);
fireEvent.click(screen.getByTestId('icon-square-pen').closest('button'));
expect(args.onEditProfile).toHaveBeenCalledWith('edit', '3');
});
it('rename button is enabled for admin user', () => {
renderOption('1', 'Profile One', makeRenderArgs({ authUser: adminUser }));
expect(
screen.getByTestId('icon-square-pen').closest('button')
).not.toBeDisabled();
});
it('rename button is disabled for non-admin user', () => {
renderOption(
'1',
'Profile One',
makeRenderArgs({ authUser: nonAdminUser })
);
expect(
screen.getByTestId('icon-square-pen').closest('button')
).toBeDisabled();
});
});
// Duplicate action
describe('duplicate action', () => {
it('calls onEditProfile with "duplicate" and option value when duplicate is clicked', () => {
const args = makeRenderArgs();
renderOption('5', 'Profile Five', args);
fireEvent.click(screen.getByTestId('icon-copy').closest('button'));
expect(args.onEditProfile).toHaveBeenCalledWith('duplicate', '5');
});
it('duplicate button is enabled for admin user', () => {
renderOption('1', 'Profile One', makeRenderArgs({ authUser: adminUser }));
expect(
screen.getByTestId('icon-copy').closest('button')
).not.toBeDisabled();
});
it('duplicate button is disabled for non-admin user', () => {
renderOption(
'1',
'Profile One',
makeRenderArgs({ authUser: nonAdminUser })
);
expect(screen.getByTestId('icon-copy').closest('button')).toBeDisabled();
});
});
// Delete action
describe('delete action', () => {
it('calls onDeleteProfile with option value when delete is clicked', () => {
const args = makeRenderArgs();
renderOption('7', 'Profile Seven', args);
fireEvent.click(
screen.getByTestId('icon-square-minus').closest('button')
);
expect(args.onDeleteProfile).toHaveBeenCalledWith('7');
});
it('delete button is disabled for non-admin user', () => {
renderOption(
'1',
'Profile One',
makeRenderArgs({ authUser: nonAdminUser })
);
expect(
screen.getByTestId('icon-square-minus').closest('button')
).toBeDisabled();
});
});
// Event propagation
describe('event propagation', () => {
it('does not propagate click event when rename is clicked', () => {
const args = makeRenderArgs();
const parentClick = vi.fn();
const renderFn = renderProfileOption(
mockTheme,
[],
args.onEditProfile,
args.onDeleteProfile,
adminUser
);
render(
<div onClick={parentClick}>
{renderFn({ option: { value: '1', label: 'Profile' } })}
</div>
);
fireEvent.click(screen.getByTestId('icon-square-pen').closest('button'));
expect(parentClick).not.toHaveBeenCalled();
});
it('does not propagate click event when duplicate is clicked', () => {
const args = makeRenderArgs();
const parentClick = vi.fn();
const renderFn = renderProfileOption(
mockTheme,
[],
args.onEditProfile,
args.onDeleteProfile,
adminUser
);
render(
<div onClick={parentClick}>
{renderFn({ option: { value: '1', label: 'Profile' } })}
</div>
);
fireEvent.click(screen.getByTestId('icon-copy').closest('button'));
expect(parentClick).not.toHaveBeenCalled();
});
it('does not propagate click event when delete is clicked', () => {
const args = makeRenderArgs();
const parentClick = vi.fn();
const renderFn = renderProfileOption(
mockTheme,
[],
args.onEditProfile,
args.onDeleteProfile,
adminUser
);
render(
<div onClick={parentClick}>
{renderFn({ option: { value: '1', label: 'Profile' } })}
</div>
);
fireEvent.click(
screen.getByTestId('icon-square-minus').closest('button')
);
expect(parentClick).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,325 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// API mock
vi.mock('../../api', () => ({
default: {
getCurrentProgramForEpg: vi.fn(),
},
}));
// Imports after mocks
import API from '../../api';
import { useEpgPreview } from '../useEpgPreview';
//
describe('useEpgPreview', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
// Invalid / empty epgDataId
describe('invalid epgDataId', () => {
it.each([null, undefined, '', '0'])(
'returns defaults and skips the API call when epgDataId is %s',
async (id) => {
const { result } = renderHook(() => useEpgPreview(id));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).toBe(null);
expect(result.current.isLoadingProgram).toBe(false);
expect(result.current.hasFetchedProgram).toBe(false);
expect(API.getCurrentProgramForEpg).not.toHaveBeenCalled();
}
);
});
// Initial state
describe('initial state with a valid epgDataId', () => {
it('sets isLoadingProgram true and hasFetchedProgram false immediately', () => {
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue(null);
const { result } = renderHook(() => useEpgPreview('epg-1'));
expect(result.current.isLoadingProgram).toBe(true);
expect(result.current.hasFetchedProgram).toBe(false);
expect(result.current.currentProgram).toBe(null);
});
});
// Successful fetch
describe('successful fetch', () => {
it('calls the API with the provided epgDataId', async () => {
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({ id: 1 });
renderHook(() => useEpgPreview('epg-42'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(API.getCurrentProgramForEpg).toHaveBeenCalledWith('epg-42');
});
it('sets currentProgram to the returned program', async () => {
const program = { id: 7, title: 'News at Six', channel: 'BBC' };
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue(program);
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).toEqual(program);
});
it('sets isLoadingProgram to false after a successful fetch', async () => {
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({ id: 1 });
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.isLoadingProgram).toBe(false);
});
it('sets hasFetchedProgram to true after a successful fetch', async () => {
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({ id: 1 });
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.hasFetchedProgram).toBe(true);
});
it('sets currentProgram to null and completes when API returns null', async () => {
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue(null);
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).toBe(null);
expect(result.current.isLoadingProgram).toBe(false);
expect(result.current.hasFetchedProgram).toBe(true);
});
});
// Parsing retry
describe('parsing retry', () => {
it('retries after a delay when program.parsing is true', async () => {
const parsingProgram = { id: 1, parsing: true };
const readyProgram = { id: 1, title: 'News', parsing: false };
vi.mocked(API.getCurrentProgramForEpg)
.mockResolvedValueOnce(parsingProgram)
.mockResolvedValueOnce(readyProgram);
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(API.getCurrentProgramForEpg).toHaveBeenCalledTimes(2);
expect(result.current.currentProgram).toEqual(readyProgram);
expect(result.current.hasFetchedProgram).toBe(true);
expect(result.current.isLoadingProgram).toBe(false);
});
it('does not set a still-parsing program as currentProgram', async () => {
const parsingProgram = { id: 1, parsing: true };
const readyProgram = { id: 1, title: 'Ready', parsing: false };
vi.mocked(API.getCurrentProgramForEpg)
.mockResolvedValueOnce(parsingProgram)
.mockResolvedValueOnce(readyProgram);
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).toEqual(readyProgram);
expect(result.current.currentProgram?.parsing).toBeFalsy();
});
it('resolves to null after all retries are exhausted with parsing: true', async () => {
// Always returns parsing: true the hook will exhaust retries/deadline
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({
id: 1,
parsing: true,
});
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).toBe(null);
expect(result.current.isLoadingProgram).toBe(false);
expect(result.current.hasFetchedProgram).toBe(true);
});
});
// Error handling
describe('error handling', () => {
it('retries after an API error and sets currentProgram on the next success', async () => {
const program = { id: 1, title: 'News' };
vi.mocked(API.getCurrentProgramForEpg)
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce(program);
vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(API.getCurrentProgramForEpg).toHaveBeenCalledTimes(2);
expect(result.current.currentProgram).toEqual(program);
expect(result.current.hasFetchedProgram).toBe(true);
});
it('resolves to null after all retries are exhausted by persistent errors', async () => {
vi.mocked(API.getCurrentProgramForEpg).mockRejectedValue(
new Error('Persistent error')
);
vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useEpgPreview('epg-1'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).toBe(null);
expect(result.current.isLoadingProgram).toBe(false);
expect(result.current.hasFetchedProgram).toBe(true);
});
});
// epgDataId changes
describe('epgDataId changes', () => {
it('resets to defaults when epgDataId changes to null', async () => {
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({
id: 1,
title: 'News',
});
const { result, rerender } = renderHook(({ id }) => useEpgPreview(id), {
initialProps: { id: 'epg-1' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).not.toBe(null);
rerender({ id: null });
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).toBe(null);
expect(result.current.isLoadingProgram).toBe(false);
expect(result.current.hasFetchedProgram).toBe(false);
});
it('resets to defaults when epgDataId changes to "0"', async () => {
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({
id: 1,
title: 'News',
});
const { result, rerender } = renderHook(({ id }) => useEpgPreview(id), {
initialProps: { id: 'epg-1' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
rerender({ id: '0' });
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.currentProgram).toBe(null);
expect(result.current.isLoadingProgram).toBe(false);
expect(result.current.hasFetchedProgram).toBe(false);
});
it('fetches with the new epgDataId when it changes to another valid value', async () => {
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({ id: 1 });
const { rerender } = renderHook(({ id }) => useEpgPreview(id), {
initialProps: { id: 'epg-1' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
rerender({ id: 'epg-2' });
await act(async () => {
await vi.runAllTimersAsync();
});
expect(API.getCurrentProgramForEpg).toHaveBeenCalledWith('epg-1');
expect(API.getCurrentProgramForEpg).toHaveBeenCalledWith('epg-2');
});
});
// Cleanup on unmount
describe('cleanup on unmount', () => {
it('does not update currentProgram after unmount', async () => {
let resolveProgram;
vi.mocked(API.getCurrentProgramForEpg).mockReturnValue(
new Promise((resolve) => {
resolveProgram = resolve;
})
);
const { result, unmount } = renderHook(() => useEpgPreview('epg-1'));
// Unmount before the API call resolves
unmount();
// Resolve the pending request
await act(async () => {
resolveProgram({ id: 1, title: 'Late News' });
await vi.runAllTimersAsync();
});
// currentProgram should remain null the cancelled flag prevented the update
expect(result.current.currentProgram).toBe(null);
});
});
});

View file

@ -1,6 +1,10 @@
import { useEffect, useState } from 'react';
import API from '../api';
const getCurrentProgramForEpg = (epgId) => {
return API.getCurrentProgramForEpg(epgId);
};
export const useEpgPreview = (epgDataId) => {
const [currentProgram, setCurrentProgram] = useState(null);
const [isLoadingProgram, setIsLoadingProgram] = useState(false);
@ -28,7 +32,7 @@ export const useEpgPreview = (epgDataId) => {
if (cancelled || Date.now() - startTime > deadlineMs) break;
try {
const program = await API.getCurrentProgramForEpg(epgDataId);
const program = await getCurrentProgramForEpg(epgDataId);
if (cancelled) return;
if (program && program.parsing && attempt < maxRetries) {

View file

@ -18,6 +18,14 @@ import { SquarePlus, Webhook, FileCode, Logs } from 'lucide-react';
import ConnectionForm from '../components/forms/Connection';
import { SUBSCRIPTION_EVENTS } from '../constants';
const deleteConnectIntegration = (id) => {
return API.deleteConnectIntegration(id);
};
const updateConnectIntegration = (id, values) => {
return API.updateConnectIntegration(id, values);
};
export default function ConnectPage() {
const { integrations, isLoading, fetchIntegrations } = useConnectStore();
const theme = useMantineTheme();
@ -40,7 +48,7 @@ export default function ConnectPage() {
const deleteConnection = async (id) => {
console.log('Deleting connection', id);
await API.deleteConnectIntegration(id);
await deleteConnectIntegration(id);
};
return (
@ -99,15 +107,14 @@ function IntegrationRow({ integration, editConnection, deleteConnection }) {
const toggleIntegration = async () => {
try {
await API.updateConnectIntegration(integration.id, {
await updateConnectIntegration(integration.id, {
...integration,
enabled: !enabled,
});
setEnabled(!enabled);
} catch (error) {
console.error('Failed to update integration', error);
} finally {
}
}
};
return (

View file

@ -18,6 +18,10 @@ import { SUBSCRIPTION_EVENTS } from '../constants';
import { CustomTable, useTable } from '../components/tables/CustomTable';
import { copyToClipboard } from '../utils';
const getConnectLogs = (params) => {
return API.getConnectLogs(params);
};
export default function ConnectLogsPage() {
const { integrations, fetchIntegrations } = useConnectStore();
@ -51,7 +55,7 @@ export default function ConnectLogsPage() {
if (filters.type) params.type = filters.type;
if (filters.integration) params.integration = filters.integration;
const data = await API.getConnectLogs(params);
const data = await getConnectLogs(params);
const results = Array.isArray(data) ? data : data?.results || [];
setLogs(results);
setCount(data?.count || results.length || 0);

View file

@ -7,66 +7,34 @@ import {
Button,
Group,
Loader,
Modal,
NativeSelect,
NumberInput,
Pagination,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
} from '@mantine/core';
import API from '../api.js';
import {
RefreshCcw,
Trash2,
Plus,
Search,
KeyRound,
ShieldCheck,
ShieldAlert,
Package,
} from 'lucide-react';
import { Package, RefreshCcw, Search } from 'lucide-react';
import { usePluginStore } from '../store/plugins.jsx';
import useSettingsStore from '../store/settings.jsx';
import AvailablePluginCard from '../components/cards/AvailablePluginCard.jsx';
import ManageReposModal from '../components/modals/ManageReposModal.jsx';
import { showNotification } from '../utils/notificationUtils.js';
import { reloadPlugins } from '../utils/pages/PluginsUtils.js';
import { compareVersions } from '../utils/components/pluginUtils.js';
export default function PluginBrowsePage() {
const repos = usePluginStore((s) => s.repos);
const reposLoading = usePluginStore((s) => s.reposLoading);
const availablePlugins = usePluginStore((s) => s.availablePlugins);
const availableLoading = usePluginStore((s) => s.availableLoading);
const fetchRepos = usePluginStore((s) => s.fetchRepos);
const fetchAvailablePlugins = usePluginStore((s) => s.fetchAvailablePlugins);
const refreshRepo = usePluginStore((s) => s.refreshRepo);
const addRepo = usePluginStore((s) => s.addRepo);
const removeRepo = usePluginStore((s) => s.removeRepo);
const updateRepo = usePluginStore((s) => s.updateRepo);
const appVersion = useSettingsStore((s) => s.version?.version || '');
const [repoModalOpen, setRepoModalOpen] = useState(false);
const [newRepoUrl, setNewRepoUrl] = useState('');
const [newRepoPublicKey, setNewRepoPublicKey] = useState('');
const [addingRepo, setAddingRepo] = useState(false);
const [refreshingAll, setRefreshingAll] = useState(false);
const [editingKeyRepoId, setEditingKeyRepoId] = useState(null);
const [editKeyValue, setEditKeyValue] = useState('');
const [savingKey, setSavingKey] = useState(false);
const [showAddRepo, setShowAddRepo] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
const [gpgKeyFocused, setGpgKeyFocused] = useState(false);
const [repoPreview, setRepoPreview] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const previewTimer = useRef(null);
const [refreshInterval, setRefreshInterval] = useState(6);
const [savingInterval, setSavingInterval] = useState(false);
const saveIntervalTimer = useRef(null);
const recentlyInstalledSlugs = useRef(new Set());
const recentlyUpdatedSlugs = useRef(new Set());
@ -121,130 +89,6 @@ export default function PluginBrowsePage() {
}
}, [refreshRepo, fetchAvailablePlugins]);
const handleAddRepo = useCallback(async () => {
if (!newRepoUrl.trim()) return;
setAddingRepo(true);
try {
await addRepo({
url: newRepoUrl.trim(),
public_key: newRepoPublicKey.trim(),
});
setNewRepoUrl('');
setNewRepoPublicKey('');
setRepoPreview(null);
setShowAddRepo(false);
await fetchAvailablePlugins();
showNotification({
title: 'Added',
message: 'Plugin repo added',
color: 'green',
});
} catch {
// Error notification handled by API layer
} finally {
setAddingRepo(false);
}
}, [newRepoUrl, newRepoPublicKey, addRepo, fetchAvailablePlugins]);
const handleDeleteRepo = useCallback(
async (id) => {
await removeRepo(id);
setDeleteConfirmId(null);
await fetchAvailablePlugins();
showNotification({
title: 'Removed',
message: 'Plugin repo removed',
color: 'green',
});
},
[removeRepo, fetchAvailablePlugins]
);
const handleEditKey = useCallback((repo) => {
setEditingKeyRepoId(repo.id);
setEditKeyValue(repo.public_key || '');
}, []);
const handleSaveKey = useCallback(async () => {
if (editingKeyRepoId == null) return;
setSavingKey(true);
try {
await updateRepo(editingKeyRepoId, { public_key: editKeyValue });
await refreshRepo(editingKeyRepoId);
await fetchAvailablePlugins();
showNotification({
title: 'Updated',
message: 'Public key updated',
color: 'green',
});
setEditingKeyRepoId(null);
setEditKeyValue('');
} catch {
showNotification({
title: 'Error',
message: 'Failed to update key',
color: 'red',
});
} finally {
setSavingKey(false);
}
}, [
editingKeyRepoId,
editKeyValue,
updateRepo,
refreshRepo,
fetchAvailablePlugins,
]);
const loadRepoSettings = useCallback(async () => {
const data = await API.getPluginRepoSettings();
if (data) setRefreshInterval(data.refresh_interval_hours ?? 6);
}, []);
const handleSaveInterval = useCallback((val) => {
const hours = val ?? 0;
setRefreshInterval(hours);
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
saveIntervalTimer.current = setTimeout(async () => {
setSavingInterval(true);
try {
await API.updatePluginRepoSettings({ refresh_interval_hours: hours });
} catch {
// Error notification handled by API layer
} finally {
setSavingInterval(false);
}
}, 800);
}, []);
// Debounced manifest preview
const fetchPreview = useCallback((url, publicKey) => {
if (previewTimer.current) clearTimeout(previewTimer.current);
if (!url.trim() || !url.match(/^https?:\/\/.+/i)) {
setRepoPreview(null);
setPreviewLoading(false);
return;
}
setPreviewLoading(true);
previewTimer.current = setTimeout(async () => {
const result = await API.previewPluginRepo(url.trim(), publicKey?.trim());
setRepoPreview(result);
setPreviewLoading(false);
}, 600);
}, []);
// Cleanup any pending timers on unmount
useEffect(() => {
return () => {
if (previewTimer.current) clearTimeout(previewTimer.current);
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
};
}, []);
// Load settings when modal opens
useEffect(() => {
if (repoModalOpen) loadRepoSettings();
}, [repoModalOpen, loadRepoSettings]);
const loading = availableLoading && availablePlugins.length === 0;
// Build repo filter options from available plugins
@ -367,153 +211,152 @@ export default function PluginBrowsePage() {
}}
>
<Box p={16} pb={60} style={{ flex: 1 }}>
<Group justify="space-between" mb="md">
<Group gap="xs" align="center">
<Text fw={700} size="lg">
Find Plugins
</Text>
{availablePlugins.length > 0 && (
<Badge variant="light" color="gray" size="sm">
{availablePlugins.length} Plugins Available
</Badge>
)}
{repos.length > 1 && (
<Badge variant="light" color="gray" size="sm">
{repos.length} Repos
</Badge>
)}
<Group justify="space-between" mb="md">
<Group gap="xs" align="center">
<Text fw={700} size="lg">
Find Plugins
</Text>
{availablePlugins.length > 0 && (
<Badge variant="light" color="gray" size="sm">
{availablePlugins.length} Plugins Available
</Badge>
)}
{repos.length > 1 && (
<Badge variant="light" color="gray" size="sm">
{repos.length} Repos
</Badge>
)}
</Group>
<Group>
<Button
size="xs"
variant="light"
color="teal"
component="a"
href="https://github.com/Dispatcharr/Plugins?tab=contributing-ov-file"
target="_blank"
rel="noopener noreferrer"
leftSection={<Package size={14} />}
>
Publish Your Plugin
</Button>
<Button
size="xs"
variant="light"
onClick={() => setRepoModalOpen(true)}
>
Manage Repos
</Button>
<ActionIcon
variant="light"
onClick={handleRefreshAll}
title="Refresh all repos"
loading={refreshingAll}
>
<RefreshCcw size={18} />
</ActionIcon>
</Group>
</Group>
<Group>
<Button
size="xs"
variant="light"
color="teal"
component="a"
href="https://github.com/Dispatcharr/Plugins?tab=contributing-ov-file"
target="_blank"
rel="noopener noreferrer"
leftSection={<Package size={14} />}
>
Publish Your Plugin
</Button>
<Button
size="xs"
variant="light"
onClick={() => setRepoModalOpen(true)}
>
Manage Repos
</Button>
<ActionIcon
variant="light"
onClick={handleRefreshAll}
title="Refresh all repos"
loading={refreshingAll}
>
<RefreshCcw size={18} />
</ActionIcon>
</Group>
</Group>
{loading && <Loader />}
{loading && <Loader />}
{!loading && (
<Group gap="sm" mb="md" wrap="wrap">
<TextInput
placeholder="Search plugins..."
leftSection={<Search size={14} />}
size="xs"
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
style={{ flex: 1, minWidth: 180, maxWidth: 300 }}
/>
<Select
size="xs"
allowDeselect={false}
value={sortBy}
onChange={setSortBy}
data={[
{ value: 'name-asc', label: 'Name A-Z' },
{ value: 'name-desc', label: 'Name Z-A' },
{ value: 'author', label: 'Author' },
{ value: 'updated', label: 'Recently Updated' },
]}
style={{ width: 170 }}
/>
{repoOptions.length > 2 && (
{!loading && (
<Group gap="sm" mb="md" wrap="wrap">
<TextInput
placeholder="Search plugins..."
leftSection={<Search size={14} />}
size="xs"
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
style={{ flex: 1, minWidth: 180, maxWidth: 300 }}
/>
<Select
size="xs"
allowDeselect={false}
value={filterRepo}
onChange={setFilterRepo}
data={repoOptions}
style={{ width: 160 }}
value={sortBy}
onChange={setSortBy}
data={[
{ value: 'name-asc', label: 'Name A-Z' },
{ value: 'name-desc', label: 'Name Z-A' },
{ value: 'author', label: 'Author' },
{ value: 'updated', label: 'Recently Updated' },
]}
style={{ width: 170 }}
/>
)}
<Select
size="xs"
allowDeselect={false}
value={filterStatus}
onChange={setFilterStatus}
data={[
{ value: 'all', label: 'All Plugins' },
{ value: 'installed', label: 'Installed' },
{ value: 'not-installed', label: 'Not Installed' },
{ value: 'compatible', label: 'Compatible' },
]}
style={{ width: 150 }}
/>
</Group>
)}
{repoOptions.length > 2 && (
<Select
size="xs"
allowDeselect={false}
value={filterRepo}
onChange={setFilterRepo}
data={repoOptions}
style={{ width: 160 }}
/>
)}
<Select
size="xs"
allowDeselect={false}
value={filterStatus}
onChange={setFilterStatus}
data={[
{ value: 'all', label: 'All Plugins' },
{ value: 'installed', label: 'Installed' },
{ value: 'not-installed', label: 'Not Installed' },
{ value: 'compatible', label: 'Compatible' },
]}
style={{ width: 150 }}
/>
</Group>
)}
{!loading &&
filteredPlugins.length === 0 &&
availablePlugins.length > 0 && (
{!loading &&
filteredPlugins.length === 0 &&
availablePlugins.length > 0 && (
<Box>
<Text c="dimmed">
No plugins match your filters. Try adjusting your search or
filter criteria.
</Text>
</Box>
)}
{!loading && availablePlugins.length === 0 && (
<Box>
<Text c="dimmed">
No plugins match your filters. Try adjusting your search or filter
criteria.
No plugins available. Try refreshing repos or adding a new plugin
repository.
</Text>
</Box>
)}
{!loading && availablePlugins.length === 0 && (
<Box>
<Text c="dimmed">
No plugins available. Try refreshing repos or adding a new plugin
repository.
</Text>
</Box>
)}
{!loading && filteredPlugins.length > 0 && (
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="md">
{paginatedPlugins.map((p) => (
<AvailablePluginCard
key={`${p.repo_id}-${p.slug}`}
plugin={p}
appVersion={appVersion}
multiRepo={repos.length > 1}
onBeforeInstall={(slug) => {
if (slug) {
if (p.install_status === 'update_available') {
recentlyUpdatedSlugs.current.add(slug);
} else {
recentlyInstalledSlugs.current.add(slug);
{!loading && filteredPlugins.length > 0 && (
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="md">
{paginatedPlugins.map((p) => (
<AvailablePluginCard
key={`${p.repo_id}-${p.slug}`}
plugin={p}
appVersion={appVersion}
multiRepo={repos.length > 1}
onBeforeInstall={(slug) => {
if (slug) {
if (p.install_status === 'update_available') {
recentlyUpdatedSlugs.current.add(slug);
} else {
recentlyInstalledSlugs.current.add(slug);
}
}
}
}}
onInstalled={(slug) => {
if (slug) recentlyInstalledSlugs.current.add(slug);
fetchAvailablePlugins();
}}
onUninstalled={(slug) => {
if (slug) recentlyUninstalledSlugs.current.add(slug);
}}
/>
))}
</SimpleGrid>
)}
}}
onInstalled={(slug) => {
if (slug) recentlyInstalledSlugs.current.add(slug);
fetchAvailablePlugins();
}}
onUninstalled={(slug) => {
if (slug) recentlyUninstalledSlugs.current.add(slug);
}}
/>
))}
</SimpleGrid>
)}
</Box>
{!loading && filteredPlugins.length > 0 && (
@ -551,398 +394,10 @@ export default function PluginBrowsePage() {
</Box>
)}
{/* Manage Repos Modal */}
<Modal
<ManageReposModal
opened={repoModalOpen}
onClose={() => setRepoModalOpen(false)}
title={
<Group justify="space-between" align="flex-start" w="100%">
<div style={{ flex: 1 }}>
<Text fw={600}>Plugin Repositories</Text>
<Text size="sm" c="dimmed" mt={4}>
Add third-party plugin repositories or manage existing ones.
Manifests are fetched automatically at the configured interval.
</Text>
</div>
<div style={{ textAlign: 'left', flexShrink: 0 }}>
<Text size="sm" fw={500} mb={2}>
Refresh Interval
</Text>
<NumberInput
value={refreshInterval}
onChange={handleSaveInterval}
min={0}
max={168}
size="xs"
disabled={savingInterval}
w={115}
/>
<Text size="xs" c="dimmed" mt={2}>
Hours, 0 to disable
</Text>
</div>
</Group>
}
centered
size="lg"
styles={{
title: { width: '100%' },
header: { alignItems: 'flex-start' },
}}
>
<Stack gap="md">
{reposLoading && repos.length === 0 && <Loader size="sm" />}
{repos.map((repo) => (
<React.Fragment key={repo.id}>
<Group
justify="space-between"
align="center"
wrap="nowrap"
style={{
padding: '8px 12px',
borderRadius: 8,
backgroundColor: 'var(--mantine-color-dark-6)',
}}
>
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap="xs" align="center">
<Text fw={500} size="sm" lineClamp={1}>
{repo.name}
</Text>
{repo.is_official && (
<Badge
size="xs"
variant="filled"
style={{ backgroundColor: '#14917E' }}
>
Official Repo
</Badge>
)}
{repo.signature_verified === true && (
<Badge
size="xs"
variant="light"
color="green"
leftSection={<ShieldCheck size={10} />}
>
Verified Signature
</Badge>
)}
{repo.signature_verified === false && (
<Badge
size="xs"
variant="light"
color="red"
leftSection={<ShieldAlert size={10} />}
>
Invalid Signature
</Badge>
)}
</Group>
{repo.registry_url ? (
<Text size="xs" c="dimmed" lineClamp={1}>
<a
href={repo.registry_url}
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--mantine-color-blue-4)',
textDecoration: 'none',
}}
>
{repo.registry_url}
</a>
</Text>
) : null}
<Text size="xs" c="dimmed" lineClamp={1}>
{repo.url}
</Text>
{repo.last_fetched && (
<Text
size="xs"
c={
repo.last_fetch_status &&
repo.last_fetch_status !== '200'
? 'red'
: 'dimmed'
}
>
Last fetched:{' '}
{new Date(repo.last_fetched).toLocaleString()}
{repo.last_fetch_status &&
repo.last_fetch_status !== '200'
? ` · ${repo.last_fetch_status}`
: repo.plugin_count != null
? ` · ${repo.plugin_count} plugin${repo.plugin_count !== 1 ? 's' : ''} available`
: ''}
</Text>
)}
</Box>
{!repo.is_official && (
<Stack gap={4} align="center">
<ActionIcon
variant="subtle"
color="gray"
title="Edit public key"
onClick={() => handleEditKey(repo)}
>
<KeyRound size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
title="Remove repo"
onClick={() => setDeleteConfirmId(repo.id)}
>
<Trash2 size={16} />
</ActionIcon>
</Stack>
)}
</Group>
{editingKeyRepoId === repo.id && (
<Stack gap="xs" mt="xs">
<Textarea
placeholder={
'-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nOptional: Paste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
}
value={editKeyValue}
onChange={(e) => setEditKeyValue(e.currentTarget.value)}
size="xs"
minRows={3}
autosize
/>
<Group gap="xs">
<Button
size="xs"
onClick={handleSaveKey}
loading={savingKey}
>
Save Key
</Button>
<Button
size="xs"
variant="subtle"
color="gray"
onClick={() => setEditingKeyRepoId(null)}
>
Cancel
</Button>
</Group>
</Stack>
)}
</React.Fragment>
))}
{!showAddRepo ? (
<Button
variant="light"
leftSection={<Plus size={16} />}
size="sm"
onClick={() => setShowAddRepo(true)}
>
Add Repository
</Button>
) : (
<>
<Text fw={500} size="sm" mt="sm">
Add Repository
</Text>
<Box
style={{
padding: '8px 12px',
borderRadius: 8,
minHeight: 90,
display: 'flex',
alignItems: 'center',
backgroundColor: 'var(--mantine-color-dark-6)',
border:
repoPreview && !previewLoading && !repoPreview.valid
? '1px solid var(--mantine-color-red-7)'
: 'none',
}}
>
{previewLoading ? (
<Group gap="xs" align="center">
<Loader size={14} />
<Text size="xs" c="dimmed">
Checking manifest...
</Text>
</Group>
) : repoPreview ? (
repoPreview.valid ? (
<Box>
<Group gap="xs" align="center">
<Text fw={500} size="sm">
{repoPreview.registry_name}
</Text>
{repoPreview.signature_verified === true && (
<Badge
size="xs"
variant="light"
color="green"
leftSection={<ShieldCheck size={10} />}
>
Verified Signature
</Badge>
)}
{repoPreview.signature_verified === false && (
<>
<Badge
size="xs"
variant="light"
color="gray"
leftSection={<ShieldCheck size={10} />}
>
Signed Manifest
</Badge>
<Text
size="xs"
c="var(--mantine-color-yellow-6)"
fs="italic"
>
Public key required for verification
</Text>
</>
)}
{repoPreview.signature_verified == null && (
<Badge size="xs" variant="light" color="gray">
No Signature
</Badge>
)}
</Group>
{repoPreview.registry_url ? (
<Text size="xs" c="dimmed">
<a
href={repoPreview.registry_url}
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--mantine-color-blue-4)',
textDecoration: 'none',
}}
>
{repoPreview.registry_url}
</a>
</Text>
) : null}
<Text size="xs" c="dimmed" lineClamp={1}>
{newRepoUrl.trim()}
</Text>
<Text size="xs" c="dimmed">
{repoPreview.plugin_count} plugin
{repoPreview.plugin_count !== 1 ? 's' : ''} available
</Text>
</Box>
) : (
<Text size="xs" c="red">
{repoPreview.errors?.join(' ') || 'Invalid manifest'}
</Text>
)
) : (
<Text size="xs" c="yellow">
Third-party repositories are not reviewed by the Dispatcharr
team.
<br />
Adding sources and installing plugins is done at your own
risk.
</Text>
)}
</Box>
<TextInput
placeholder="Repository Manifest URL (ending in .json)"
value={newRepoUrl}
onChange={(e) => {
setNewRepoUrl(e.currentTarget.value);
fetchPreview(e.currentTarget.value, newRepoPublicKey);
}}
size="sm"
/>
<Textarea
placeholder={
gpgKeyFocused
? '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nPaste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
: 'Optional: Paste public GPG key here'
}
value={newRepoPublicKey}
onChange={(e) => {
const value = e.currentTarget.value;
setNewRepoPublicKey(value);
fetchPreview(newRepoUrl, value);
}}
size="sm"
minRows={gpgKeyFocused || newRepoPublicKey ? 4 : 1}
maxRows={8}
autosize
onFocus={() => setGpgKeyFocused(true)}
onBlur={() => {
if (!newRepoPublicKey) setGpgKeyFocused(false);
}}
styles={
repoPreview?.valid &&
repoPreview?.signature_verified === false &&
!newRepoPublicKey.trim()
? {
input: { borderColor: 'var(--mantine-color-yellow-6)' },
}
: undefined
}
/>
<Group gap="xs" justify="flex-end">
<Button
variant="subtle"
color="gray"
size="sm"
onClick={() => {
setShowAddRepo(false);
setNewRepoUrl('');
setNewRepoPublicKey('');
setRepoPreview(null);
}}
>
Cancel
</Button>
<Button
onClick={handleAddRepo}
loading={addingRepo}
disabled={!newRepoUrl.trim()}
leftSection={<Plus size={16} />}
size="sm"
>
Add Repo
</Button>
</Group>
</>
)}
</Stack>
</Modal>
{/* Delete Confirmation Modal */}
<Modal
opened={deleteConfirmId != null}
onClose={() => setDeleteConfirmId(null)}
title="Remove Repository"
size="sm"
centered
>
<Text size="sm">Are you sure you want to remove this repository?</Text>
<Text size="xs" c="dimmed" mt="xs">
Plugins installed from this repo will remain installed but become
unmanaged.
</Text>
<Group mt="md" justify="flex-end" gap="xs">
<Button
variant="subtle"
color="gray"
onClick={() => setDeleteConfirmId(null)}
>
Cancel
</Button>
<Button color="red" onClick={() => handleDeleteRepo(deleteConfirmId)}>
Remove
</Button>
</Group>
</Modal>
/>
</AppShellMain>
);
}

View file

@ -0,0 +1,395 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// API mock
vi.mock('../../api', () => ({
default: {
deleteConnectIntegration: vi.fn(),
updateConnectIntegration: vi.fn(),
},
}));
// Store mock
vi.mock('../../store/connect', () => ({
default: vi.fn(),
}));
// Constants mock
vi.mock('../../constants', () => ({
SUBSCRIPTION_EVENTS: {
channel_start: 'Channel Started',
channel_stop: 'Channel Stopped',
recording_start: 'Recording Started',
},
}));
// ConnectionForm mock
vi.mock('../../components/forms/Connection', () => ({
default: ({ connection, isOpen, onClose }) =>
isOpen ? (
<div data-testid="connection-form">
<div data-testid="connection-form-id">{connection?.id ?? 'new'}</div>
<button data-testid="connection-form-close" onClick={onClose}>
Close
</button>
</div>
) : null,
}));
// lucide-react
vi.mock('lucide-react', () => ({
SquarePlus: () => <svg data-testid="icon-square-plus" />,
Webhook: () => <svg data-testid="icon-webhook" />,
FileCode: () => <svg data-testid="icon-file-code" />,
Logs: () => <svg data-testid="icon-logs" />,
}));
// @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>
),
Box: ({ children, display, style }) => (
<div data-display={display} style={style}>
{children}
</div>
),
Button: ({ children, onClick, variant, color, size, leftSection }) => (
<button
onClick={onClick}
data-variant={variant}
data-color={color}
data-size={size}
>
{leftSection}
{children}
</button>
),
Card: ({ children }) => <div data-testid="card">{children}</div>,
Flex: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Stack: ({ children }) => <div>{children}</div>,
Switch: ({ label, checked, onChange }) => (
<label>
<input
type="checkbox"
data-testid="toggle-switch"
checked={checked ?? false}
onChange={onChange}
/>
{label}
</label>
),
Text: ({ children, fw }) => <span data-fw={fw}>{children}</span>,
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
useMantineTheme: () => ({
tailwind: { green: { 5: '#22c55e' } },
}),
}));
// Imports after mocks
import ConnectPage from '../Connect';
import API from '../../api';
import useConnectStore from '../../store/connect';
// Shared helpers
const makeIntegration = (overrides = {}) => ({
id: 1,
name: 'My Webhook',
type: 'webhook',
enabled: true,
config: { url: 'https://example.com/hook' },
subscriptions: [
{ event: 'channel_start', enabled: true },
{ event: 'channel_stop', enabled: false },
],
...overrides,
});
const setupStore = (overrides = {}) => {
const fetchIntegrations = vi.fn();
vi.mocked(useConnectStore).mockReturnValue({
integrations: [],
isLoading: false,
fetchIntegrations,
...overrides,
});
return { fetchIntegrations };
};
//
describe('ConnectPage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(API.deleteConnectIntegration).mockResolvedValue(undefined);
vi.mocked(API.updateConnectIntegration).mockResolvedValue(undefined);
});
// Initialization
describe('initialization', () => {
it('calls fetchIntegrations on mount', () => {
const { fetchIntegrations } = setupStore();
render(<ConnectPage />);
expect(fetchIntegrations).toHaveBeenCalledTimes(1);
});
});
// Loading state
describe('loading state', () => {
it('shows loading indicator when isLoading is true', () => {
setupStore({ isLoading: true });
render(<ConnectPage />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('does not show loading indicator when isLoading is false', () => {
setupStore({ isLoading: false });
render(<ConnectPage />);
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
});
// Integration list
describe('integration list', () => {
it('renders a card for each integration', () => {
setupStore({
integrations: [
makeIntegration({ id: 1 }),
makeIntegration({ id: 2, name: 'Other' }),
],
});
render(<ConnectPage />);
expect(screen.getAllByTestId('card')).toHaveLength(2);
});
it('renders integration names', () => {
setupStore({ integrations: [makeIntegration({ name: 'Plex Hook' })] });
render(<ConnectPage />);
expect(screen.getByText('Plex Hook')).toBeInTheDocument();
});
it('shows no cards when integrations list is empty', () => {
setupStore({ integrations: [] });
render(<ConnectPage />);
expect(screen.queryByTestId('card')).not.toBeInTheDocument();
});
});
// New Connection button
describe('"New Connection" button', () => {
it('renders the New Connection button', () => {
setupStore();
render(<ConnectPage />);
expect(screen.getByText('New Connection')).toBeInTheDocument();
});
it('ConnectionForm is not visible initially', () => {
setupStore();
render(<ConnectPage />);
expect(screen.queryByTestId('connection-form')).not.toBeInTheDocument();
});
it('opens ConnectionForm with no connection when New Connection is clicked', () => {
setupStore();
render(<ConnectPage />);
fireEvent.click(screen.getByText('New Connection'));
expect(screen.getByTestId('connection-form')).toBeInTheDocument();
expect(screen.getByTestId('connection-form-id')).toHaveTextContent('new');
});
it('closes ConnectionForm when its close button is clicked', () => {
setupStore();
render(<ConnectPage />);
fireEvent.click(screen.getByText('New Connection'));
fireEvent.click(screen.getByTestId('connection-form-close'));
expect(screen.queryByTestId('connection-form')).not.toBeInTheDocument();
});
});
// Edit connection
describe('edit connection', () => {
it('opens ConnectionForm with the integration when Edit is clicked', () => {
const integration = makeIntegration({ id: 7, name: 'My Hook' });
setupStore({ integrations: [integration] });
render(<ConnectPage />);
fireEvent.click(screen.getByText('Edit'));
expect(screen.getByTestId('connection-form')).toBeInTheDocument();
expect(screen.getByTestId('connection-form-id')).toHaveTextContent('7');
});
});
// Delete connection
describe('delete connection', () => {
it('calls deleteConnectIntegration with the integration id when Delete is clicked', async () => {
setupStore({ integrations: [makeIntegration({ id: 3 })] });
render(<ConnectPage />);
fireEvent.click(screen.getByText('Delete'));
await waitFor(() => {
expect(API.deleteConnectIntegration).toHaveBeenCalledWith(3);
});
});
});
});
//
describe('IntegrationRow', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(API.updateConnectIntegration).mockResolvedValue(undefined);
vi.mocked(API.deleteConnectIntegration).mockResolvedValue(undefined);
});
const renderRow = (integrationOverrides = {}) => {
const integration = makeIntegration(integrationOverrides);
const { fetchIntegrations } = setupStore({ integrations: [integration] });
render(<ConnectPage />);
return { integration, fetchIntegrations };
};
// Type icons
describe('type icons', () => {
it('shows webhook icon for webhook type', () => {
renderRow({ type: 'webhook' });
expect(screen.getByTestId('icon-webhook')).toBeInTheDocument();
});
it('shows file code icon for non-webhook type', () => {
renderRow({ type: 'script' });
expect(screen.getByTestId('icon-file-code')).toBeInTheDocument();
});
});
// Target display
describe('target display', () => {
it('shows webhook URL for webhook type', () => {
renderRow({
type: 'webhook',
config: { url: 'https://hooks.example.com' },
});
expect(screen.getByText('https://hooks.example.com')).toBeInTheDocument();
});
it('shows script path for non-webhook type', () => {
renderRow({ type: 'script', config: { path: '/scripts/my-script.sh' } });
expect(screen.getByText('/scripts/my-script.sh')).toBeInTheDocument();
});
});
// Enabled switch
describe('enabled switch', () => {
it('renders checked when integration.enabled is true', () => {
renderRow({ enabled: true });
expect(screen.getByTestId('toggle-switch')).toBeChecked();
});
it('renders unchecked when integration.enabled is false', () => {
renderRow({ enabled: false });
expect(screen.getByTestId('toggle-switch')).not.toBeChecked();
});
it('calls updateConnectIntegration with toggled enabled value on toggle', async () => {
renderRow({ id: 5, enabled: true });
fireEvent.click(screen.getByTestId('toggle-switch'));
await waitFor(() => {
expect(API.updateConnectIntegration).toHaveBeenCalledWith(
5,
expect.objectContaining({ enabled: false })
);
});
});
it('toggles from false to true', async () => {
renderRow({ id: 5, enabled: false });
fireEvent.click(screen.getByTestId('toggle-switch'));
await waitFor(() => {
expect(API.updateConnectIntegration).toHaveBeenCalledWith(
5,
expect.objectContaining({ enabled: true })
);
});
});
it('does not throw when updateConnectIntegration fails', async () => {
vi.mocked(API.updateConnectIntegration).mockRejectedValue(
new Error('fail')
);
vi.spyOn(console, 'error').mockImplementation(() => {});
renderRow({ enabled: true });
await expect(
waitFor(() => fireEvent.click(screen.getByTestId('toggle-switch')))
).resolves.not.toThrow();
});
});
// Subscription badges
describe('subscription badges', () => {
it('renders a badge for each enabled subscription', () => {
renderRow({
subscriptions: [
{ event: 'channel_start', enabled: true },
{ event: 'recording_start', enabled: true },
],
});
expect(screen.getByText('Channel Started')).toBeInTheDocument();
expect(screen.getByText('Recording Started')).toBeInTheDocument();
});
it('does not render badges for disabled subscriptions', () => {
renderRow({
subscriptions: [
{ event: 'channel_start', enabled: true },
{ event: 'channel_stop', enabled: false },
],
});
expect(screen.getByText('Channel Started')).toBeInTheDocument();
expect(screen.queryByText('Channel Stopped')).not.toBeInTheDocument();
});
it('falls back to the raw event name when not in SUBSCRIPTION_EVENTS', () => {
renderRow({
subscriptions: [{ event: 'custom_event', enabled: true }],
});
expect(screen.getByText('custom_event')).toBeInTheDocument();
});
});
// Action buttons
describe('action buttons', () => {
it('opens ConnectionForm with the integration when Edit is clicked', () => {
const integration = makeIntegration({ id: 9, name: 'Test Hook' });
setupStore({ integrations: [integration] });
render(<ConnectPage />);
fireEvent.click(screen.getByText('Edit'));
expect(screen.getByTestId('connection-form-id')).toHaveTextContent('9');
});
it('calls deleteConnectIntegration with the correct id when Delete is clicked', async () => {
renderRow({ id: 11 });
fireEvent.click(screen.getByText('Delete'));
await waitFor(() => {
expect(API.deleteConnectIntegration).toHaveBeenCalledWith(11);
});
});
});
});

View file

@ -0,0 +1,462 @@
import {
render,
screen,
fireEvent,
waitFor,
act,
within,
} from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// API mock
vi.mock('../../api', () => ({
default: {
getConnectLogs: vi.fn(),
},
}));
// Store mock
vi.mock('../../store/connect', () => ({
default: vi.fn(),
}));
// Constants mock
vi.mock('../../constants', () => ({
SUBSCRIPTION_EVENTS: {
channel_start: 'Channel Started',
channel_stop: 'Channel Stopped',
},
}));
// CustomTable mock
vi.mock('../../components/tables/CustomTable', () => ({
CustomTable: () => <div data-testid="custom-table" />,
useTable: vi.fn(() => ({})),
}));
// Utils mock
vi.mock('../../utils', () => ({
copyToClipboard: vi.fn(),
}));
// lucide-react
vi.mock('lucide-react', () => ({
FileCode: () => <svg data-testid="icon-file-code" />,
Webhook: () => <svg data-testid="icon-webhook" />,
}));
// @mantine/core
vi.mock('@mantine/core', () => ({
Badge: ({ children, color, variant }) => (
<span data-testid="badge" data-color={color} data-variant={variant}>
{children}
</span>
),
Box: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
LoadingOverlay: ({ visible }) =>
visible ? <div data-testid="loading-overlay" /> : null,
NativeSelect: ({ value, onChange, data }) => (
<select data-testid="page-size-select" value={value} onChange={onChange}>
{data?.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
),
Pagination: ({ total, value, onChange }) => (
<div data-testid="pagination">
<span data-testid="pagination-total">{total}</span>
<button
data-testid="next-page"
onClick={() => onChange(value + 1)}
disabled={value >= total}
>
Next
</button>
</div>
),
Paper: ({ children }) => <div>{children}</div>,
// Distinguish type filter (has 'webhook' option) from integration filter
Select: ({ data, value, onChange }) => {
const isTypeFilter = data?.some((d) => d.value === 'webhook');
return (
<select
data-testid={isTypeFilter ? 'select-type' : 'select-integration'}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
>
{data?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
},
Text: ({ children, size }) => <span data-size={size}>{children}</span>,
Title: ({ children, order }) => <h3 data-order={order}>{children}</h3>,
}));
// Imports after mocks
import ConnectLogsPage from '../ConnectLogs';
import API from '../../api';
import useConnectStore from '../../store/connect';
// Shared helpers
const makeIntegration = (overrides = {}) => ({
id: 1,
name: 'My Webhook',
type: 'webhook',
...overrides,
});
const setupStore = ({
integrations = [],
fetchIntegrations = vi.fn(),
} = {}) => {
vi.mocked(useConnectStore).mockReturnValue({
integrations,
fetchIntegrations,
});
return { fetchIntegrations };
};
const setupApiResponse = (overrides = {}) => {
vi.mocked(API.getConnectLogs).mockResolvedValue({
results: [],
count: 0,
...overrides,
});
};
//
describe('ConnectLogsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
setupApiResponse();
setupStore();
});
// Rendering
describe('rendering', () => {
it('renders the "Connect Logs" title', () => {
render(<ConnectLogsPage />);
expect(screen.getByText('Connect Logs')).toBeInTheDocument();
});
it('renders the type filter select', () => {
render(<ConnectLogsPage />);
expect(screen.getByTestId('select-type')).toBeInTheDocument();
});
it('renders the integration filter select', () => {
render(<ConnectLogsPage />);
expect(screen.getByTestId('select-integration')).toBeInTheDocument();
});
it('renders the table', () => {
render(<ConnectLogsPage />);
expect(screen.getByTestId('custom-table')).toBeInTheDocument();
});
it('renders the page size select and pagination', () => {
render(<ConnectLogsPage />);
expect(screen.getByTestId('page-size-select')).toBeInTheDocument();
expect(screen.getByTestId('pagination')).toBeInTheDocument();
});
});
// Integration initialization
describe('integration initialization', () => {
it('calls fetchIntegrations on mount when integrations list is empty', async () => {
const { fetchIntegrations } = setupStore({ integrations: [] });
render(<ConnectLogsPage />);
await waitFor(() => {
expect(fetchIntegrations).toHaveBeenCalled();
});
});
it('does not call fetchIntegrations when integrations are already loaded', async () => {
const { fetchIntegrations } = setupStore({
integrations: [makeIntegration()],
});
render(<ConnectLogsPage />);
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalled();
});
expect(fetchIntegrations).not.toHaveBeenCalled();
});
});
// API calls
describe('API calls', () => {
it('calls getConnectLogs on mount with page=1 and page_size=50', async () => {
render(<ConnectLogsPage />);
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalledWith(
expect.objectContaining({ page: 1, page_size: 50 })
);
});
});
it('does not include type in params when type filter is empty', async () => {
render(<ConnectLogsPage />);
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalled();
});
const callArgs = vi.mocked(API.getConnectLogs).mock.calls[0][0];
expect(callArgs).not.toHaveProperty('type');
});
it('does not include integration in params when integration filter is empty', async () => {
render(<ConnectLogsPage />);
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalled();
});
const callArgs = vi.mocked(API.getConnectLogs).mock.calls[0][0];
expect(callArgs).not.toHaveProperty('integration');
});
it('handles an array API response', async () => {
vi.mocked(API.getConnectLogs).mockResolvedValue([]);
render(<ConnectLogsPage />);
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalled();
expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument();
});
});
it('extracts count from a paginated API response', async () => {
vi.mocked(API.getConnectLogs).mockResolvedValue({
results: [],
count: 77,
});
render(<ConnectLogsPage />);
await waitFor(() => {
expect(screen.getByText(/77/)).toBeInTheDocument();
});
});
});
// Loading state
describe('loading state', () => {
it('shows LoadingOverlay while the fetch is in progress', async () => {
let resolve;
vi.mocked(API.getConnectLogs).mockReturnValue(
new Promise((r) => {
resolve = r;
})
);
render(<ConnectLogsPage />);
await waitFor(() => {
expect(screen.getByTestId('loading-overlay')).toBeInTheDocument();
});
await act(async () => {
resolve({ results: [], count: 0 });
});
});
it('hides LoadingOverlay after the fetch completes', async () => {
render(<ConnectLogsPage />);
await waitFor(() => {
expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument();
});
});
});
// Pagination string
describe('pagination string', () => {
it('shows the correct range and total when results are returned', async () => {
vi.mocked(API.getConnectLogs).mockResolvedValue({
results: [],
count: 120,
});
render(<ConnectLogsPage />);
await waitFor(() => {
expect(screen.getByText('Showing 1-50 of 120')).toBeInTheDocument();
});
});
it('shows "Showing 1-0 of 0" when there are no logs', async () => {
render(<ConnectLogsPage />);
await waitFor(() => {
expect(screen.getByText('Showing 1-0 of 0')).toBeInTheDocument();
});
});
});
// Page count
describe('page count', () => {
it('computes pageCount as ceil(count / pageSize)', async () => {
vi.mocked(API.getConnectLogs).mockResolvedValue({
results: [],
count: 110,
});
render(<ConnectLogsPage />);
await waitFor(() => {
// ceil(110 / 50) = 3
expect(screen.getByTestId('pagination-total')).toHaveTextContent('3');
});
});
it('shows at least 1 page when count is 0', async () => {
render(<ConnectLogsPage />);
await waitFor(() => {
expect(screen.getByTestId('pagination-total')).toHaveTextContent('1');
});
});
});
// Type filter
describe('type filter', () => {
it('populates type filter with All, Webhooks, Scripts options', () => {
render(<ConnectLogsPage />);
const typeSelect = screen.getByTestId('select-type');
expect(
within(typeSelect).getByRole('option', { name: 'All' })
).toBeInTheDocument();
expect(
within(typeSelect).getByRole('option', { name: 'Webhooks' })
).toBeInTheDocument();
expect(
within(typeSelect).getByRole('option', { name: 'Scripts' })
).toBeInTheDocument();
});
it('refetches with type param when type filter changes', async () => {
render(<ConnectLogsPage />);
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByTestId('select-type'), {
target: { value: 'webhook' },
});
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalledWith(
expect.objectContaining({ type: 'webhook' })
);
});
});
it('omits type from params when filter is reset to empty', async () => {
render(<ConnectLogsPage />);
fireEvent.change(screen.getByTestId('select-type'), {
target: { value: 'webhook' },
});
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalledWith(
expect.objectContaining({ type: 'webhook' })
);
});
vi.mocked(API.getConnectLogs).mockClear();
fireEvent.change(screen.getByTestId('select-type'), {
target: { value: '' },
});
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalled();
const args = vi.mocked(API.getConnectLogs).mock.calls[0][0];
expect(args).not.toHaveProperty('type');
});
});
});
// Integration filter
describe('integration filter', () => {
it('populates integration select from store integrations', async () => {
setupStore({
integrations: [makeIntegration({ id: 3, name: 'Plex Hook' })],
});
render(<ConnectLogsPage />);
await waitFor(() => {
expect(
screen.getByRole('option', { name: 'Plex Hook' })
).toBeInTheDocument();
});
});
it('refetches with integration param when integration filter changes', async () => {
setupStore({
integrations: [makeIntegration({ id: 5, name: 'Hook 5' })],
});
render(<ConnectLogsPage />);
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByTestId('select-integration'), {
target: { value: '5' },
});
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalledWith(
expect.objectContaining({ integration: '5' })
);
});
});
});
// Page size change
describe('page size change', () => {
it('refetches with new page_size when page size is changed', async () => {
render(<ConnectLogsPage />);
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByTestId('page-size-select'), {
target: { value: '100' },
});
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalledWith(
expect.objectContaining({ page_size: 100 })
);
});
});
it('resets to page 1 when page size changes', async () => {
render(<ConnectLogsPage />);
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByTestId('page-size-select'), {
target: { value: '25' },
});
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalledWith(
expect.objectContaining({ page: 1 })
);
});
});
});
// Page navigation
describe('page navigation', () => {
it('refetches with page 2 when the next page button is clicked', async () => {
vi.mocked(API.getConnectLogs).mockResolvedValue({
results: [],
count: 100,
});
render(<ConnectLogsPage />);
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByTestId('next-page'));
await waitFor(() => {
expect(API.getConnectLogs).toHaveBeenCalledWith(
expect.objectContaining({ page: 2 })
);
});
});
});
});

View file

@ -0,0 +1,631 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Store mocks
vi.mock('../../store/plugins.jsx', () => {
const mockStore = vi.fn();
mockStore.getState = vi.fn(() => ({ repos: [], invalidatePlugins: vi.fn() }));
return { usePluginStore: mockStore };
});
vi.mock('../../store/settings.jsx', () => ({
default: vi.fn(),
}));
// Component mocks
vi.mock('../../components/cards/AvailablePluginCard.jsx', () => ({
default: ({ plugin }) => (
<div data-testid="plugin-card" data-slug={plugin.slug}>
{plugin.name}
</div>
),
}));
vi.mock('../../components/modals/ManageReposModal.jsx', () => ({
default: ({ opened, onClose }) =>
opened ? (
<div data-testid="manage-repos-modal">
<button data-testid="manage-repos-close" onClick={onClose}>
Close
</button>
</div>
) : null,
}));
// Utility mocks
vi.mock('../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('../../utils/pages/PluginsUtils.js', () => ({
reloadPlugins: vi.fn(),
}));
vi.mock('../../utils/components/pluginUtils.js', () => ({
compareVersions: vi.fn(() => 0),
}));
// lucide-react
vi.mock('lucide-react', () => ({
Package: () => <svg data-testid="icon-package" />,
RefreshCcw: () => <svg data-testid="icon-refresh" />,
Search: () => <svg data-testid="icon-search" />,
}));
// @mantine/core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, loading, title }) => (
<button
data-testid="action-icon"
data-loading={loading}
title={title}
onClick={onClick}
>
{children}
</button>
),
AppShellMain: ({ children }) => <main>{children}</main>,
Badge: ({ children, color, variant }) => (
<span data-testid="badge" data-color={color} data-variant={variant}>
{children}
</span>
),
Box: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, href, variant, color, leftSection }) => (
<button
onClick={onClick}
data-href={href}
data-variant={variant}
data-color={color}
>
{leftSection}
{children}
</button>
),
Group: ({ children }) => <div>{children}</div>,
Loader: () => <div data-testid="loader" />,
NativeSelect: ({ value, onChange, data }) => (
<select data-testid="page-size-select" value={value} onChange={onChange}>
{data?.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
),
Pagination: ({ total, value, onChange }) => (
<div data-testid="pagination">
<span data-testid="pagination-total">{total}</span>
<span data-testid="pagination-value">{value}</span>
<button
data-testid="next-page"
onClick={() => onChange(value + 1)}
disabled={value >= total}
>
Next
</button>
</div>
),
Select: ({ data, value, onChange }) => {
const isSort = data?.some((d) => d.value === 'name-asc');
const isStatus = data?.some((d) => d.value === 'installed');
const testId = isSort
? 'sort-select'
: isStatus
? 'status-select'
: 'repo-select';
return (
<select
data-testid={testId}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
>
{data?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
},
SimpleGrid: ({ children }) => <div data-testid="plugin-grid">{children}</div>,
Text: ({ children, fw, size, c }) => (
<span data-fw={fw} data-size={size} data-color={c}>
{children}
</span>
),
TextInput: ({ value, onChange, placeholder }) => (
<input
data-testid="search-input"
value={value ?? ''}
onChange={onChange}
placeholder={placeholder}
/>
),
}));
// Imports after mocks
import PluginBrowsePage from '../PluginBrowse';
import { usePluginStore } from '../../store/plugins.jsx';
import useSettingsStore from '../../store/settings.jsx';
import { showNotification } from '../../utils/notificationUtils.js';
import { reloadPlugins } from '../../utils/pages/PluginsUtils.js';
// Shared helpers
const makePlugin = (overrides = {}) => ({
slug: 'my-plugin',
repo_id: 1,
repo_name: 'Main Repo',
name: 'My Plugin',
description: 'A test plugin',
author: 'Test Author',
installed: false,
install_status: null,
deprecated: false,
min_dispatcharr_version: null,
max_dispatcharr_version: null,
last_updated: '2024-01-01',
...overrides,
});
const makeRepo = (overrides = {}) => ({
id: 1,
name: 'Main Repo',
url: 'https://example.com',
...overrides,
});
let mockFetchRepos;
let mockFetchAvailablePlugins;
let mockRefreshRepo;
let mockInvalidatePlugins;
const setupStore = ({
repos = [],
availablePlugins = [],
availableLoading = false,
} = {}) => {
mockFetchRepos = vi.fn();
mockFetchAvailablePlugins = vi.fn();
mockRefreshRepo = vi.fn().mockResolvedValue(undefined);
mockInvalidatePlugins = vi.fn();
vi.mocked(usePluginStore).mockImplementation((sel) =>
sel({
repos,
availablePlugins,
availableLoading,
fetchRepos: mockFetchRepos,
fetchAvailablePlugins: mockFetchAvailablePlugins,
refreshRepo: mockRefreshRepo,
})
);
vi.mocked(usePluginStore).getState.mockReturnValue({
repos,
invalidatePlugins: mockInvalidatePlugins,
});
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ version: { version: '1.0.0' } })
);
};
//
describe('PluginBrowsePage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(reloadPlugins).mockResolvedValue(undefined);
setupStore();
localStorage.clear();
});
// Initialization
describe('initialization', () => {
it('calls fetchRepos and fetchAvailablePlugins on mount', () => {
render(<PluginBrowsePage />);
expect(mockFetchRepos).toHaveBeenCalledTimes(1);
expect(mockFetchAvailablePlugins).toHaveBeenCalledTimes(1);
});
it('does not refetch on re-render (hasFetched guard)', () => {
const { rerender } = render(<PluginBrowsePage />);
rerender(<PluginBrowsePage />);
expect(mockFetchRepos).toHaveBeenCalledTimes(1);
});
});
// Rendering
describe('rendering', () => {
it('renders "Find Plugins" title', () => {
render(<PluginBrowsePage />);
expect(screen.getByText('Find Plugins')).toBeInTheDocument();
});
it('shows plugin count badge when plugins are available', () => {
setupStore({
availablePlugins: [makePlugin(), makePlugin({ slug: 'p2' })],
});
render(<PluginBrowsePage />);
expect(screen.getByText('2 Plugins Available')).toBeInTheDocument();
});
it('does not show plugin count badge when no plugins', () => {
render(<PluginBrowsePage />);
expect(screen.queryByText(/Plugins Available/)).not.toBeInTheDocument();
});
it('shows repo count badge when repos > 1', () => {
setupStore({ repos: [makeRepo(), makeRepo({ id: 2 })] });
render(<PluginBrowsePage />);
expect(screen.getByText('2 Repos')).toBeInTheDocument();
});
it('does not show repo count badge when only 1 repo', () => {
setupStore({ repos: [makeRepo()] });
render(<PluginBrowsePage />);
expect(screen.queryByText(/\d+ Repos/)).not.toBeInTheDocument();
});
it('shows Loader when loading and no plugins yet', () => {
setupStore({ availableLoading: true, availablePlugins: [] });
render(<PluginBrowsePage />);
expect(screen.getByTestId('loader')).toBeInTheDocument();
});
it('does not show Loader when not loading', () => {
setupStore({ availableLoading: false });
render(<PluginBrowsePage />);
expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
});
it('does not show Loader when loading but plugins are already loaded', () => {
setupStore({ availableLoading: true, availablePlugins: [makePlugin()] });
render(<PluginBrowsePage />);
expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
});
});
// Empty states
describe('empty states', () => {
it('shows "No plugins available" when there are no plugins', () => {
render(<PluginBrowsePage />);
expect(
screen.getByText(/No plugins available. Try refreshing repos/)
).toBeInTheDocument();
});
it('shows "No plugins match" when all plugins are filtered out', () => {
setupStore({ availablePlugins: [makePlugin({ name: 'Plex' })] });
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'xyznotfound' },
});
expect(
screen.getByText(/No plugins match your filters/)
).toBeInTheDocument();
});
it('does not show "No plugins match" when there are no plugins at all', () => {
render(<PluginBrowsePage />);
expect(
screen.queryByText(/No plugins match your filters/)
).not.toBeInTheDocument();
});
});
// Plugin cards
describe('plugin cards', () => {
it('renders a card for each visible plugin', () => {
setupStore({
availablePlugins: [
makePlugin({ slug: 'a', name: 'Plugin A' }),
makePlugin({ slug: 'b', name: 'Plugin B' }),
],
});
render(<PluginBrowsePage />);
expect(screen.getAllByTestId('plugin-card')).toHaveLength(2);
});
it('renders no cards when no plugins available', () => {
render(<PluginBrowsePage />);
expect(screen.queryByTestId('plugin-card')).not.toBeInTheDocument();
});
});
// Manage Repos modal
describe('Manage Repos modal', () => {
it('ManageReposModal is not visible initially', () => {
render(<PluginBrowsePage />);
expect(
screen.queryByTestId('manage-repos-modal')
).not.toBeInTheDocument();
});
it('opens ManageReposModal when "Manage Repos" button is clicked', () => {
render(<PluginBrowsePage />);
fireEvent.click(screen.getByText('Manage Repos'));
expect(screen.getByTestId('manage-repos-modal')).toBeInTheDocument();
});
it('closes ManageReposModal when its close handler is called', () => {
render(<PluginBrowsePage />);
fireEvent.click(screen.getByText('Manage Repos'));
fireEvent.click(screen.getByTestId('manage-repos-close'));
expect(
screen.queryByTestId('manage-repos-modal')
).not.toBeInTheDocument();
});
});
// Refresh all
describe('handleRefreshAll', () => {
it('calls refreshRepo for each repo in the store', async () => {
const repos = [makeRepo({ id: 1 }), makeRepo({ id: 2 })];
setupStore({ repos });
vi.mocked(usePluginStore).getState.mockReturnValue({
repos,
invalidatePlugins: mockInvalidatePlugins,
});
render(<PluginBrowsePage />);
fireEvent.click(screen.getByTestId('action-icon'));
await waitFor(() => {
expect(mockRefreshRepo).toHaveBeenCalledWith(1);
expect(mockRefreshRepo).toHaveBeenCalledWith(2);
});
});
it('calls fetchAvailablePlugins, reloadPlugins, and invalidatePlugins after refresh', async () => {
setupStore({ repos: [makeRepo()] });
vi.mocked(usePluginStore).getState.mockReturnValue({
repos: [makeRepo()],
invalidatePlugins: mockInvalidatePlugins,
});
render(<PluginBrowsePage />);
fireEvent.click(screen.getByTestId('action-icon'));
await waitFor(() => {
expect(mockFetchAvailablePlugins).toHaveBeenCalled();
expect(reloadPlugins).toHaveBeenCalled();
expect(mockInvalidatePlugins).toHaveBeenCalled();
});
});
it('shows success notification on successful refresh', async () => {
render(<PluginBrowsePage />);
fireEvent.click(screen.getByTestId('action-icon'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Refreshed', color: 'green' })
);
});
});
it('shows error notification when refresh fails', async () => {
mockRefreshRepo = vi.fn().mockRejectedValue(new Error('Network error'));
vi.mocked(usePluginStore).mockImplementation((sel) =>
sel({
repos: [makeRepo()],
availablePlugins: [],
availableLoading: false,
fetchRepos: vi.fn(),
fetchAvailablePlugins: vi.fn(),
refreshRepo: mockRefreshRepo,
})
);
vi.mocked(usePluginStore).getState.mockReturnValue({
repos: [makeRepo()],
invalidatePlugins: vi.fn(),
});
render(<PluginBrowsePage />);
fireEvent.click(screen.getByTestId('action-icon'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Error', color: 'red' })
);
});
});
});
// Search filter
describe('search filter', () => {
it('filters plugins by name', () => {
setupStore({
availablePlugins: [
makePlugin({ slug: 'plex', name: 'Plex Plugin' }),
makePlugin({ slug: 'other', name: 'Other Plugin' }),
],
});
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'plex' },
});
const cards = screen.getAllByTestId('plugin-card');
expect(cards).toHaveLength(1);
expect(cards[0]).toHaveTextContent('Plex Plugin');
});
it('filters plugins by description', () => {
setupStore({
availablePlugins: [
makePlugin({
slug: 'a',
name: 'Alpha',
description: 'streaming service',
}),
makePlugin({ slug: 'b', name: 'Beta', description: 'other thing' }),
],
});
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'streaming' },
});
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
expect(screen.getByText('Alpha')).toBeInTheDocument();
});
it('filters plugins by author', () => {
setupStore({
availablePlugins: [
makePlugin({ slug: 'a', name: 'A', author: 'alice' }),
makePlugin({ slug: 'b', name: 'B', author: 'bob' }),
],
});
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'alice' },
});
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
});
it('is case-insensitive', () => {
setupStore({ availablePlugins: [makePlugin({ name: 'PlexPlugin' })] });
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'PLEXPLUGIN' },
});
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
});
});
// Status filter
describe('status filter', () => {
it('filters to installed plugins only', () => {
setupStore({
availablePlugins: [
makePlugin({ slug: 'a', installed: true }),
makePlugin({ slug: 'b', installed: false }),
],
});
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('status-select'), {
target: { value: 'installed' },
});
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
});
it('filters to not-installed plugins only', () => {
setupStore({
availablePlugins: [
makePlugin({ slug: 'a', installed: true }),
makePlugin({ slug: 'b', installed: false }),
makePlugin({ slug: 'c', installed: false }),
],
});
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('status-select'), {
target: { value: 'not-installed' },
});
expect(screen.getAllByTestId('plugin-card')).toHaveLength(2);
});
});
// Repo filter
describe('repo filter', () => {
it('does not show repo filter when only one repo', () => {
setupStore({
availablePlugins: [makePlugin({ repo_id: 1, repo_name: 'Repo 1' })],
});
render(<PluginBrowsePage />);
// repoOptions.length = 2 (all + 1 repo), which is NOT > 2, so hidden
expect(screen.queryByTestId('repo-select')).not.toBeInTheDocument();
});
it('shows repo filter when there are multiple repos', () => {
setupStore({
availablePlugins: [
makePlugin({ slug: 'a', repo_id: 1, repo_name: 'Repo 1' }),
makePlugin({ slug: 'b', repo_id: 2, repo_name: 'Repo 2' }),
makePlugin({ slug: 'c', repo_id: 3, repo_name: 'Repo 3' }),
],
});
render(<PluginBrowsePage />);
// repoOptions.length = 4 (all + 3 repos), which IS > 2
expect(screen.getByTestId('repo-select')).toBeInTheDocument();
});
it('filters plugins by selected repo', () => {
setupStore({
availablePlugins: [
makePlugin({ slug: 'a', repo_id: 1, repo_name: 'Repo 1' }),
makePlugin({ slug: 'b', repo_id: 2, repo_name: 'Repo 2' }),
makePlugin({ slug: 'c', repo_id: 3, repo_name: 'Repo 3' }),
],
});
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('repo-select'), {
target: { value: '2' },
});
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
});
});
// Pagination
describe('pagination', () => {
it('shows pagination bar only when there are filtered plugins', () => {
render(<PluginBrowsePage />);
expect(screen.queryByTestId('pagination')).not.toBeInTheDocument();
});
it('shows pagination when plugins are available', () => {
setupStore({ availablePlugins: [makePlugin()] });
render(<PluginBrowsePage />);
expect(screen.getByTestId('pagination')).toBeInTheDocument();
});
it('shows correct pagination range text', () => {
const plugins = Array.from({ length: 5 }, (_, i) =>
makePlugin({ slug: `plugin-${i}`, name: `Plugin ${i}` })
);
setupStore({ availablePlugins: plugins });
render(<PluginBrowsePage />);
expect(screen.getByText('1 to 5 of 5')).toBeInTheDocument();
});
it('saves perPage to localStorage when page size changes', () => {
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
setupStore({ availablePlugins: [makePlugin()] });
render(<PluginBrowsePage />);
fireEvent.change(screen.getByTestId('page-size-select'), {
target: { value: '18' },
});
expect(setItemSpy).toHaveBeenCalledWith('pluginBrowsePerPage', '18');
});
it('reads initial perPage from localStorage', () => {
localStorage.setItem('pluginBrowsePerPage', '27');
const plugins = Array.from({ length: 27 }, (_, i) =>
makePlugin({ slug: `p-${i}`, name: `Plugin ${i}` })
);
setupStore({ availablePlugins: plugins });
render(<PluginBrowsePage />);
expect(screen.getByTestId('page-size-select')).toHaveValue('27');
});
it('resets page to 1 when search query changes', () => {
const plugins = Array.from({ length: 20 }, (_, i) =>
makePlugin({ slug: `p-${i}`, name: `Plugin ${i}` })
);
setupStore({ availablePlugins: plugins });
render(<PluginBrowsePage />);
// Go to page 2
fireEvent.click(screen.getByTestId('next-page'));
expect(screen.getByTestId('pagination-value')).toHaveTextContent('2');
// Change search page resets
fireEvent.change(screen.getByTestId('search-input'), {
target: { value: 'Plugin' },
});
expect(screen.getByTestId('pagination-value')).toHaveTextContent('1');
});
});
});

View file

@ -0,0 +1,36 @@
import * as Yup from 'yup';
import API from '../../api';
import { yupResolver } from '@hookform/resolvers/yup';
export const BUILT_IN_COMMANDS = [
{ value: 'ffmpeg', label: 'FFmpeg' },
{ value: '__custom__', label: 'Custom…' },
];
export const COMMAND_EXAMPLES = {
ffmpeg:
'-i pipe:0 -c:v libx264 -b:v 2000k -vf scale=-2:720 -c:a copy -f mpegts pipe:1',
};
export const toCommandSelection = (command) =>
BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__')
? command
: '__custom__';
export const schema = Yup.object({
name: Yup.string().required('Name is required'),
command: Yup.string().required('Command is required'),
parameters: Yup.string(),
});
export const addOutputProfile = (values) => {
return API.addOutputProfile(values);
};
export const updateOutputProfile = (values) => {
return API.updateOutputProfile(values);
};
export const getResolver = () => {
return yupResolver(schema);
};

View file

@ -0,0 +1,16 @@
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
});
export const getResolver = () => {
return yupResolver(schema);
};
export const updateServerGroup = (values) => {
return API.updateServerGroup(values);
};
export const addServerGroup = (values) => {
return API.addServerGroup(values);
};

View file

@ -0,0 +1,210 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Mocks ──────────────────────────────────────────────────────────────────────
vi.mock('../../../api', () => ({
default: {
addOutputProfile: vi.fn(),
updateOutputProfile: vi.fn(),
},
}));
const mockResolver = vi.fn();
vi.mock('@hookform/resolvers/yup', () => ({
yupResolver: vi.fn(() => mockResolver),
}));
// ── Imports after mocks ────────────────────────────────────────────────────────
import API from '../../../api';
import { yupResolver } from '@hookform/resolvers/yup';
import {
BUILT_IN_COMMANDS,
COMMAND_EXAMPLES,
toCommandSelection,
schema,
addOutputProfile,
updateOutputProfile,
getResolver,
} from '../OutputProfileUtils';
// ──────────────────────────────────────────────────────────────────────────────
describe('OutputProfileUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(yupResolver).mockReturnValue(mockResolver);
});
// ── Constants ──────────────────────────────────────────────────────────────
describe('BUILT_IN_COMMANDS', () => {
it('includes the ffmpeg entry', () => {
expect(BUILT_IN_COMMANDS).toContainEqual({
value: 'ffmpeg',
label: 'FFmpeg',
});
});
it('includes the custom entry', () => {
expect(BUILT_IN_COMMANDS).toContainEqual({
value: '__custom__',
label: 'Custom…',
});
});
});
describe('COMMAND_EXAMPLES', () => {
it('has a non-empty string example for ffmpeg', () => {
expect(typeof COMMAND_EXAMPLES.ffmpeg).toBe('string');
expect(COMMAND_EXAMPLES.ffmpeg.length).toBeGreaterThan(0);
});
});
// ── toCommandSelection ─────────────────────────────────────────────────────
describe('toCommandSelection', () => {
it('returns the command value when it matches a non-custom built-in', () => {
expect(toCommandSelection('ffmpeg')).toBe('ffmpeg');
});
it('returns "__custom__" when command is "__custom__"', () => {
// __custom__ is in BUILT_IN_COMMANDS but excluded by the o.value !== '__custom__' guard
expect(toCommandSelection('__custom__')).toBe('__custom__');
});
it('returns "__custom__" for an unrecognized command string', () => {
expect(toCommandSelection('my-arbitrary-tool')).toBe('__custom__');
});
it('returns "__custom__" for an empty string', () => {
expect(toCommandSelection('')).toBe('__custom__');
});
it('returns "__custom__" for undefined', () => {
expect(toCommandSelection(undefined)).toBe('__custom__');
});
});
// ── schema ─────────────────────────────────────────────────────────────────
describe('schema', () => {
it('validates a fully populated object', async () => {
await expect(
schema.validate({
name: 'HD Profile',
command: 'ffmpeg',
parameters: '-c:v copy',
})
).resolves.toMatchObject({
name: 'HD Profile',
command: 'ffmpeg',
parameters: '-c:v copy',
});
});
it('validates when parameters is omitted (optional)', async () => {
await expect(
schema.validate({ name: 'HD Profile', command: 'ffmpeg' })
).resolves.toMatchObject({ name: 'HD Profile', command: 'ffmpeg' });
});
it('rejects when name is missing', async () => {
await expect(schema.validate({ command: 'ffmpeg' })).rejects.toThrow(
'Name is required'
);
});
it('rejects when name is an empty string', async () => {
await expect(
schema.validate({ name: '', command: 'ffmpeg' })
).rejects.toThrow('Name is required');
});
it('rejects when command is missing', async () => {
await expect(schema.validate({ name: 'HD Profile' })).rejects.toThrow(
'Command is required'
);
});
it('rejects when command is an empty string', async () => {
await expect(
schema.validate({ name: 'HD Profile', command: '' })
).rejects.toThrow('Command is required');
});
});
// ── addOutputProfile ───────────────────────────────────────────────────────
describe('addOutputProfile', () => {
it('calls API.addOutputProfile with the provided values', async () => {
const values = { name: 'New Profile', command: 'ffmpeg', parameters: '' };
vi.mocked(API.addOutputProfile).mockResolvedValue({ id: 1, ...values });
await addOutputProfile(values);
expect(API.addOutputProfile).toHaveBeenCalledWith(values);
expect(API.addOutputProfile).toHaveBeenCalledTimes(1);
});
it('returns the API response', async () => {
const values = { name: 'New Profile', command: 'ffmpeg' };
const response = { id: 42, ...values };
vi.mocked(API.addOutputProfile).mockResolvedValue(response);
const result = await addOutputProfile(values);
expect(result).toEqual(response);
});
it('propagates errors thrown by API.addOutputProfile', async () => {
vi.mocked(API.addOutputProfile).mockRejectedValue(
new Error('Network error')
);
await expect(addOutputProfile({})).rejects.toThrow('Network error');
});
});
// ── updateOutputProfile ────────────────────────────────────────────────────
describe('updateOutputProfile', () => {
it('calls API.updateOutputProfile with the provided values', async () => {
const values = { id: 1, name: 'Updated Profile', command: 'ffmpeg' };
vi.mocked(API.updateOutputProfile).mockResolvedValue(values);
await updateOutputProfile(values);
expect(API.updateOutputProfile).toHaveBeenCalledWith(values);
expect(API.updateOutputProfile).toHaveBeenCalledTimes(1);
});
it('returns the API response', async () => {
const values = { id: 7, name: 'Updated Profile', command: 'ffmpeg' };
vi.mocked(API.updateOutputProfile).mockResolvedValue(values);
const result = await updateOutputProfile(values);
expect(result).toEqual(values);
});
it('propagates errors thrown by API.updateOutputProfile', async () => {
vi.mocked(API.updateOutputProfile).mockRejectedValue(
new Error('Update failed')
);
await expect(updateOutputProfile({})).rejects.toThrow('Update failed');
});
});
// ── getResolver ────────────────────────────────────────────────────────────
describe('getResolver', () => {
it('returns the result of yupResolver called with schema', () => {
const resolver = getResolver();
expect(yupResolver).toHaveBeenCalledWith(schema);
expect(resolver).toBe(mockResolver);
});
});
});

View file

@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Mocks ──────────────────────────────────────────────────────────────────────
vi.mock('../../../api', () => ({
default: {
addServerGroup: vi.fn(),
updateServerGroup: vi.fn(),
},
}));
const mockResolver = vi.fn();
vi.mock('@hookform/resolvers/yup', () => ({
yupResolver: vi.fn(() => mockResolver),
}));
// ── Imports after mocks ────────────────────────────────────────────────────────
import API from '../../../api';
import { yupResolver } from '@hookform/resolvers/yup';
import {
getResolver,
addServerGroup,
updateServerGroup,
} from '../ServerGroupUtils';
// ──────────────────────────────────────────────────────────────────────────────
describe('ServerGroupUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(yupResolver).mockReturnValue(mockResolver);
});
// ── getResolver ────────────────────────────────────────────────────────────
describe('getResolver', () => {
it('calls yupResolver with a schema and returns the result', () => {
const resolver = getResolver();
expect(yupResolver).toHaveBeenCalledTimes(1);
expect(yupResolver).toHaveBeenCalledWith(expect.any(Object));
expect(resolver).toBe(mockResolver);
});
it('returns a new resolver on each call', () => {
const resolverA = getResolver();
const resolverB = getResolver();
expect(yupResolver).toHaveBeenCalledTimes(2);
expect(resolverA).toBe(mockResolver);
expect(resolverB).toBe(mockResolver);
});
});
// ── addServerGroup ─────────────────────────────────────────────────────────
describe('addServerGroup', () => {
it('calls API.addServerGroup with the provided values', async () => {
const values = { name: 'US East' };
vi.mocked(API.addServerGroup).mockResolvedValue({ id: 1, ...values });
await addServerGroup(values);
expect(API.addServerGroup).toHaveBeenCalledWith(values);
expect(API.addServerGroup).toHaveBeenCalledTimes(1);
});
it('returns the API response', async () => {
const values = { name: 'US East' };
const response = { id: 1, ...values };
vi.mocked(API.addServerGroup).mockResolvedValue(response);
const result = await addServerGroup(values);
expect(result).toEqual(response);
});
it('propagates errors thrown by API.addServerGroup', async () => {
vi.mocked(API.addServerGroup).mockRejectedValue(new Error('Network error'));
await expect(addServerGroup({ name: 'Test' })).rejects.toThrow('Network error');
});
});
// ── updateServerGroup ──────────────────────────────────────────────────────
describe('updateServerGroup', () => {
it('calls API.updateServerGroup with the provided values', async () => {
const values = { id: 5, name: 'EU West' };
vi.mocked(API.updateServerGroup).mockResolvedValue(values);
await updateServerGroup(values);
expect(API.updateServerGroup).toHaveBeenCalledWith(values);
expect(API.updateServerGroup).toHaveBeenCalledTimes(1);
});
it('returns the API response', async () => {
const values = { id: 5, name: 'EU West' };
vi.mocked(API.updateServerGroup).mockResolvedValue(values);
const result = await updateServerGroup(values);
expect(result).toEqual(values);
});
it('propagates errors thrown by API.updateServerGroup', async () => {
vi.mocked(API.updateServerGroup).mockRejectedValue(new Error('Update failed'));
await expect(updateServerGroup({ id: 1, name: 'Test' })).rejects.toThrow('Update failed');
});
});
});

View file

@ -25,3 +25,12 @@ export const deletePluginByKey = (key) => {
export const getPluginDetailManifest = (repoId, manifestUrl) => {
return API.getPluginDetailManifest(repoId, manifestUrl);
};
export const getPluginRepoSettings = () => {
return API.getPluginRepoSettings();
};
export const updatePluginRepoSettings = (values) => {
return API.updatePluginRepoSettings(values);
};
export const previewPluginRepo = (url, publicKey) => {
return API.previewPluginRepo(url, publicKey);
};

View file

@ -11,6 +11,9 @@ vi.mock('../../../api.js', () => ({
reloadPlugins: vi.fn(),
deletePlugin: vi.fn(),
getPluginDetailManifest: vi.fn(),
getPluginRepoSettings: vi.fn(),
updatePluginRepoSettings: vi.fn(),
previewPluginRepo: vi.fn(),
},
}));
@ -344,4 +347,86 @@ describe('PluginsUtils', () => {
expect(API.getPluginDetailManifest).toHaveBeenCalledWith(null, null);
});
});
describe('getPluginRepoSettings', () => {
it('should call API getPluginRepoSettings', () => {
PluginsUtils.getPluginRepoSettings();
expect(API.getPluginRepoSettings).toHaveBeenCalledTimes(1);
});
it('should return API response', () => {
const mockResponse = { repos: [] };
API.getPluginRepoSettings.mockReturnValue(mockResponse);
const result = PluginsUtils.getPluginRepoSettings();
expect(result).toEqual(mockResponse);
});
});
describe('updatePluginRepoSettings', () => {
it('should call API updatePluginRepoSettings with values', () => {
const values = { repos: ['https://example.com/repo.json'] };
PluginsUtils.updatePluginRepoSettings(values);
expect(API.updatePluginRepoSettings).toHaveBeenCalledWith(values);
expect(API.updatePluginRepoSettings).toHaveBeenCalledTimes(1);
});
it('should return API response', () => {
const values = { repos: ['https://example.com/repo.json'] };
const mockResponse = { success: true };
API.updatePluginRepoSettings.mockReturnValue(mockResponse);
const result = PluginsUtils.updatePluginRepoSettings(values);
expect(result).toEqual(mockResponse);
});
});
describe('previewPluginRepo', () => {
it('should call API previewPluginRepo with url and publicKey', () => {
const url = 'https://example.com/repo.json';
const publicKey = 'public-key';
PluginsUtils.previewPluginRepo(url, publicKey);
expect(API.previewPluginRepo).toHaveBeenCalledWith(url, publicKey);
expect(API.previewPluginRepo).toHaveBeenCalledTimes(1);
});
it('should return API response', () => {
const url = 'https://example.com/repo.json';
const publicKey = 'public-key';
const mockResponse = { name: 'Test Repo', plugins: [] };
API.previewPluginRepo.mockReturnValue(mockResponse);
const result = PluginsUtils.previewPluginRepo(url, publicKey);
expect(result).toEqual(mockResponse);
});
it('should handle empty string url and publicKey', () => {
const url = '';
const publicKey = '';
PluginsUtils.previewPluginRepo(url, publicKey);
expect(API.previewPluginRepo).toHaveBeenCalledWith('', '');
});
it('should handle null url and publicKey', () => {
const url = null;
const publicKey = null;
PluginsUtils.previewPluginRepo(url, publicKey);
expect(API.previewPluginRepo).toHaveBeenCalledWith(null, null);
});
});
});