diff --git a/frontend/src/components/forms/OutputProfile.jsx b/frontend/src/components/forms/OutputProfile.jsx
index 541c911b..610c0ef4 100644
--- a/frontend/src/components/forms/OutputProfile.jsx
+++ b/frontend/src/components/forms/OutputProfile.jsx
@@ -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();
diff --git a/frontend/src/components/forms/ServerGroup.jsx b/frontend/src/components/forms/ServerGroup.jsx
index 80c8c1a4..0d5ecd05 100644
--- a/frontend/src/components/forms/ServerGroup.jsx
+++ b/frontend/src/components/forms/ServerGroup.jsx
@@ -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);
diff --git a/frontend/src/components/forms/__tests__/OutputProfile.test.jsx b/frontend/src/components/forms/__tests__/OutputProfile.test.jsx
new file mode 100644
index 00000000..f06e89e1
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/OutputProfile.test.jsx
@@ -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 }) => (
+
+ ),
+ Checkbox: ({ label, checked, onChange }) => (
+
+
+
+ onChange({ currentTarget: { checked: e.target.checked } })
+ }
+ />
+
+ ),
+ Flex: ({ children }) => {children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ {children}
+
+ ) : null,
+ Select: ({ label, value, onChange, data, disabled }) => (
+
+
+
+
+ ),
+ Stack: ({ children }) => {children}
,
+ Textarea: ({
+ label,
+ name,
+ value,
+ onChange,
+ placeholder,
+ description,
+ disabled,
+ ...rest
+ }) => (
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+ ),
+ TextInput: ({ label, name, value, onChange, error, disabled, ...rest }) => (
+
+
+
+ {error && {error}}
+
+ ),
+}));
+
+// ── 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();
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render the modal when isOpen is false', () => {
+ render();
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('renders "Output Profile" as the modal title', () => {
+ render();
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ 'Output Profile'
+ );
+ });
+
+ it('calls onClose when the modal close button is clicked', () => {
+ const onClose = vi.fn();
+ render();
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Form fields ────────────────────────────────────────────────────────────
+
+ describe('form fields', () => {
+ it('renders the Name input', () => {
+ render();
+ expect(screen.getByTestId('input-name')).toBeInTheDocument();
+ });
+
+ it('renders the Command select', () => {
+ render();
+ expect(screen.getByTestId('select-command')).toBeInTheDocument();
+ });
+
+ it('renders the Parameters textarea', () => {
+ render();
+ expect(screen.getByTestId('textarea-parameters')).toBeInTheDocument();
+ });
+
+ it('renders the Is Active checkbox', () => {
+ render();
+ expect(screen.getByTestId('checkbox-is-active')).toBeInTheDocument();
+ });
+
+ it('renders the Save button', () => {
+ render();
+ expect(screen.getByText('Save')).toBeInTheDocument();
+ });
+
+ it('populates the Command select with built-in options', () => {
+ render();
+ 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();
+ expect(
+ screen.queryByTestId('input-custom-command')
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Default values ─────────────────────────────────────────────────────────
+
+ describe('default values', () => {
+ it('name field is empty for a new profile', () => {
+ render();
+ expect(screen.getByTestId('input-name')).toHaveValue('');
+ });
+
+ it('command select defaults to ffmpeg', () => {
+ render();
+ expect(screen.getByTestId('select-command')).toHaveValue('ffmpeg');
+ });
+
+ it('is_active checkbox is checked by default', () => {
+ render();
+ expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
+ });
+
+ it('parameters field is empty for a new profile', () => {
+ render();
+ expect(screen.getByTestId('textarea-parameters')).toHaveValue('');
+ });
+ });
+
+ // ── Profile pre-fill ───────────────────────────────────────────────────────
+
+ describe('profile pre-fill', () => {
+ it('pre-fills the name from the profile', () => {
+ render();
+ expect(screen.getByTestId('input-name')).toHaveValue('HD Transcode');
+ });
+
+ it('pre-fills the parameters from the profile', () => {
+ render();
+ 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();
+ expect(screen.getByTestId('select-command')).toHaveValue('ffmpeg');
+ });
+
+ it('is_active checkbox is unchecked when profile has is_active: false', () => {
+ render(
+
+ );
+ 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(
+
+ );
+ expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
+ });
+ });
+
+ // ── Locked profile ─────────────────────────────────────────────────────────
+
+ describe('locked profile', () => {
+ it('disables the Name input when profile is locked', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('input-name')).toBeDisabled();
+ });
+
+ it('disables the Command select when profile is locked', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('select-command')).toBeDisabled();
+ });
+
+ it('disables the Parameters textarea when profile is locked', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('textarea-parameters')).toBeDisabled();
+ });
+
+ it('does not disable inputs when profile is not locked', () => {
+ render(
+
+ );
+ 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();
+ 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();
+ 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();
+ 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();
+ 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(
+
+ );
+ expect(container.textContent).toMatch(/-i pipe:0/);
+ });
+ });
+
+ // ── Is Active checkbox ─────────────────────────────────────────────────────
+
+ describe('Is Active checkbox', () => {
+ it('toggles is_active to false when unchecked', () => {
+ render();
+ fireEvent.click(screen.getByTestId('checkbox-is-active'));
+ expect(__form.values.is_active).toBe(false);
+ });
+
+ it('toggles is_active to true when checked', () => {
+ render(
+
+ );
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ fireEvent.click(screen.getByText('Save'));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('calls reset after successful submission', async () => {
+ render();
+ fireEvent.click(screen.getByText('Save'));
+ await waitFor(() => {
+ expect(__form.resetSpy).toHaveBeenCalled();
+ });
+ });
+
+ it('passes is_active value to addOutputProfile', async () => {
+ render();
+ 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 })
+ );
+ });
+ });
+ });
+});
diff --git a/frontend/src/components/forms/__tests__/ServerGroup.test.jsx b/frontend/src/components/forms/__tests__/ServerGroup.test.jsx
new file mode 100644
index 00000000..f85d2e92
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/ServerGroup.test.jsx
@@ -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 }) => (
+
+ ),
+ Flex: ({ children }) => {children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ {children}
+
+ ) : null,
+ TextInput: ({
+ label,
+ name,
+ value,
+ onChange,
+ error,
+ description,
+ ...rest
+ }) => (
+
+
+ {description &&
{description}
}
+
+ {error &&
{error}}
+
+ ),
+}));
+
+// ── 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();
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('renders nothing when isOpen is false', () => {
+ render();
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('renders "Server Group" as the modal title', () => {
+ render();
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ 'Server Group'
+ );
+ });
+
+ it('calls onClose when the modal close button is clicked', () => {
+ const onClose = vi.fn();
+ render();
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Form fields ────────────────────────────────────────────────────────────
+
+ describe('form fields', () => {
+ it('renders the Name input', () => {
+ render();
+ expect(screen.getByTestId('input-name')).toBeInTheDocument();
+ });
+
+ it('renders the Submit button', () => {
+ render();
+ expect(screen.getByText('Submit')).toBeInTheDocument();
+ });
+ });
+
+ // ── Default values ─────────────────────────────────────────────────────────
+
+ describe('default values', () => {
+ it('name input is empty when no serverGroup is provided', () => {
+ render();
+ expect(screen.getByTestId('input-name')).toHaveValue('');
+ });
+
+ it('pre-fills the name from the serverGroup prop', () => {
+ render(
+
+ );
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ fireEvent.click(screen.getByText('Submit'));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('calls reset after submission', async () => {
+ render();
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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();
+ fireEvent.click(screen.getByText('Submit'));
+ await expect(
+ waitFor(() =>
+ expect(ServerGroupUtils.addServerGroup).toHaveBeenCalled()
+ )
+ ).resolves.not.toThrow();
+ });
+ });
+});
diff --git a/frontend/src/components/modals/CreateChannelModal.jsx b/frontend/src/components/modals/CreateChannelModal.jsx
index a1a42b29..4f4d9d4e 100644
--- a/frontend/src/components/modals/CreateChannelModal.jsx
+++ b/frontend/src/components/modals/CreateChannelModal.jsx
@@ -4,6 +4,7 @@ import {
Stack,
Text,
Radio,
+ RadioGroup,
NumberInput,
Checkbox,
Group,
@@ -101,11 +102,7 @@ const CreateChannelModal = ({
-
+
-
+
{mode === customModeValue && (
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 (
+ <>
+
+
+ Plugin Repositories
+
+ Add third-party plugin repositories or manage existing ones.
+ Manifests are fetched automatically at the configured interval.
+
+
+
+
+ Refresh Interval
+
+
+
+ Hours, 0 to disable
+
+
+
+ }
+ centered
+ size="lg"
+ styles={{
+ title: { width: '100%' },
+ header: { alignItems: 'flex-start' },
+ }}
+ >
+
+ {reposLoading && repos.length === 0 && }
+
+ {repos.map((repo) => (
+
+
+
+
+
+ {repo.name}
+
+ {repo.is_official && (
+
+ Official Repo
+
+ )}
+ {repo.signature_verified === true && (
+ }
+ >
+ Verified Signature
+
+ )}
+ {repo.signature_verified === false && (
+ }
+ >
+ Invalid Signature
+
+ )}
+
+ {repo.registry_url ? (
+
+
+ {repo.registry_url}
+
+
+ ) : null}
+
+ {repo.url}
+
+ {repo.last_fetched && (
+
+ 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`
+ : ''}
+
+ )}
+
+ {!repo.is_official && (
+
+ handleEditKey(repo)}
+ >
+
+
+ setDeleteConfirmId(repo.id)}
+ >
+
+
+
+ )}
+
+ {editingKeyRepoId === repo.id && (
+
+
+ )}
+
+ ))}
+
+ {!showAddRepo ? (
+ }
+ size="sm"
+ onClick={() => setShowAddRepo(true)}
+ >
+ Add Repository
+
+ ) : (
+ <>
+
+ Add Repository
+
+
+ {previewLoading ? (
+
+
+
+ Checking manifest...
+
+
+ ) : repoPreview ? (
+ repoPreview.valid ? (
+
+
+
+ {repoPreview.registry_name}
+
+ {repoPreview.signature_verified === true && (
+ }
+ >
+ Verified Signature
+
+ )}
+ {repoPreview.signature_verified === false && (
+ <>
+ }
+ >
+ Signed Manifest
+
+
+ Public key required for verification
+
+ >
+ )}
+ {repoPreview.signature_verified == null && (
+
+ No Signature
+
+ )}
+
+ {repoPreview.registry_url ? (
+
+
+ {repoPreview.registry_url}
+
+
+ ) : null}
+
+ {newRepoUrl.trim()}
+
+
+ {repoPreview.plugin_count} plugin
+ {repoPreview.plugin_count !== 1 ? 's' : ''} available
+
+
+ ) : (
+
+ {repoPreview.errors?.join(' ') || 'Invalid manifest'}
+
+ )
+ ) : (
+
+ Third-party repositories are not reviewed by the Dispatcharr
+ team.
+
+ Adding sources and installing plugins is done at your own
+ risk.
+
+ )}
+
+ {
+ setNewRepoUrl(e.currentTarget.value);
+ fetchPreview(e.currentTarget.value, newRepoPublicKey);
+ }}
+ size="sm"
+ />
+
+
+
+ setDeleteConfirmId(null)}
+ onConfirm={() => handleDeleteRepo(deleteConfirmId)}
+ title="Remove Repository"
+ message={
+ <>
+
+ Are you sure you want to remove this repository?
+
+
+ Plugins installed from this repo will remain installed but become
+ unmanaged.
+
+ >
+ }
+ confirmLabel="Remove"
+ size="sm"
+ />
+ >
+ );
+}
diff --git a/frontend/src/components/modals/ProfileModal.jsx b/frontend/src/components/modals/ProfileModal.jsx
index 069457bb..c931a1c8 100644
--- a/frontend/src/components/modals/ProfileModal.jsx
+++ b/frontend/src/components/modals/ProfileModal.jsx
@@ -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',
diff --git a/frontend/src/components/modals/__tests__/CreateChannelModal.test.jsx b/frontend/src/components/modals/__tests__/CreateChannelModal.test.jsx
new file mode 100644
index 00000000..b8d3a89d
--- /dev/null
+++ b/frontend/src/components/modals/__tests__/CreateChannelModal.test.jsx
@@ -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 {children}
;
+ }
+
+ function RadioItem({ value, label, description, _onChange }) {
+ return (
+
+ );
+ }
+
+ 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 (
+
+ {label && }
+ {React.Children.map(children, inject)}
+
+ );
+ }
+
+ return {
+ Button: ({ children, onClick, variant }) => (
+
+ ),
+ Checkbox: ({ label, checked, onChange }) => (
+
+
+ onChange({ currentTarget: { checked: e.target.checked } })
+ }
+ />
+ {label && }
+
+ ),
+ Divider: ({ label }) =>
,
+ Group: ({ children }) => {children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ {children}
+
+ ) : null,
+ MultiSelect: ({ label, data, value, onChange }) => {
+ const flat = (data ?? []).flatMap((g) =>
+ g.items ? g.items : [{ value: g.value, label: g.label }]
+ );
+ return (
+
+ {label && }
+ {flat.map((opt) => (
+
+ ))}
+
+ );
+ },
+ NumberInput: ({
+ label,
+ value,
+ onChange,
+ min,
+ placeholder,
+ description,
+ }) => (
+
+ {label && }
+ onChange(Number(e.target.value))}
+ />
+ {description && {description}}
+
+ ),
+ Radio: RadioItem,
+ RadioGroup,
+ Stack,
+ Text: ({ children, c }) => {children},
+ };
+});
+
+// ── 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();
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render the modal when opened is false', () => {
+ render();
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('calls onClose when the close button is clicked', () => {
+ const onClose = vi.fn();
+ render();
+ 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();
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ 'Create Channel'
+ );
+ });
+
+ it('shows "Create Channels Options" title for bulk mode', () => {
+ render();
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ 'Create Channels Options'
+ );
+ });
+
+ it('shows "Create Channel" confirm button for single mode', () => {
+ render();
+ expect(
+ screen.getByRole('button', { name: 'Create Channel' })
+ ).toBeInTheDocument();
+ });
+
+ it('shows "Create Channels" confirm button for bulk mode', () => {
+ render();
+ expect(screen.getByText('Create Channels')).toBeInTheDocument();
+ });
+
+ it('shows "Number Assignment" numbering label for single mode', () => {
+ render();
+ expect(screen.getByText('Number Assignment')).toBeInTheDocument();
+ });
+
+ it('shows "Numbering Mode" numbering label for bulk mode', () => {
+ render();
+ expect(screen.getByText('Numbering Mode')).toBeInTheDocument();
+ });
+ });
+
+ // ── Description text ───────────────────────────────────────────────────────
+
+ describe('description text', () => {
+ it('shows the streamName in the description for single mode', () => {
+ render(
+
+ );
+ expect(screen.getByText(/ESPN HD/)).toBeInTheDocument();
+ });
+
+ it('shows the streamCount in the description for bulk mode', () => {
+ render(
+
+ );
+ expect(screen.getByText(/5 channels/)).toBeInTheDocument();
+ });
+ });
+
+ // ── Radio options ──────────────────────────────────────────────────────────
+
+ describe('radio options', () => {
+ it('renders all four radio options', () => {
+ render();
+ 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();
+ expect(screen.getByLabelText('Use Provider Number')).toBeInTheDocument();
+ });
+
+ it('renders "Use Provider Numbers" label for bulk mode', () => {
+ render();
+ expect(screen.getByLabelText('Use Provider Numbers')).toBeInTheDocument();
+ });
+
+ it('renders "Auto-Assign Next Available" label for single mode', () => {
+ render();
+ expect(
+ screen.getByLabelText('Auto-Assign Next Available')
+ ).toBeInTheDocument();
+ });
+
+ it('renders "Auto-Assign Sequential" label for bulk mode', () => {
+ render();
+ expect(
+ screen.getByLabelText('Auto-Assign Sequential')
+ ).toBeInTheDocument();
+ });
+
+ it('renders "Use Specific Number" label for single mode', () => {
+ render();
+ expect(screen.getByLabelText('Use Specific Number')).toBeInTheDocument();
+ });
+
+ it('renders "Start from Custom Number" label for bulk mode', () => {
+ render();
+ expect(
+ screen.getByLabelText('Start from Custom Number')
+ ).toBeInTheDocument();
+ });
+
+ it('calls onModeChange when a radio option is selected', () => {
+ const onModeChange = vi.fn();
+ render();
+ 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();
+ expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
+ });
+
+ it('does not show NumberInput when mode is "auto"', () => {
+ render();
+ expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
+ });
+
+ it('does not show NumberInput when mode is "highest"', () => {
+ render();
+ expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
+ });
+
+ it('shows NumberInput when mode is "specific" in single mode', () => {
+ render();
+ expect(screen.getByTestId('number-input')).toBeInTheDocument();
+ });
+
+ it('shows NumberInput when mode is "custom" in bulk mode', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('number-input')).toBeInTheDocument();
+ });
+
+ it('does not show NumberInput when mode is "specific" but isBulk is true', () => {
+ render(
+
+ );
+ expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
+ });
+
+ it('calls onNumberValueChange when NumberInput value changes', () => {
+ const onNumberValueChange = vi.fn();
+ render(
+
+ );
+ fireEvent.change(screen.getByTestId('number-input'), {
+ target: { value: '10' },
+ });
+ expect(onNumberValueChange).toHaveBeenCalledWith(10);
+ });
+
+ it('shows "Channel Number" label in single mode', () => {
+ render();
+ expect(screen.getByText('Channel Number')).toBeInTheDocument();
+ });
+
+ it('shows "Starting Channel Number" label in bulk mode', () => {
+ render(
+
+ );
+ expect(screen.getByText('Starting Channel Number')).toBeInTheDocument();
+ });
+ });
+
+ // ── Remember choice checkbox ───────────────────────────────────────────────
+
+ describe('remember choice checkbox', () => {
+ it('renders unchecked when rememberChoice is false', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('remember-checkbox')).not.toBeChecked();
+ });
+
+ it('renders checked when rememberChoice is true', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('remember-checkbox')).toBeChecked();
+ });
+
+ it('calls onRememberChoiceChange with true when checked', () => {
+ const onRememberChoiceChange = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByTestId('remember-checkbox'));
+ expect(onRememberChoiceChange).toHaveBeenCalledWith(true);
+ });
+
+ it('calls onRememberChoiceChange with false when unchecked', () => {
+ const onRememberChoiceChange = vi.fn();
+ render(
+
+ );
+ 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();
+ 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();
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Channel profiles ───────────────────────────────────────────────────────
+
+ describe('channel profiles', () => {
+ it('renders the "All Profiles" special option', () => {
+ render();
+ expect(screen.getByTestId('profile-option-all')).toBeInTheDocument();
+ });
+
+ it('renders the "No Profiles" special option', () => {
+ render();
+ expect(screen.getByTestId('profile-option-none')).toBeInTheDocument();
+ });
+
+ it('renders channel profile options (excluding id "0")', () => {
+ render();
+ expect(screen.getByTestId('profile-option-1')).toBeInTheDocument();
+ expect(screen.getByTestId('profile-option-2')).toBeInTheDocument();
+ });
+
+ it('does not render the profile with id "0"', () => {
+ render();
+ expect(screen.queryByTestId('profile-option-0')).not.toBeInTheDocument();
+ });
+
+ it('renders "All Profiles" and "No Profiles" labels', () => {
+ render();
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ fireEvent.click(screen.getByTestId('profile-option-2'));
+ expect(onProfileIdsChange).toHaveBeenCalledWith(['2']);
+ });
+
+ it('allows selecting multiple specific profiles', () => {
+ const onProfileIdsChange = vi.fn();
+ render(
+
+ );
+ 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(
+
+ );
+ // Mock passes ['all', 'none'] to onChange, making lastSelected = 'none'
+ fireEvent.click(screen.getByTestId('profile-option-none'));
+ expect(onProfileIdsChange).toHaveBeenCalledWith(['none']);
+ });
+ });
+});
diff --git a/frontend/src/components/modals/__tests__/ManageReposModal.test.jsx b/frontend/src/components/modals/__tests__/ManageReposModal.test.jsx
new file mode 100644
index 00000000..87313de6
--- /dev/null
+++ b/frontend/src/components/modals/__tests__/ManageReposModal.test.jsx
@@ -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 ? (
+
+
{title}
+
+
+
+ ) : null,
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ KeyRound: () => ,
+ Plus: () => ,
+ ShieldAlert: () => ,
+ ShieldCheck: () => ,
+ Trash2: () => ,
+}));
+
+// ── @mantine/core ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick, title, color, variant }) => (
+
+ ),
+ Badge: ({ children, color, variant, leftSection }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ Box: ({ children }) => {children}
,
+ Button: ({
+ children,
+ onClick,
+ loading,
+ disabled,
+ variant,
+ color,
+ leftSection,
+ }) => (
+
+ ),
+ Group: ({ children }) => {children}
,
+ Loader: ({ size }) => ,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ {children}
+
+ ) : null,
+ NumberInput: ({ value, onChange, min, max, disabled }) => (
+ onChange(Number(e.target.value))}
+ min={min}
+ max={max}
+ disabled={disabled}
+ />
+ ),
+ Stack: ({ children }) => {children}
,
+ Text: ({ children, fw, size, c }) => (
+
+ {children}
+
+ ),
+ Textarea: ({ value, onChange, placeholder, onFocus, onBlur }) => (
+
+ ),
+ TextInput: ({ value, onChange, 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();
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render the modal when opened is false', () => {
+ render();
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('calls onClose when the close button is clicked', () => {
+ const onClose = vi.fn();
+ render();
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Settings load ──────────────────────────────────────────────────────────
+
+ describe('settings load', () => {
+ it('calls getPluginRepoSettings when opened', async () => {
+ render();
+ await waitFor(() => {
+ expect(getPluginRepoSettings).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('does not call getPluginRepoSettings when closed', async () => {
+ render();
+ expect(getPluginRepoSettings).not.toHaveBeenCalled();
+ });
+
+ it('loads the refresh interval from settings', async () => {
+ vi.mocked(getPluginRepoSettings).mockResolvedValue({
+ refresh_interval_hours: 24,
+ });
+ render();
+ 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();
+ 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();
+ expect(screen.getByTestId('loader')).toBeInTheDocument();
+ });
+
+ it('does not show loader when repos are present', () => {
+ setupStore({ reposLoading: true, repos: [makeRepo()] });
+ render();
+ expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
+ });
+
+ it('shows "Official Repo" badge for official repos', () => {
+ setupStore({ repos: [makeRepo({ is_official: true })] });
+ render();
+ expect(screen.getByText('Official Repo')).toBeInTheDocument();
+ });
+
+ it('shows "Verified Signature" badge when signature_verified is true', () => {
+ setupStore({ repos: [makeRepo({ signature_verified: true })] });
+ render();
+ expect(screen.getByText('Verified Signature')).toBeInTheDocument();
+ });
+
+ it('shows "Invalid Signature" badge when signature_verified is false', () => {
+ setupStore({ repos: [makeRepo({ signature_verified: false })] });
+ render();
+ expect(screen.getByText('Invalid Signature')).toBeInTheDocument();
+ });
+
+ it('shows edit and delete buttons only for non-official repos', () => {
+ setupStore({ repos: [makeRepo({ is_official: false })] });
+ render();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ expect(screen.getByText('Add Repository')).toBeInTheDocument();
+ });
+
+ it('shows the add form when "Add Repository" is clicked', () => {
+ render();
+ 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();
+ 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();
+ fireEvent.click(screen.getByText('Add Repository'));
+ expect(screen.getByText('Add Repo')).toBeDisabled();
+ });
+
+ it('"Add Repo" button is enabled when URL is entered', () => {
+ render();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ });
+ });
+});
diff --git a/frontend/src/components/modals/__tests__/ProfileModal.test.jsx b/frontend/src/components/modals/__tests__/ProfileModal.test.jsx
new file mode 100644
index 00000000..bc25bccc
--- /dev/null
+++ b/frontend/src/components/modals/__tests__/ProfileModal.test.jsx
@@ -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: () => ,
+ SquareMinus: () => ,
+ SquarePen: () => ,
+}));
+
+// ── @mantine/core ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick, disabled }) => (
+
+ ),
+ Alert: ({ children, title, color }) => (
+
+ {title &&
{title}
}
+ {children}
+
+ ),
+ Box: ({ children }) => {children}
,
+ Button: ({ children, onClick, variant, size }) => (
+
+ ),
+ Group: ({ children }) => {children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ {children}
+
+ ) : null,
+ Stack: ({ children }) => {children}
,
+ Text: ({ children, size }) => {children},
+ TextInput: ({ label, value, onChange, placeholder }) => (
+
+ {label && }
+
+
+ ),
+ Tooltip: ({ children, label }) => {children}
,
+}));
+
+// ── 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();
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render the modal when opened is false', () => {
+ render();
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Title ──────────────────────────────────────────────────────────────────
+
+ describe('title', () => {
+ it('shows "Rename Profile: {name}" title in edit mode', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ 'Rename Profile: Work Profile'
+ );
+ });
+
+ it('shows "Duplicate Profile: {name}" title in duplicate mode', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ 'Duplicate Profile: Work Profile'
+ );
+ });
+ });
+
+ // ── Warning alert ──────────────────────────────────────────────────────────
+
+ describe('warning alert', () => {
+ it('shows the warning alert in edit mode', () => {
+ render();
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
+ });
+
+ it('does not show the warning alert in duplicate mode', () => {
+ render();
+ 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(
+
+ );
+ expect(screen.getByTestId('profile-name-input')).toHaveValue(
+ 'My Profile'
+ );
+ });
+
+ it('pre-fills the input with "{name} Copy" in duplicate mode', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('profile-name-input')).toHaveValue(
+ 'My Profile Copy'
+ );
+ });
+ });
+
+ // ── Button labels ──────────────────────────────────────────────────────────
+
+ describe('button labels', () => {
+ it('shows "Save" button in edit mode', () => {
+ render();
+ expect(screen.getByText('Save')).toBeInTheDocument();
+ });
+
+ it('shows "Duplicate" button in duplicate mode', () => {
+ render();
+ expect(screen.getByText('Duplicate')).toBeInTheDocument();
+ });
+ });
+
+ // ── Cancel / close ─────────────────────────────────────────────────────────
+
+ describe('cancel / close', () => {
+ it('calls onClose when Cancel is clicked', () => {
+ const onClose = vi.fn();
+ render();
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('calls onClose when the modal close button is clicked', () => {
+ const onClose = vi.fn();
+ render();
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Submit validation ──────────────────────────────────────────────────────
+
+ describe('submit validation', () => {
+ it('shows notification when name is empty', () => {
+ render();
+ 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();
+ 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();
+ 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();
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ );
+ fireEvent.click(screen.getByText('Duplicate'));
+ await waitFor(() => {
+ expect(mockSetSelectedProfileId).toHaveBeenCalledWith('99');
+ });
+ });
+
+ it('calls onClose after a successful duplication', async () => {
+ const onClose = vi.fn();
+ render(
+
+ );
+ 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(
+
+ );
+ 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(
+
+ {renderFn({ option: { value: '1', label: 'Profile' } })}
+
+ );
+ 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(
+
+ {renderFn({ option: { value: '1', label: 'Profile' } })}
+
+ );
+ 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(
+
+ {renderFn({ option: { value: '1', label: 'Profile' } })}
+
+ );
+ fireEvent.click(
+ screen.getByTestId('icon-square-minus').closest('button')
+ );
+ expect(parentClick).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/frontend/src/hooks/__tests__/useEpgPreview.test.jsx b/frontend/src/hooks/__tests__/useEpgPreview.test.jsx
new file mode 100644
index 00000000..bfdc8592
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useEpgPreview.test.jsx
@@ -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);
+ });
+ });
+});
diff --git a/frontend/src/hooks/useEpgPreview.jsx b/frontend/src/hooks/useEpgPreview.jsx
index f2cab900..3c804ea3 100644
--- a/frontend/src/hooks/useEpgPreview.jsx
+++ b/frontend/src/hooks/useEpgPreview.jsx
@@ -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) {
diff --git a/frontend/src/pages/Connect.jsx b/frontend/src/pages/Connect.jsx
index 099546af..293c2e57 100644
--- a/frontend/src/pages/Connect.jsx
+++ b/frontend/src/pages/Connect.jsx
@@ -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 (
diff --git a/frontend/src/pages/ConnectLogs.jsx b/frontend/src/pages/ConnectLogs.jsx
index 08705a13..eccc3672 100644
--- a/frontend/src/pages/ConnectLogs.jsx
+++ b/frontend/src/pages/ConnectLogs.jsx
@@ -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);
diff --git a/frontend/src/pages/PluginBrowse.jsx b/frontend/src/pages/PluginBrowse.jsx
index ab15270a..372107a4 100644
--- a/frontend/src/pages/PluginBrowse.jsx
+++ b/frontend/src/pages/PluginBrowse.jsx
@@ -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() {
}}
>
-
-
-
- Find Plugins
-
- {availablePlugins.length > 0 && (
-
- {availablePlugins.length} Plugins Available
-
- )}
- {repos.length > 1 && (
-
- {repos.length} Repos
-
- )}
+
+
+
+ Find Plugins
+
+ {availablePlugins.length > 0 && (
+
+ {availablePlugins.length} Plugins Available
+
+ )}
+ {repos.length > 1 && (
+
+ {repos.length} Repos
+
+ )}
+
+
+ }
+ >
+ Publish Your Plugin
+
+
+
+
+
+
-
- }
- >
- Publish Your Plugin
-
-
-
-
-
-
-
- {loading && }
+ {loading && }
- {!loading && (
-
- }
- size="xs"
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.currentTarget.value)}
- style={{ flex: 1, minWidth: 180, maxWidth: 300 }}
- />
-
- {repoOptions.length > 2 && (
+ {!loading && (
+
+ }
+ size="xs"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.currentTarget.value)}
+ style={{ flex: 1, minWidth: 180, maxWidth: 300 }}
+ />
- )}
-
-
- )}
+ {repoOptions.length > 2 && (
+
+ )}
+
+
+ )}
- {!loading &&
- filteredPlugins.length === 0 &&
- availablePlugins.length > 0 && (
+ {!loading &&
+ filteredPlugins.length === 0 &&
+ availablePlugins.length > 0 && (
+
+
+ No plugins match your filters. Try adjusting your search or
+ filter criteria.
+
+
+ )}
+
+ {!loading && availablePlugins.length === 0 && (
- No plugins match your filters. Try adjusting your search or filter
- criteria.
+ No plugins available. Try refreshing repos or adding a new plugin
+ repository.
)}
- {!loading && availablePlugins.length === 0 && (
-
-
- No plugins available. Try refreshing repos or adding a new plugin
- repository.
-
-
- )}
-
- {!loading && filteredPlugins.length > 0 && (
-
- {paginatedPlugins.map((p) => (
- 1}
- onBeforeInstall={(slug) => {
- if (slug) {
- if (p.install_status === 'update_available') {
- recentlyUpdatedSlugs.current.add(slug);
- } else {
- recentlyInstalledSlugs.current.add(slug);
+ {!loading && filteredPlugins.length > 0 && (
+
+ {paginatedPlugins.map((p) => (
+ 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);
- }}
- />
- ))}
-
- )}
-
+ }}
+ onInstalled={(slug) => {
+ if (slug) recentlyInstalledSlugs.current.add(slug);
+ fetchAvailablePlugins();
+ }}
+ onUninstalled={(slug) => {
+ if (slug) recentlyUninstalledSlugs.current.add(slug);
+ }}
+ />
+ ))}
+
+ )}
{!loading && filteredPlugins.length > 0 && (
@@ -551,398 +394,10 @@ export default function PluginBrowsePage() {
)}
- {/* Manage Repos Modal */}
- setRepoModalOpen(false)}
- title={
-
-
- Plugin Repositories
-
- Add third-party plugin repositories or manage existing ones.
- Manifests are fetched automatically at the configured interval.
-
-
-
-
- Refresh Interval
-
-
-
- Hours, 0 to disable
-
-
-
- }
- centered
- size="lg"
- styles={{
- title: { width: '100%' },
- header: { alignItems: 'flex-start' },
- }}
- >
-
- {reposLoading && repos.length === 0 && }
-
- {repos.map((repo) => (
-
-
-
-
-
- {repo.name}
-
- {repo.is_official && (
-
- Official Repo
-
- )}
- {repo.signature_verified === true && (
- }
- >
- Verified Signature
-
- )}
- {repo.signature_verified === false && (
- }
- >
- Invalid Signature
-
- )}
-
- {repo.registry_url ? (
-
-
- {repo.registry_url}
-
-
- ) : null}
-
- {repo.url}
-
- {repo.last_fetched && (
-
- 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`
- : ''}
-
- )}
-
- {!repo.is_official && (
-
- handleEditKey(repo)}
- >
-
-
- setDeleteConfirmId(repo.id)}
- >
-
-
-
- )}
-
- {editingKeyRepoId === repo.id && (
-
-
- )}
-
- ))}
-
- {!showAddRepo ? (
- }
- size="sm"
- onClick={() => setShowAddRepo(true)}
- >
- Add Repository
-
- ) : (
- <>
-
- Add Repository
-
-
- {previewLoading ? (
-
-
-
- Checking manifest...
-
-
- ) : repoPreview ? (
- repoPreview.valid ? (
-
-
-
- {repoPreview.registry_name}
-
- {repoPreview.signature_verified === true && (
- }
- >
- Verified Signature
-
- )}
- {repoPreview.signature_verified === false && (
- <>
- }
- >
- Signed Manifest
-
-
- Public key required for verification
-
- >
- )}
- {repoPreview.signature_verified == null && (
-
- No Signature
-
- )}
-
- {repoPreview.registry_url ? (
-
-
- {repoPreview.registry_url}
-
-
- ) : null}
-
- {newRepoUrl.trim()}
-
-
- {repoPreview.plugin_count} plugin
- {repoPreview.plugin_count !== 1 ? 's' : ''} available
-
-
- ) : (
-
- {repoPreview.errors?.join(' ') || 'Invalid manifest'}
-
- )
- ) : (
-
- Third-party repositories are not reviewed by the Dispatcharr
- team.
-
- Adding sources and installing plugins is done at your own
- risk.
-
- )}
-
- {
- setNewRepoUrl(e.currentTarget.value);
- fetchPreview(e.currentTarget.value, newRepoPublicKey);
- }}
- size="sm"
- />
-
-
-
- {/* Delete Confirmation Modal */}
- setDeleteConfirmId(null)}
- title="Remove Repository"
- size="sm"
- centered
- >
- Are you sure you want to remove this repository?
-
- Plugins installed from this repo will remain installed but become
- unmanaged.
-
-
-
-
-
-
+ />
);
}
diff --git a/frontend/src/pages/__tests__/Connect.test.jsx b/frontend/src/pages/__tests__/Connect.test.jsx
new file mode 100644
index 00000000..f49481f6
--- /dev/null
+++ b/frontend/src/pages/__tests__/Connect.test.jsx
@@ -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 ? (
+
+
{connection?.id ?? 'new'}
+
+
+ ) : null,
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ SquarePlus: () => ,
+ Webhook: () => ,
+ FileCode: () => ,
+ Logs: () => ,
+}));
+
+// ── @mantine/core ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Badge: ({ children, color, variant, size }) => (
+
+ {children}
+
+ ),
+ Box: ({ children, display, style }) => (
+
+ {children}
+
+ ),
+ Button: ({ children, onClick, variant, color, size, leftSection }) => (
+
+ ),
+ Card: ({ children }) => {children}
,
+ Flex: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ Stack: ({ children }) => {children}
,
+ Switch: ({ label, checked, onChange }) => (
+
+ ),
+ Text: ({ children, fw }) => {children},
+ Tooltip: ({ children, label }) => {children}
,
+ 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();
+ expect(fetchIntegrations).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ // ── Loading state ──────────────────────────────────────────────────────────
+
+ describe('loading state', () => {
+ it('shows loading indicator when isLoading is true', () => {
+ setupStore({ isLoading: true });
+ render();
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ });
+
+ it('does not show loading indicator when isLoading is false', () => {
+ setupStore({ isLoading: false });
+ render();
+ 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();
+ expect(screen.getAllByTestId('card')).toHaveLength(2);
+ });
+
+ it('renders integration names', () => {
+ setupStore({ integrations: [makeIntegration({ name: 'Plex Hook' })] });
+ render();
+ expect(screen.getByText('Plex Hook')).toBeInTheDocument();
+ });
+
+ it('shows no cards when integrations list is empty', () => {
+ setupStore({ integrations: [] });
+ render();
+ expect(screen.queryByTestId('card')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── New Connection button ──────────────────────────────────────────────────
+
+ describe('"New Connection" button', () => {
+ it('renders the New Connection button', () => {
+ setupStore();
+ render();
+ expect(screen.getByText('New Connection')).toBeInTheDocument();
+ });
+
+ it('ConnectionForm is not visible initially', () => {
+ setupStore();
+ render();
+ expect(screen.queryByTestId('connection-form')).not.toBeInTheDocument();
+ });
+
+ it('opens ConnectionForm with no connection when New Connection is clicked', () => {
+ setupStore();
+ render();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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);
+ });
+ });
+ });
+});
diff --git a/frontend/src/pages/__tests__/ConnectLogs.test.jsx b/frontend/src/pages/__tests__/ConnectLogs.test.jsx
new file mode 100644
index 00000000..e2159a04
--- /dev/null
+++ b/frontend/src/pages/__tests__/ConnectLogs.test.jsx
@@ -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: () => ,
+ useTable: vi.fn(() => ({})),
+}));
+
+// ── Utils mock ─────────────────────────────────────────────────────────────────
+vi.mock('../../utils', () => ({
+ copyToClipboard: vi.fn(),
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ FileCode: () => ,
+ Webhook: () => ,
+}));
+
+// ── @mantine/core ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Badge: ({ children, color, variant }) => (
+
+ {children}
+
+ ),
+ Box: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ LoadingOverlay: ({ visible }) =>
+ visible ? : null,
+ NativeSelect: ({ value, onChange, data }) => (
+
+ ),
+ Pagination: ({ total, value, onChange }) => (
+
+ {total}
+
+
+ ),
+ Paper: ({ children }) => {children}
,
+ // Distinguish type filter (has 'webhook' option) from integration filter
+ Select: ({ data, value, onChange }) => {
+ const isTypeFilter = data?.some((d) => d.value === 'webhook');
+ return (
+
+ );
+ },
+ Text: ({ children, size }) => {children},
+ Title: ({ children, order }) => {children}
,
+}));
+
+// ── 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();
+ expect(screen.getByText('Connect Logs')).toBeInTheDocument();
+ });
+
+ it('renders the type filter select', () => {
+ render();
+ expect(screen.getByTestId('select-type')).toBeInTheDocument();
+ });
+
+ it('renders the integration filter select', () => {
+ render();
+ expect(screen.getByTestId('select-integration')).toBeInTheDocument();
+ });
+
+ it('renders the table', () => {
+ render();
+ expect(screen.getByTestId('custom-table')).toBeInTheDocument();
+ });
+
+ it('renders the page size select and pagination', () => {
+ render();
+ 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();
+ await waitFor(() => {
+ expect(fetchIntegrations).toHaveBeenCalled();
+ });
+ });
+
+ it('does not call fetchIntegrations when integrations are already loaded', async () => {
+ const { fetchIntegrations } = setupStore({
+ integrations: [makeIntegration()],
+ });
+ render();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ await waitFor(() => {
+ expect(screen.getByTestId('loading-overlay')).toBeInTheDocument();
+ });
+ await act(async () => {
+ resolve({ results: [], count: 0 });
+ });
+ });
+
+ it('hides LoadingOverlay after the fetch completes', async () => {
+ render();
+ 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();
+ 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();
+ 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();
+ 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();
+ await waitFor(() => {
+ expect(screen.getByTestId('pagination-total')).toHaveTextContent('1');
+ });
+ });
+ });
+
+ // ── Type filter ────────────────────────────────────────────────────────────
+
+ describe('type filter', () => {
+ it('populates type filter with All, Webhooks, Scripts options', () => {
+ render();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
+
+ fireEvent.click(screen.getByTestId('next-page'));
+
+ await waitFor(() => {
+ expect(API.getConnectLogs).toHaveBeenCalledWith(
+ expect.objectContaining({ page: 2 })
+ );
+ });
+ });
+ });
+});
diff --git a/frontend/src/pages/__tests__/PluginBrowse.test.jsx b/frontend/src/pages/__tests__/PluginBrowse.test.jsx
new file mode 100644
index 00000000..3f5782a7
--- /dev/null
+++ b/frontend/src/pages/__tests__/PluginBrowse.test.jsx
@@ -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 }) => (
+
+ {plugin.name}
+
+ ),
+}));
+
+vi.mock('../../components/modals/ManageReposModal.jsx', () => ({
+ default: ({ opened, onClose }) =>
+ opened ? (
+
+
+
+ ) : 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: () => ,
+ RefreshCcw: () => ,
+ Search: () => ,
+}));
+
+// ── @mantine/core ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick, loading, title }) => (
+
+ ),
+ AppShellMain: ({ children }) => {children},
+ Badge: ({ children, color, variant }) => (
+
+ {children}
+
+ ),
+ Box: ({ children }) => {children}
,
+ Button: ({ children, onClick, href, variant, color, leftSection }) => (
+
+ ),
+ Group: ({ children }) => {children}
,
+ Loader: () => ,
+ NativeSelect: ({ value, onChange, data }) => (
+
+ ),
+ Pagination: ({ total, value, onChange }) => (
+
+ {total}
+ {value}
+
+
+ ),
+ 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 (
+
+ );
+ },
+ SimpleGrid: ({ children }) => {children}
,
+ Text: ({ children, fw, size, c }) => (
+
+ {children}
+
+ ),
+ TextInput: ({ value, onChange, 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();
+ expect(mockFetchRepos).toHaveBeenCalledTimes(1);
+ expect(mockFetchAvailablePlugins).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not refetch on re-render (hasFetched guard)', () => {
+ const { rerender } = render();
+ rerender();
+ expect(mockFetchRepos).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders "Find Plugins" title', () => {
+ render();
+ expect(screen.getByText('Find Plugins')).toBeInTheDocument();
+ });
+
+ it('shows plugin count badge when plugins are available', () => {
+ setupStore({
+ availablePlugins: [makePlugin(), makePlugin({ slug: 'p2' })],
+ });
+ render();
+ expect(screen.getByText('2 Plugins Available')).toBeInTheDocument();
+ });
+
+ it('does not show plugin count badge when no plugins', () => {
+ render();
+ expect(screen.queryByText(/Plugins Available/)).not.toBeInTheDocument();
+ });
+
+ it('shows repo count badge when repos > 1', () => {
+ setupStore({ repos: [makeRepo(), makeRepo({ id: 2 })] });
+ render();
+ expect(screen.getByText('2 Repos')).toBeInTheDocument();
+ });
+
+ it('does not show repo count badge when only 1 repo', () => {
+ setupStore({ repos: [makeRepo()] });
+ render();
+ expect(screen.queryByText(/\d+ Repos/)).not.toBeInTheDocument();
+ });
+
+ it('shows Loader when loading and no plugins yet', () => {
+ setupStore({ availableLoading: true, availablePlugins: [] });
+ render();
+ expect(screen.getByTestId('loader')).toBeInTheDocument();
+ });
+
+ it('does not show Loader when not loading', () => {
+ setupStore({ availableLoading: false });
+ render();
+ expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
+ });
+
+ it('does not show Loader when loading but plugins are already loaded', () => {
+ setupStore({ availableLoading: true, availablePlugins: [makePlugin()] });
+ render();
+ expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Empty states ───────────────────────────────────────────────────────────
+
+ describe('empty states', () => {
+ it('shows "No plugins available" when there are no plugins', () => {
+ render();
+ 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();
+ 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();
+ 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();
+ expect(screen.getAllByTestId('plugin-card')).toHaveLength(2);
+ });
+
+ it('renders no cards when no plugins available', () => {
+ render();
+ expect(screen.queryByTestId('plugin-card')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Manage Repos modal ─────────────────────────────────────────────────────
+
+ describe('Manage Repos modal', () => {
+ it('ManageReposModal is not visible initially', () => {
+ render();
+ expect(
+ screen.queryByTestId('manage-repos-modal')
+ ).not.toBeInTheDocument();
+ });
+
+ it('opens ManageReposModal when "Manage Repos" button is clicked', () => {
+ render();
+ fireEvent.click(screen.getByText('Manage Repos'));
+ expect(screen.getByTestId('manage-repos-modal')).toBeInTheDocument();
+ });
+
+ it('closes ManageReposModal when its close handler is called', () => {
+ render();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ // 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();
+ // 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();
+ 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();
+ expect(screen.queryByTestId('pagination')).not.toBeInTheDocument();
+ });
+
+ it('shows pagination when plugins are available', () => {
+ setupStore({ availablePlugins: [makePlugin()] });
+ render();
+ 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();
+ 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();
+ 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();
+ 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();
+ // 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');
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/OutputProfileUtils.js b/frontend/src/utils/forms/OutputProfileUtils.js
new file mode 100644
index 00000000..b95e7664
--- /dev/null
+++ b/frontend/src/utils/forms/OutputProfileUtils.js
@@ -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);
+};
diff --git a/frontend/src/utils/forms/ServerGroupUtils.js b/frontend/src/utils/forms/ServerGroupUtils.js
new file mode 100644
index 00000000..55cbf5c4
--- /dev/null
+++ b/frontend/src/utils/forms/ServerGroupUtils.js
@@ -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);
+};
diff --git a/frontend/src/utils/forms/__tests__/OutputProfileUtils.test.js b/frontend/src/utils/forms/__tests__/OutputProfileUtils.test.js
new file mode 100644
index 00000000..86eb2c00
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/OutputProfileUtils.test.js
@@ -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);
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/ServerGroupUtils.test.js b/frontend/src/utils/forms/__tests__/ServerGroupUtils.test.js
new file mode 100644
index 00000000..db94a8cd
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/ServerGroupUtils.test.js
@@ -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');
+ });
+ });
+});
diff --git a/frontend/src/utils/pages/PluginsUtils.js b/frontend/src/utils/pages/PluginsUtils.js
index 3f11bfd2..54942385 100644
--- a/frontend/src/utils/pages/PluginsUtils.js
+++ b/frontend/src/utils/pages/PluginsUtils.js
@@ -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);
+};
diff --git a/frontend/src/utils/pages/__tests__/PluginsUtils.test.js b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js
index 7d175451..bc27e005 100644
--- a/frontend/src/utils/pages/__tests__/PluginsUtils.test.js
+++ b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js
@@ -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);
+ });
+ });
});