mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 09:06:06 +00:00
Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into dev
This commit is contained in:
commit
540f3d9bed
24 changed files with 5820 additions and 745 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
534
frontend/src/components/forms/__tests__/OutputProfile.test.jsx
Normal file
534
frontend/src/components/forms/__tests__/OutputProfile.test.jsx
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Module-level form state ────────────────────────────────────────────────────
|
||||
const __form = { values: {}, resetSpy: null };
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/forms/OutputProfileUtils', () => ({
|
||||
BUILT_IN_COMMANDS: [
|
||||
{ value: 'ffmpeg', label: 'FFmpeg' },
|
||||
{ value: '__custom__', label: 'Custom…' },
|
||||
],
|
||||
COMMAND_EXAMPLES: {
|
||||
ffmpeg: '-i pipe:0 -c:v libx264 -f mpegts pipe:1',
|
||||
},
|
||||
addOutputProfile: vi.fn(),
|
||||
updateOutputProfile: vi.fn(),
|
||||
getResolver: vi.fn(() => undefined),
|
||||
toCommandSelection: vi.fn((cmd) =>
|
||||
cmd === 'ffmpeg' ? 'ffmpeg' : '__custom__'
|
||||
),
|
||||
}));
|
||||
|
||||
// ── react-hook-form ────────────────────────────────────────────────────────────
|
||||
vi.mock('react-hook-form', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
useForm: vi.fn(({ defaultValues } = {}) => {
|
||||
const [formValues, setFormValues] = React.useState(() => {
|
||||
const vals = defaultValues || {};
|
||||
Object.assign(__form.values, vals);
|
||||
return vals;
|
||||
});
|
||||
|
||||
const updateField = (name, value) => {
|
||||
__form.values[name] = value;
|
||||
setFormValues((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const register = (name) => ({
|
||||
name,
|
||||
value: __form.values[name] ?? '',
|
||||
onChange: (e) => updateField(name, e.target.value),
|
||||
onBlur: () => {},
|
||||
});
|
||||
|
||||
const setValue = (name, value) => updateField(name, value);
|
||||
const watch = (name) => formValues[name];
|
||||
|
||||
const handleSubmit = (onSubmit) => (e) => {
|
||||
e?.preventDefault?.();
|
||||
return onSubmit({ ...__form.values });
|
||||
};
|
||||
|
||||
const resetImpl = React.useCallback((newValues) => {
|
||||
const vals = newValues || defaultValues || {};
|
||||
Object.assign(__form.values, vals);
|
||||
setFormValues({ ...vals });
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const resetRef = React.useRef(null);
|
||||
if (!resetRef.current) {
|
||||
resetRef.current = vi.fn((...args) => resetImpl(...args));
|
||||
__form.resetSpy = resetRef.current;
|
||||
}
|
||||
|
||||
return {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors: {}, isSubmitting: false },
|
||||
reset: resetRef.current,
|
||||
setValue,
|
||||
watch,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Button: ({ children, type, disabled }) => (
|
||||
<button type={type} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Checkbox: ({ label, checked, onChange }) => (
|
||||
<div>
|
||||
<label htmlFor="checkbox-is-active">{label}</label>
|
||||
<input
|
||||
id="checkbox-is-active"
|
||||
data-testid="checkbox-is-active"
|
||||
type="checkbox"
|
||||
checked={checked ?? false}
|
||||
onChange={(e) =>
|
||||
onChange({ currentTarget: { checked: e.target.checked } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
Select: ({ label, value, onChange, data, disabled }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<select
|
||||
data-testid={`select-${label?.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
value={value ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
{(data ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Textarea: ({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
description,
|
||||
disabled,
|
||||
...rest
|
||||
}) => (
|
||||
<div>
|
||||
<label htmlFor={name}>{label}</label>
|
||||
{description && (
|
||||
<div data-testid={`desc-${label?.replace(/\s+/g, '-').toLowerCase()}`}>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
id={name}
|
||||
name={name}
|
||||
data-testid={`textarea-${label?.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
value={value ?? ''}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
TextInput: ({ label, name, value, onChange, error, disabled, ...rest }) => (
|
||||
<div>
|
||||
<label htmlFor={name}>{label}</label>
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
data-testid={`input-${label?.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
{error && <span data-testid={`error-${label}`}>{error}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import OutputProfile from '../OutputProfile';
|
||||
import * as OutputProfileUtils from '../../../utils/forms/OutputProfileUtils';
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
const makeProfile = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'HD Transcode',
|
||||
command: 'ffmpeg',
|
||||
parameters: '-i pipe:0 -c:v copy -f mpegts pipe:1',
|
||||
is_active: true,
|
||||
locked: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
profile: null,
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('OutputProfile', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__form.values = {};
|
||||
__form.resetSpy = null;
|
||||
vi.mocked(OutputProfileUtils.addOutputProfile).mockResolvedValue(undefined);
|
||||
vi.mocked(OutputProfileUtils.updateOutputProfile).mockResolvedValue(
|
||||
undefined
|
||||
);
|
||||
vi.mocked(OutputProfileUtils.getResolver).mockReturnValue(undefined);
|
||||
vi.mocked(OutputProfileUtils.toCommandSelection).mockImplementation(
|
||||
(cmd) => (cmd === 'ffmpeg' ? 'ffmpeg' : '__custom__')
|
||||
);
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when isOpen is true', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the modal when isOpen is false', () => {
|
||||
render(<OutputProfile {...defaultProps({ isOpen: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Output Profile" as the modal title', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'Output Profile'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onClose when the modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<OutputProfile {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form fields ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('form fields', () => {
|
||||
it('renders the Name input', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Command select', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('select-command')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Parameters textarea', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('textarea-parameters')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Is Active checkbox', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('checkbox-is-active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Save button', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates the Command select with built-in options', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByText('FFmpeg')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Custom…').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not show Custom Command input when a built-in is selected', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(
|
||||
screen.queryByTestId('input-custom-command')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Default values ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('default values', () => {
|
||||
it('name field is empty for a new profile', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-name')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('command select defaults to ffmpeg', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('select-command')).toHaveValue('ffmpeg');
|
||||
});
|
||||
|
||||
it('is_active checkbox is checked by default', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
|
||||
});
|
||||
|
||||
it('parameters field is empty for a new profile', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('textarea-parameters')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Profile pre-fill ───────────────────────────────────────────────────────
|
||||
|
||||
describe('profile pre-fill', () => {
|
||||
it('pre-fills the name from the profile', () => {
|
||||
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('input-name')).toHaveValue('HD Transcode');
|
||||
});
|
||||
|
||||
it('pre-fills the parameters from the profile', () => {
|
||||
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('textarea-parameters')).toHaveValue(
|
||||
'-i pipe:0 -c:v copy -f mpegts pipe:1'
|
||||
);
|
||||
});
|
||||
|
||||
it('pre-selects the command from the profile', () => {
|
||||
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('select-command')).toHaveValue('ffmpeg');
|
||||
});
|
||||
|
||||
it('is_active checkbox is unchecked when profile has is_active: false', () => {
|
||||
render(
|
||||
<OutputProfile
|
||||
{...defaultProps({ profile: makeProfile({ is_active: false }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('shows Custom Command input when profile has a custom command', () => {
|
||||
vi.mocked(OutputProfileUtils.toCommandSelection).mockReturnValue(
|
||||
'__custom__'
|
||||
);
|
||||
render(
|
||||
<OutputProfile
|
||||
{...defaultProps({
|
||||
profile: makeProfile({ command: '/usr/bin/mycmd' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Locked profile ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('locked profile', () => {
|
||||
it('disables the Name input when profile is locked', () => {
|
||||
render(
|
||||
<OutputProfile
|
||||
{...defaultProps({ profile: makeProfile({ locked: true }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('input-name')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables the Command select when profile is locked', () => {
|
||||
render(
|
||||
<OutputProfile
|
||||
{...defaultProps({ profile: makeProfile({ locked: true }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('select-command')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables the Parameters textarea when profile is locked', () => {
|
||||
render(
|
||||
<OutputProfile
|
||||
{...defaultProps({ profile: makeProfile({ locked: true }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('textarea-parameters')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not disable inputs when profile is not locked', () => {
|
||||
render(
|
||||
<OutputProfile
|
||||
{...defaultProps({ profile: makeProfile({ locked: false }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('input-name')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('select-command')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('textarea-parameters')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Command selection ──────────────────────────────────────────────────────
|
||||
|
||||
describe('command selection', () => {
|
||||
it('shows Custom Command input when Custom… is selected', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: '__custom__' },
|
||||
});
|
||||
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Custom Command input when switching back to a built-in', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: '__custom__' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: 'ffmpeg' },
|
||||
});
|
||||
expect(
|
||||
screen.queryByTestId('input-custom-command')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets command form value when switching to a built-in', () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: 'ffmpeg' },
|
||||
});
|
||||
expect(__form.values.command).toBe('ffmpeg');
|
||||
});
|
||||
|
||||
it('clears command form value when Custom… is selected', () => {
|
||||
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: '__custom__' },
|
||||
});
|
||||
expect(__form.values.command).toBe('');
|
||||
});
|
||||
|
||||
it('shows parameters example in the description for ffmpeg', () => {
|
||||
const { container } = render(
|
||||
<OutputProfile {...defaultProps({ profile: makeProfile() })} />
|
||||
);
|
||||
expect(container.textContent).toMatch(/-i pipe:0/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Is Active checkbox ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Is Active checkbox', () => {
|
||||
it('toggles is_active to false when unchecked', () => {
|
||||
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
fireEvent.click(screen.getByTestId('checkbox-is-active'));
|
||||
expect(__form.values.is_active).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles is_active to true when checked', () => {
|
||||
render(
|
||||
<OutputProfile
|
||||
{...defaultProps({ profile: makeProfile({ is_active: false }) })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('checkbox-is-active'));
|
||||
expect(__form.values.is_active).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form submission ────────────────────────────────────────────────────────
|
||||
|
||||
describe('form submission', () => {
|
||||
it('calls addOutputProfile when submitting a new profile', async () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('input-name'), {
|
||||
target: { value: 'New Profile' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(OutputProfileUtils.addOutputProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'New Profile', command: 'ffmpeg' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call updateOutputProfile for a new profile', async () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(OutputProfileUtils.addOutputProfile).toHaveBeenCalled();
|
||||
});
|
||||
expect(OutputProfileUtils.updateOutputProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls updateOutputProfile when submitting an existing profile', async () => {
|
||||
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(OutputProfileUtils.updateOutputProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 1, name: 'HD Transcode' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call addOutputProfile for an existing profile', async () => {
|
||||
render(<OutputProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(OutputProfileUtils.updateOutputProfile).toHaveBeenCalled();
|
||||
});
|
||||
expect(OutputProfileUtils.addOutputProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose after successful submission', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<OutputProfile {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls reset after successful submission', async () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(__form.resetSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes is_active value to addOutputProfile', async () => {
|
||||
render(<OutputProfile {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('checkbox-is-active')); // uncheck → false
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(OutputProfileUtils.addOutputProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ is_active: false })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
363
frontend/src/components/forms/__tests__/ServerGroup.test.jsx
Normal file
363
frontend/src/components/forms/__tests__/ServerGroup.test.jsx
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Module-level form state ────────────────────────────────────────────────────
|
||||
const __form = { values: {}, resetSpy: null };
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/forms/ServerGroupUtils', () => ({
|
||||
addServerGroup: vi.fn(),
|
||||
updateServerGroup: vi.fn(),
|
||||
getResolver: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
// ── react-hook-form ────────────────────────────────────────────────────────────
|
||||
vi.mock('react-hook-form', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
useForm: vi.fn(({ defaultValues } = {}) => {
|
||||
const [_formValues, setFormValues] = React.useState(() => {
|
||||
const vals = defaultValues || {};
|
||||
Object.assign(__form.values, vals);
|
||||
return vals;
|
||||
});
|
||||
|
||||
const updateField = (name, value) => {
|
||||
__form.values[name] = value;
|
||||
setFormValues((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const register = (name) => ({
|
||||
name,
|
||||
value: __form.values[name] ?? '',
|
||||
onChange: (e) => updateField(name, e.target.value),
|
||||
onBlur: () => {},
|
||||
});
|
||||
|
||||
const handleSubmit = (onSubmit) => (e) => {
|
||||
e?.preventDefault?.();
|
||||
return onSubmit({ ...__form.values });
|
||||
};
|
||||
|
||||
const resetImpl = React.useCallback((newValues) => {
|
||||
const vals = newValues || defaultValues || {};
|
||||
Object.assign(__form.values, vals);
|
||||
setFormValues({ ...vals });
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const resetRef = React.useRef(null);
|
||||
if (!resetRef.current) {
|
||||
resetRef.current = vi.fn((...args) => resetImpl(...args));
|
||||
__form.resetSpy = resetRef.current;
|
||||
}
|
||||
|
||||
return {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors: {}, isSubmitting: false },
|
||||
reset: resetRef.current,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Button: ({ children, type, disabled }) => (
|
||||
<button type={type} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
TextInput: ({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
description,
|
||||
...rest
|
||||
}) => (
|
||||
<div>
|
||||
<label htmlFor={name}>{label}</label>
|
||||
{description && <div>{description}</div>}
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
data-testid={`input-${label?.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
{error && <span data-testid={`error-${label}`}>{error}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import ServerGroupForm from '../ServerGroup';
|
||||
import * as ServerGroupUtils from '../../../utils/forms/ServerGroupUtils';
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
const makeServerGroup = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'US East',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
serverGroup: null,
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
onSaved: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ServerGroupForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__form.values = {};
|
||||
__form.resetSpy = null;
|
||||
vi.mocked(ServerGroupUtils.addServerGroup).mockResolvedValue({
|
||||
id: 2,
|
||||
name: 'New Group',
|
||||
});
|
||||
vi.mocked(ServerGroupUtils.updateServerGroup).mockResolvedValue({
|
||||
id: 1,
|
||||
name: 'Updated',
|
||||
});
|
||||
vi.mocked(ServerGroupUtils.getResolver).mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the form when isOpen is true', () => {
|
||||
render(<ServerGroupForm {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when isOpen is false', () => {
|
||||
render(<ServerGroupForm {...defaultProps({ isOpen: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Server Group" as the modal title', () => {
|
||||
render(<ServerGroupForm {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'Server Group'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onClose when the modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ServerGroupForm {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form fields ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('form fields', () => {
|
||||
it('renders the Name input', () => {
|
||||
render(<ServerGroupForm {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Submit button', () => {
|
||||
render(<ServerGroupForm {...defaultProps()} />);
|
||||
expect(screen.getByText('Submit')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Default values ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('default values', () => {
|
||||
it('name input is empty when no serverGroup is provided', () => {
|
||||
render(<ServerGroupForm {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-name')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('pre-fills the name from the serverGroup prop', () => {
|
||||
render(
|
||||
<ServerGroupForm
|
||||
{...defaultProps({
|
||||
serverGroup: makeServerGroup({ name: 'EU West' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('input-name')).toHaveValue('EU West');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Create (no id) ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('create (no serverGroup.id)', () => {
|
||||
it('calls addServerGroup with the form values', async () => {
|
||||
render(<ServerGroupForm {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('input-name'), {
|
||||
target: { value: 'New Group' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(ServerGroupUtils.addServerGroup).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'New Group' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call updateServerGroup when creating', async () => {
|
||||
render(<ServerGroupForm {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(ServerGroupUtils.addServerGroup).toHaveBeenCalled();
|
||||
});
|
||||
expect(ServerGroupUtils.updateServerGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSaved with the API response when response is truthy', async () => {
|
||||
const onSaved = vi.fn();
|
||||
const response = { id: 5, name: 'New Group' };
|
||||
vi.mocked(ServerGroupUtils.addServerGroup).mockResolvedValue(response);
|
||||
render(<ServerGroupForm {...defaultProps({ onSaved })} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(onSaved).toHaveBeenCalledWith(response);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call onSaved when addServerGroup returns null', async () => {
|
||||
vi.mocked(ServerGroupUtils.addServerGroup).mockResolvedValue(null);
|
||||
const onSaved = vi.fn();
|
||||
render(<ServerGroupForm {...defaultProps({ onSaved })} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(ServerGroupUtils.addServerGroup).toHaveBeenCalled();
|
||||
});
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose after submission regardless of response', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ServerGroupForm {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls reset after submission', async () => {
|
||||
render(<ServerGroupForm {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(__form.resetSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Update (with id) ───────────────────────────────────────────────────────
|
||||
|
||||
describe('update (serverGroup with id)', () => {
|
||||
it('calls updateServerGroup with id and form values', async () => {
|
||||
render(
|
||||
<ServerGroupForm
|
||||
{...defaultProps({
|
||||
serverGroup: makeServerGroup({ id: 7, name: 'Old Name' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('input-name'), {
|
||||
target: { value: 'New Name' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(ServerGroupUtils.updateServerGroup).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 7, name: 'New Name' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call addServerGroup when updating', async () => {
|
||||
render(
|
||||
<ServerGroupForm
|
||||
{...defaultProps({ serverGroup: makeServerGroup() })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(ServerGroupUtils.updateServerGroup).toHaveBeenCalled();
|
||||
});
|
||||
expect(ServerGroupUtils.addServerGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSaved with the updated response', async () => {
|
||||
const updated = { id: 1, name: 'Updated Name' };
|
||||
vi.mocked(ServerGroupUtils.updateServerGroup).mockResolvedValue(updated);
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<ServerGroupForm
|
||||
{...defaultProps({ serverGroup: makeServerGroup(), onSaved })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(onSaved).toHaveBeenCalledWith(updated);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call onSaved when updateServerGroup returns null', async () => {
|
||||
vi.mocked(ServerGroupUtils.updateServerGroup).mockResolvedValue(null);
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<ServerGroupForm
|
||||
{...defaultProps({ serverGroup: makeServerGroup(), onSaved })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(ServerGroupUtils.updateServerGroup).toHaveBeenCalled();
|
||||
});
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose after update regardless of response', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ServerGroupForm
|
||||
{...defaultProps({ serverGroup: makeServerGroup(), onClose })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── onSaved optional ───────────────────────────────────────────────────────
|
||||
|
||||
describe('onSaved optional', () => {
|
||||
it('does not throw when onSaved is not provided and response is truthy', async () => {
|
||||
render(<ServerGroupForm isOpen={true} onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await expect(
|
||||
waitFor(() =>
|
||||
expect(ServerGroupUtils.addServerGroup).toHaveBeenCalled()
|
||||
)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
Stack,
|
||||
Text,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
NumberInput,
|
||||
Checkbox,
|
||||
Group,
|
||||
|
|
@ -101,11 +102,7 @@ const CreateChannelModal = ({
|
|||
|
||||
<Divider label="Channel Number" labelPosition="left" />
|
||||
|
||||
<Radio.Group
|
||||
value={mode}
|
||||
onChange={onModeChange}
|
||||
label={numberingLabel}
|
||||
>
|
||||
<RadioGroup value={mode} onChange={onModeChange} label={numberingLabel}>
|
||||
<Stack mt="xs" spacing="xs">
|
||||
<Radio
|
||||
value="provider"
|
||||
|
|
@ -148,7 +145,7 @@ const CreateChannelModal = ({
|
|||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
</RadioGroup>
|
||||
|
||||
{mode === customModeValue && (
|
||||
<NumberInput
|
||||
|
|
|
|||
567
frontend/src/components/modals/ManageReposModal.jsx
Normal file
567
frontend/src/components/modals/ManageReposModal.jsx
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { KeyRound, Plus, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import ConfirmationDialog from '../ConfirmationDialog.jsx';
|
||||
import { usePluginStore } from '../../store/plugins.jsx';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
import {
|
||||
getPluginRepoSettings,
|
||||
previewPluginRepo,
|
||||
updatePluginRepoSettings,
|
||||
} from '../../utils/pages/PluginsUtils.js';
|
||||
|
||||
export default function ManageReposModal({ opened, onClose }) {
|
||||
const repos = usePluginStore((s) => s.repos);
|
||||
const reposLoading = usePluginStore((s) => s.reposLoading);
|
||||
const fetchAvailablePlugins = usePluginStore((s) => s.fetchAvailablePlugins);
|
||||
const refreshRepo = usePluginStore((s) => s.refreshRepo);
|
||||
const addRepo = usePluginStore((s) => s.addRepo);
|
||||
const removeRepo = usePluginStore((s) => s.removeRepo);
|
||||
const updateRepo = usePluginStore((s) => s.updateRepo);
|
||||
|
||||
const [refreshInterval, setRefreshInterval] = useState(6);
|
||||
const [savingInterval, setSavingInterval] = useState(false);
|
||||
const saveIntervalTimer = useRef(null);
|
||||
|
||||
const [editingKeyRepoId, setEditingKeyRepoId] = useState(null);
|
||||
const [editKeyValue, setEditKeyValue] = useState('');
|
||||
const [savingKey, setSavingKey] = useState(false);
|
||||
|
||||
const [showAddRepo, setShowAddRepo] = useState(false);
|
||||
const [newRepoUrl, setNewRepoUrl] = useState('');
|
||||
const [newRepoPublicKey, setNewRepoPublicKey] = useState('');
|
||||
const [addingRepo, setAddingRepo] = useState(false);
|
||||
const [gpgKeyFocused, setGpgKeyFocused] = useState(false);
|
||||
const [repoPreview, setRepoPreview] = useState(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const previewTimer = useRef(null);
|
||||
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
||||
|
||||
const loadRepoSettings = useCallback(async () => {
|
||||
const data = await getPluginRepoSettings();
|
||||
if (data) setRefreshInterval(data.refresh_interval_hours ?? 6);
|
||||
}, []);
|
||||
|
||||
const handleSaveInterval = useCallback((val) => {
|
||||
const hours = val ?? 0;
|
||||
setRefreshInterval(hours);
|
||||
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
|
||||
saveIntervalTimer.current = setTimeout(async () => {
|
||||
setSavingInterval(true);
|
||||
try {
|
||||
await updatePluginRepoSettings({ refresh_interval_hours: hours });
|
||||
} catch {
|
||||
// Error notification handled by API layer
|
||||
} finally {
|
||||
setSavingInterval(false);
|
||||
}
|
||||
}, 800);
|
||||
}, []);
|
||||
|
||||
// Debounced manifest preview
|
||||
const fetchPreview = useCallback((url, publicKey) => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
if (!url.trim() || !url.match(/^https?:\/\/.+/i)) {
|
||||
setRepoPreview(null);
|
||||
setPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
setPreviewLoading(true);
|
||||
previewTimer.current = setTimeout(async () => {
|
||||
const result = await previewPluginRepo(url.trim(), publicKey?.trim());
|
||||
setRepoPreview(result);
|
||||
setPreviewLoading(false);
|
||||
}, 600);
|
||||
}, []);
|
||||
|
||||
const handleAddRepo = useCallback(async () => {
|
||||
if (!newRepoUrl.trim()) return;
|
||||
setAddingRepo(true);
|
||||
try {
|
||||
await addRepo({
|
||||
url: newRepoUrl.trim(),
|
||||
public_key: newRepoPublicKey.trim(),
|
||||
});
|
||||
setNewRepoUrl('');
|
||||
setNewRepoPublicKey('');
|
||||
setRepoPreview(null);
|
||||
setShowAddRepo(false);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Added',
|
||||
message: 'Plugin repo added',
|
||||
color: 'green',
|
||||
});
|
||||
} catch {
|
||||
// Error notification handled by API layer
|
||||
} finally {
|
||||
setAddingRepo(false);
|
||||
}
|
||||
}, [newRepoUrl, newRepoPublicKey, addRepo, fetchAvailablePlugins]);
|
||||
|
||||
const handleDeleteRepo = useCallback(
|
||||
async (id) => {
|
||||
await removeRepo(id);
|
||||
setDeleteConfirmId(null);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Removed',
|
||||
message: 'Plugin repo removed',
|
||||
color: 'green',
|
||||
});
|
||||
},
|
||||
[removeRepo, fetchAvailablePlugins]
|
||||
);
|
||||
|
||||
const handleEditKey = useCallback((repo) => {
|
||||
setEditingKeyRepoId(repo.id);
|
||||
setEditKeyValue(repo.public_key || '');
|
||||
}, []);
|
||||
|
||||
const handleSaveKey = useCallback(async () => {
|
||||
if (editingKeyRepoId == null) return;
|
||||
setSavingKey(true);
|
||||
try {
|
||||
await updateRepo(editingKeyRepoId, { public_key: editKeyValue });
|
||||
await refreshRepo(editingKeyRepoId);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Updated',
|
||||
message: 'Public key updated',
|
||||
color: 'green',
|
||||
});
|
||||
setEditingKeyRepoId(null);
|
||||
setEditKeyValue('');
|
||||
} catch {
|
||||
showNotification({
|
||||
title: 'Error',
|
||||
message: 'Failed to update key',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setSavingKey(false);
|
||||
}
|
||||
}, [
|
||||
editingKeyRepoId,
|
||||
editKeyValue,
|
||||
updateRepo,
|
||||
refreshRepo,
|
||||
fetchAvailablePlugins,
|
||||
]);
|
||||
|
||||
// Load settings when modal opens
|
||||
useEffect(() => {
|
||||
if (opened) loadRepoSettings();
|
||||
}, [opened, loadRepoSettings]);
|
||||
|
||||
// Cleanup any pending timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group justify="space-between" align="flex-start" w="100%">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text fw={600}>Plugin Repositories</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
Add third-party plugin repositories or manage existing ones.
|
||||
Manifests are fetched automatically at the configured interval.
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left', flexShrink: 0 }}>
|
||||
<Text size="sm" fw={500} mb={2}>
|
||||
Refresh Interval
|
||||
</Text>
|
||||
<NumberInput
|
||||
value={refreshInterval}
|
||||
onChange={handleSaveInterval}
|
||||
min={0}
|
||||
max={168}
|
||||
size="xs"
|
||||
disabled={savingInterval}
|
||||
w={115}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
Hours, 0 to disable
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
size="lg"
|
||||
styles={{
|
||||
title: { width: '100%' },
|
||||
header: { alignItems: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{reposLoading && repos.length === 0 && <Loader size="sm" />}
|
||||
|
||||
{repos.map((repo) => (
|
||||
<React.Fragment key={repo.id}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--mantine-color-dark-6)',
|
||||
}}
|
||||
>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={500} size="sm" lineClamp={1}>
|
||||
{repo.name}
|
||||
</Text>
|
||||
{repo.is_official && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="filled"
|
||||
style={{ backgroundColor: '#14917E' }}
|
||||
>
|
||||
Official Repo
|
||||
</Badge>
|
||||
)}
|
||||
{repo.signature_verified === true && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Verified Signature
|
||||
</Badge>
|
||||
)}
|
||||
{repo.signature_verified === false && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<ShieldAlert size={10} />}
|
||||
>
|
||||
Invalid Signature
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{repo.registry_url ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
<a
|
||||
href={repo.registry_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--mantine-color-blue-4)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{repo.registry_url}
|
||||
</a>
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{repo.url}
|
||||
</Text>
|
||||
{repo.last_fetched && (
|
||||
<Text
|
||||
size="xs"
|
||||
c={
|
||||
repo.last_fetch_status &&
|
||||
repo.last_fetch_status !== '200'
|
||||
? 'red'
|
||||
: 'dimmed'
|
||||
}
|
||||
>
|
||||
Last fetched:{' '}
|
||||
{new Date(repo.last_fetched).toLocaleString()}
|
||||
{repo.last_fetch_status &&
|
||||
repo.last_fetch_status !== '200'
|
||||
? ` · ${repo.last_fetch_status}`
|
||||
: repo.plugin_count != null
|
||||
? ` · ${repo.plugin_count} plugin${repo.plugin_count !== 1 ? 's' : ''} available`
|
||||
: ''}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{!repo.is_official && (
|
||||
<Stack gap={4} align="center">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
title="Edit public key"
|
||||
onClick={() => handleEditKey(repo)}
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
title="Remove repo"
|
||||
onClick={() => setDeleteConfirmId(repo.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Stack>
|
||||
)}
|
||||
</Group>
|
||||
{editingKeyRepoId === repo.id && (
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Textarea
|
||||
placeholder={
|
||||
'-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nOptional: Paste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
|
||||
}
|
||||
value={editKeyValue}
|
||||
onChange={(e) => setEditKeyValue(e.currentTarget.value)}
|
||||
size="xs"
|
||||
minRows={3}
|
||||
autosize
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={handleSaveKey}
|
||||
loading={savingKey}
|
||||
>
|
||||
Save Key
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setEditingKeyRepoId(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{!showAddRepo ? (
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Plus size={16} />}
|
||||
size="sm"
|
||||
onClick={() => setShowAddRepo(true)}
|
||||
>
|
||||
Add Repository
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Text fw={500} size="sm" mt="sm">
|
||||
Add Repository
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
minHeight: 90,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'var(--mantine-color-dark-6)',
|
||||
border:
|
||||
repoPreview && !previewLoading && !repoPreview.valid
|
||||
? '1px solid var(--mantine-color-red-7)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
{previewLoading ? (
|
||||
<Group gap="xs" align="center">
|
||||
<Loader size={14} />
|
||||
<Text size="xs" c="dimmed">
|
||||
Checking manifest...
|
||||
</Text>
|
||||
</Group>
|
||||
) : repoPreview ? (
|
||||
repoPreview.valid ? (
|
||||
<Box>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={500} size="sm">
|
||||
{repoPreview.registry_name}
|
||||
</Text>
|
||||
{repoPreview.signature_verified === true && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Verified Signature
|
||||
</Badge>
|
||||
)}
|
||||
{repoPreview.signature_verified === false && (
|
||||
<>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Signed Manifest
|
||||
</Badge>
|
||||
<Text
|
||||
size="xs"
|
||||
c="var(--mantine-color-yellow-6)"
|
||||
fs="italic"
|
||||
>
|
||||
Public key required for verification
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{repoPreview.signature_verified == null && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
No Signature
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{repoPreview.registry_url ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
<a
|
||||
href={repoPreview.registry_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--mantine-color-blue-4)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{repoPreview.registry_url}
|
||||
</a>
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{newRepoUrl.trim()}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{repoPreview.plugin_count} plugin
|
||||
{repoPreview.plugin_count !== 1 ? 's' : ''} available
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Text size="xs" c="red">
|
||||
{repoPreview.errors?.join(' ') || 'Invalid manifest'}
|
||||
</Text>
|
||||
)
|
||||
) : (
|
||||
<Text size="xs" c="yellow">
|
||||
Third-party repositories are not reviewed by the Dispatcharr
|
||||
team.
|
||||
<br />
|
||||
Adding sources and installing plugins is done at your own
|
||||
risk.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<TextInput
|
||||
placeholder="Repository Manifest URL (ending in .json)"
|
||||
value={newRepoUrl}
|
||||
onChange={(e) => {
|
||||
setNewRepoUrl(e.currentTarget.value);
|
||||
fetchPreview(e.currentTarget.value, newRepoPublicKey);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
<Textarea
|
||||
placeholder={
|
||||
gpgKeyFocused
|
||||
? '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nPaste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
|
||||
: 'Optional: Paste public GPG key here'
|
||||
}
|
||||
value={newRepoPublicKey}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setNewRepoPublicKey(value);
|
||||
fetchPreview(newRepoUrl, value);
|
||||
}}
|
||||
size="sm"
|
||||
minRows={gpgKeyFocused || newRepoPublicKey ? 4 : 1}
|
||||
maxRows={8}
|
||||
autosize
|
||||
onFocus={() => setGpgKeyFocused(true)}
|
||||
onBlur={() => {
|
||||
if (!newRepoPublicKey) setGpgKeyFocused(false);
|
||||
}}
|
||||
styles={
|
||||
repoPreview?.valid &&
|
||||
repoPreview?.signature_verified === false &&
|
||||
!newRepoPublicKey.trim()
|
||||
? {
|
||||
input: { borderColor: 'var(--mantine-color-yellow-6)' },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowAddRepo(false);
|
||||
setNewRepoUrl('');
|
||||
setNewRepoPublicKey('');
|
||||
setRepoPreview(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddRepo}
|
||||
loading={addingRepo}
|
||||
disabled={!newRepoUrl.trim()}
|
||||
leftSection={<Plus size={16} />}
|
||||
size="sm"
|
||||
>
|
||||
Add Repo
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={deleteConfirmId != null}
|
||||
onClose={() => setDeleteConfirmId(null)}
|
||||
onConfirm={() => handleDeleteRepo(deleteConfirmId)}
|
||||
title="Remove Repository"
|
||||
message={
|
||||
<>
|
||||
<Text size="sm">
|
||||
Are you sure you want to remove this repository?
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
Plugins installed from this repo will remain installed but become
|
||||
unmanaged.
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
confirmLabel="Remove"
|
||||
size="sm"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,539 @@
|
|||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => {
|
||||
const React = require('react');
|
||||
|
||||
// Named declarations so reference equality checks (child.type === Stack) work
|
||||
function Stack({ children }) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
function RadioItem({ value, label, description, _onChange }) {
|
||||
return (
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
data-testid={`radio-${value}`}
|
||||
value={value}
|
||||
onChange={() => _onChange?.(value)}
|
||||
aria-label={label}
|
||||
/>
|
||||
{label}
|
||||
{description && (
|
||||
<span data-testid={`radio-desc-${value}`}>{description}</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioGroup({ children, onChange, label }) {
|
||||
// Inject _onChange into Radio children, handling the Stack wrapper
|
||||
const inject = (child) => {
|
||||
if (!React.isValidElement(child)) return child;
|
||||
if (child.type === RadioItem) {
|
||||
return React.cloneElement(child, { _onChange: onChange });
|
||||
}
|
||||
if (child.type === Stack && child.props.children) {
|
||||
return React.cloneElement(child, {
|
||||
children: React.Children.map(child.props.children, inject),
|
||||
});
|
||||
}
|
||||
return child;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
{React.Children.map(children, inject)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
Button: ({ children, onClick, variant }) => (
|
||||
<button onClick={onClick} data-variant={variant}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Checkbox: ({ label, checked, onChange }) => (
|
||||
<div>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="remember-checkbox"
|
||||
checked={checked ?? false}
|
||||
onChange={(e) =>
|
||||
onChange({ currentTarget: { checked: e.target.checked } })
|
||||
}
|
||||
/>
|
||||
{label && <label>{label}</label>}
|
||||
</div>
|
||||
),
|
||||
Divider: ({ label }) => <hr aria-label={label} />,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
MultiSelect: ({ label, data, value, onChange }) => {
|
||||
const flat = (data ?? []).flatMap((g) =>
|
||||
g.items ? g.items : [{ value: g.value, label: g.label }]
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
{flat.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
data-testid={`profile-option-${opt.value}`}
|
||||
onClick={() => onChange([...(value ?? []), opt.value])}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
NumberInput: ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
placeholder,
|
||||
description,
|
||||
}) => (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
<input
|
||||
type="number"
|
||||
data-testid="number-input"
|
||||
value={value ?? ''}
|
||||
min={min}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
{description && <span>{description}</span>}
|
||||
</div>
|
||||
),
|
||||
Radio: RadioItem,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
Text: ({ children, c }) => <span data-color={c}>{children}</span>,
|
||||
};
|
||||
});
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import CreateChannelModal from '../CreateChannelModal';
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
const makeProfiles = () => [
|
||||
{ id: '0', name: 'All Profiles' }, // should be filtered out
|
||||
{ id: '1', name: 'Profile One' },
|
||||
{ id: '2', name: 'Profile Two' },
|
||||
];
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
opened: true,
|
||||
onClose: vi.fn(),
|
||||
mode: 'provider',
|
||||
onModeChange: vi.fn(),
|
||||
numberValue: '',
|
||||
onNumberValueChange: vi.fn(),
|
||||
rememberChoice: false,
|
||||
onRememberChoiceChange: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
isBulk: false,
|
||||
streamCount: 1,
|
||||
streamName: 'My Stream',
|
||||
selectedProfileIds: [],
|
||||
onProfileIdsChange: vi.fn(),
|
||||
channelProfiles: makeProfiles(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('CreateChannelModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when opened is true', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the modal when opened is false', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ opened: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when the close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<CreateChannelModal {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Title and labels ───────────────────────────────────────────────────────
|
||||
|
||||
describe('title and labels', () => {
|
||||
it('shows "Create Channel" title for single mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'Create Channel'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows "Create Channels Options" title for bulk mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'Create Channels Options'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows "Create Channel" confirm button for single mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Create Channel' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Create Channels" confirm button for bulk mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
|
||||
expect(screen.getByText('Create Channels')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Number Assignment" numbering label for single mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Number Assignment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Numbering Mode" numbering label for bulk mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
|
||||
expect(screen.getByText('Numbering Mode')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Description text ───────────────────────────────────────────────────────
|
||||
|
||||
describe('description text', () => {
|
||||
it('shows the streamName in the description for single mode', () => {
|
||||
render(
|
||||
<CreateChannelModal {...defaultProps({ streamName: 'ESPN HD' })} />
|
||||
);
|
||||
expect(screen.getByText(/ESPN HD/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the streamCount in the description for bulk mode', () => {
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ isBulk: true, streamCount: 5 })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/5 channels/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Radio options ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('radio options', () => {
|
||||
it('renders all four radio options', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('radio-provider')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('radio-auto')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('radio-highest')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('radio-specific')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Use Provider Number" label for single mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByLabelText('Use Provider Number')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Use Provider Numbers" label for bulk mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
|
||||
expect(screen.getByLabelText('Use Provider Numbers')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Auto-Assign Next Available" label for single mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(
|
||||
screen.getByLabelText('Auto-Assign Next Available')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Auto-Assign Sequential" label for bulk mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
|
||||
expect(
|
||||
screen.getByLabelText('Auto-Assign Sequential')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Use Specific Number" label for single mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByLabelText('Use Specific Number')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Start from Custom Number" label for bulk mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ isBulk: true })} />);
|
||||
expect(
|
||||
screen.getByLabelText('Start from Custom Number')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onModeChange when a radio option is selected', () => {
|
||||
const onModeChange = vi.fn();
|
||||
render(<CreateChannelModal {...defaultProps({ onModeChange })} />);
|
||||
fireEvent.click(screen.getByLabelText('Auto-Assign Next Available'));
|
||||
expect(onModeChange).toHaveBeenCalledWith('auto');
|
||||
});
|
||||
});
|
||||
|
||||
// ── NumberInput visibility ─────────────────────────────────────────────────
|
||||
|
||||
describe('NumberInput visibility', () => {
|
||||
it('does not show NumberInput when mode is "provider"', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ mode: 'provider' })} />);
|
||||
expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show NumberInput when mode is "auto"', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ mode: 'auto' })} />);
|
||||
expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show NumberInput when mode is "highest"', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ mode: 'highest' })} />);
|
||||
expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows NumberInput when mode is "specific" in single mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ mode: 'specific' })} />);
|
||||
expect(screen.getByTestId('number-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows NumberInput when mode is "custom" in bulk mode', () => {
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ isBulk: true, mode: 'custom' })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('number-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show NumberInput when mode is "specific" but isBulk is true', () => {
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ isBulk: true, mode: 'specific' })}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId('number-input')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onNumberValueChange when NumberInput value changes', () => {
|
||||
const onNumberValueChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({
|
||||
mode: 'specific',
|
||||
numberValue: 5,
|
||||
onNumberValueChange,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('number-input'), {
|
||||
target: { value: '10' },
|
||||
});
|
||||
expect(onNumberValueChange).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('shows "Channel Number" label in single mode', () => {
|
||||
render(<CreateChannelModal {...defaultProps({ mode: 'specific' })} />);
|
||||
expect(screen.getByText('Channel Number')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Starting Channel Number" label in bulk mode', () => {
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ isBulk: true, mode: 'custom' })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Starting Channel Number')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Remember choice checkbox ───────────────────────────────────────────────
|
||||
|
||||
describe('remember choice checkbox', () => {
|
||||
it('renders unchecked when rememberChoice is false', () => {
|
||||
render(
|
||||
<CreateChannelModal {...defaultProps({ rememberChoice: false })} />
|
||||
);
|
||||
expect(screen.getByTestId('remember-checkbox')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('renders checked when rememberChoice is true', () => {
|
||||
render(
|
||||
<CreateChannelModal {...defaultProps({ rememberChoice: true })} />
|
||||
);
|
||||
expect(screen.getByTestId('remember-checkbox')).toBeChecked();
|
||||
});
|
||||
|
||||
it('calls onRememberChoiceChange with true when checked', () => {
|
||||
const onRememberChoiceChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ rememberChoice: false, onRememberChoiceChange })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('remember-checkbox'));
|
||||
expect(onRememberChoiceChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('calls onRememberChoiceChange with false when unchecked', () => {
|
||||
const onRememberChoiceChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ rememberChoice: true, onRememberChoiceChange })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('remember-checkbox'));
|
||||
expect(onRememberChoiceChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Action buttons ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('action buttons', () => {
|
||||
it('calls onConfirm when the confirm button is clicked', () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(<CreateChannelModal {...defaultProps({ onConfirm })} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create Channel' }));
|
||||
expect(onConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose when the Cancel button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<CreateChannelModal {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Channel profiles ───────────────────────────────────────────────────────
|
||||
|
||||
describe('channel profiles', () => {
|
||||
it('renders the "All Profiles" special option', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('profile-option-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the "No Profiles" special option', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('profile-option-none')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders channel profile options (excluding id "0")', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('profile-option-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('profile-option-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the profile with id "0"', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.queryByTestId('profile-option-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "All Profiles" and "No Profiles" labels', () => {
|
||||
render(<CreateChannelModal {...defaultProps()} />);
|
||||
expect(screen.getByText('All Profiles')).toBeInTheDocument();
|
||||
expect(screen.getByText('No Profiles')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleProfileChange logic ──────────────────────────────────────────────
|
||||
|
||||
describe('handleProfileChange', () => {
|
||||
it('selects only "all" when "All Profiles" is clicked', () => {
|
||||
const onProfileIdsChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ selectedProfileIds: [], onProfileIdsChange })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('profile-option-all'));
|
||||
expect(onProfileIdsChange).toHaveBeenCalledWith(['all']);
|
||||
});
|
||||
|
||||
it('selects only "none" when "No Profiles" is clicked', () => {
|
||||
const onProfileIdsChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ selectedProfileIds: [], onProfileIdsChange })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('profile-option-none'));
|
||||
expect(onProfileIdsChange).toHaveBeenCalledWith(['none']);
|
||||
});
|
||||
|
||||
it('removes "all" when a specific profile is added while "all" is selected', () => {
|
||||
const onProfileIdsChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ selectedProfileIds: ['all'], onProfileIdsChange })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('profile-option-1'));
|
||||
expect(onProfileIdsChange).toHaveBeenCalledWith(['1']);
|
||||
});
|
||||
|
||||
it('removes "none" when a specific profile is added while "none" is selected', () => {
|
||||
const onProfileIdsChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({
|
||||
selectedProfileIds: ['none'],
|
||||
onProfileIdsChange,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('profile-option-2'));
|
||||
expect(onProfileIdsChange).toHaveBeenCalledWith(['2']);
|
||||
});
|
||||
|
||||
it('allows selecting multiple specific profiles', () => {
|
||||
const onProfileIdsChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ selectedProfileIds: ['1'], onProfileIdsChange })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('profile-option-2'));
|
||||
expect(onProfileIdsChange).toHaveBeenCalledWith(['1', '2']);
|
||||
});
|
||||
|
||||
it('replaces existing "all" selection when "none" is clicked last', () => {
|
||||
const onProfileIdsChange = vi.fn();
|
||||
render(
|
||||
<CreateChannelModal
|
||||
{...defaultProps({ selectedProfileIds: ['all'], onProfileIdsChange })}
|
||||
/>
|
||||
);
|
||||
// Mock passes ['all', 'none'] to onChange, making lastSelected = 'none'
|
||||
fireEvent.click(screen.getByTestId('profile-option-none'));
|
||||
expect(onProfileIdsChange).toHaveBeenCalledWith(['none']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,597 @@
|
|||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
act,
|
||||
} from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/plugins.jsx', () => ({
|
||||
usePluginStore: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/pages/PluginsUtils.js', () => ({
|
||||
getPluginRepoSettings: vi.fn(),
|
||||
previewPluginRepo: vi.fn(),
|
||||
updatePluginRepoSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── ConfirmationDialog mock ────────────────────────────────────────────────────
|
||||
vi.mock('../../ConfirmationDialog.jsx', () => ({
|
||||
default: ({ opened, onClose, onConfirm, title, confirmLabel }) =>
|
||||
opened ? (
|
||||
<div data-testid="confirmation-dialog">
|
||||
<div data-testid="dialog-title">{title}</div>
|
||||
<button data-testid="dialog-confirm" onClick={onConfirm}>
|
||||
{confirmLabel || 'Confirm'}
|
||||
</button>
|
||||
<button data-testid="dialog-close" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
KeyRound: () => <svg data-testid="icon-key-round" />,
|
||||
Plus: () => <svg data-testid="icon-plus" />,
|
||||
ShieldAlert: () => <svg data-testid="icon-shield-alert" />,
|
||||
ShieldCheck: () => <svg data-testid="icon-shield-check" />,
|
||||
Trash2: () => <svg data-testid="icon-trash-2" />,
|
||||
}));
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
ActionIcon: ({ children, onClick, title, color, variant }) => (
|
||||
<button
|
||||
data-testid="action-icon"
|
||||
title={title}
|
||||
data-color={color}
|
||||
data-variant={variant}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Badge: ({ children, color, variant, leftSection }) => (
|
||||
<span data-testid="badge" data-color={color} data-variant={variant}>
|
||||
{leftSection}
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Box: ({ children }) => <div>{children}</div>,
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
loading,
|
||||
disabled,
|
||||
variant,
|
||||
color,
|
||||
leftSection,
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
data-loading={loading}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
>
|
||||
{leftSection}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Loader: ({ size }) => <div data-testid="loader" data-size={size} />,
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-header">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
NumberInput: ({ value, onChange, min, max, disabled }) => (
|
||||
<input
|
||||
type="number"
|
||||
data-testid="refresh-interval-input"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
min={min}
|
||||
max={max}
|
||||
disabled={disabled}
|
||||
/>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children, fw, size, c }) => (
|
||||
<span data-fw={fw} data-size={size} data-color={c}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Textarea: ({ value, onChange, placeholder, onFocus, onBlur }) => (
|
||||
<textarea
|
||||
data-testid="textarea"
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
),
|
||||
TextInput: ({ value, onChange, placeholder }) => (
|
||||
<input
|
||||
data-testid="repo-url-input"
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import ManageReposModal from '../ManageReposModal';
|
||||
import { usePluginStore } from '../../../store/plugins.jsx';
|
||||
import { showNotification } from '../../../utils/notificationUtils.js';
|
||||
import {
|
||||
getPluginRepoSettings,
|
||||
previewPluginRepo,
|
||||
updatePluginRepoSettings,
|
||||
} from '../../../utils/pages/PluginsUtils.js';
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
const makeRepo = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'Main Repo',
|
||||
url: 'https://example.com/manifest.json',
|
||||
is_official: false,
|
||||
signature_verified: null,
|
||||
registry_url: null,
|
||||
last_fetched: null,
|
||||
last_fetch_status: null,
|
||||
plugin_count: null,
|
||||
public_key: '',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
let mockFetchAvailablePlugins;
|
||||
let mockRefreshRepo;
|
||||
let mockAddRepo;
|
||||
let mockRemoveRepo;
|
||||
let mockUpdateRepo;
|
||||
|
||||
const setupStore = ({ repos = [], reposLoading = false } = {}) => {
|
||||
mockFetchAvailablePlugins = vi.fn().mockResolvedValue(undefined);
|
||||
mockRefreshRepo = vi.fn().mockResolvedValue(undefined);
|
||||
mockAddRepo = vi.fn().mockResolvedValue(undefined);
|
||||
mockRemoveRepo = vi.fn().mockResolvedValue(undefined);
|
||||
mockUpdateRepo = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(usePluginStore).mockImplementation((sel) =>
|
||||
sel({
|
||||
repos,
|
||||
reposLoading,
|
||||
fetchAvailablePlugins: mockFetchAvailablePlugins,
|
||||
refreshRepo: mockRefreshRepo,
|
||||
addRepo: mockAddRepo,
|
||||
removeRepo: mockRemoveRepo,
|
||||
updateRepo: mockUpdateRepo,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
opened: true,
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ManageReposModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupStore();
|
||||
vi.mocked(getPluginRepoSettings).mockResolvedValue({
|
||||
refresh_interval_hours: 6,
|
||||
});
|
||||
vi.mocked(updatePluginRepoSettings).mockResolvedValue(undefined);
|
||||
vi.mocked(previewPluginRepo).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when opened is true', () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the modal when opened is false', () => {
|
||||
render(<ManageReposModal {...defaultProps({ opened: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when the close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ManageReposModal {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Settings load ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('settings load', () => {
|
||||
it('calls getPluginRepoSettings when opened', async () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
await waitFor(() => {
|
||||
expect(getPluginRepoSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call getPluginRepoSettings when closed', async () => {
|
||||
render(<ManageReposModal {...defaultProps({ opened: false })} />);
|
||||
expect(getPluginRepoSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads the refresh interval from settings', async () => {
|
||||
vi.mocked(getPluginRepoSettings).mockResolvedValue({
|
||||
refresh_interval_hours: 24,
|
||||
});
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('refresh-interval-input')).toHaveValue(24);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Repos list ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('repos list', () => {
|
||||
it('renders a row for each repo', () => {
|
||||
setupStore({
|
||||
repos: [
|
||||
makeRepo({ id: 1, name: 'Repo A' }),
|
||||
makeRepo({ id: 2, name: 'Repo B' }),
|
||||
],
|
||||
});
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Repo A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Repo B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loader when loading and no repos are present', () => {
|
||||
setupStore({ reposLoading: true, repos: [] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('loader')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show loader when repos are present', () => {
|
||||
setupStore({ reposLoading: true, repos: [makeRepo()] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Official Repo" badge for official repos', () => {
|
||||
setupStore({ repos: [makeRepo({ is_official: true })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Official Repo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Verified Signature" badge when signature_verified is true', () => {
|
||||
setupStore({ repos: [makeRepo({ signature_verified: true })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Verified Signature')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Invalid Signature" badge when signature_verified is false', () => {
|
||||
setupStore({ repos: [makeRepo({ signature_verified: false })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Invalid Signature')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows edit and delete buttons only for non-official repos', () => {
|
||||
setupStore({ repos: [makeRepo({ is_official: false })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.getByTitle('Edit public key')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Remove repo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show edit and delete buttons for official repos', () => {
|
||||
setupStore({ repos: [makeRepo({ is_official: true })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.queryByTitle('Edit public key')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Remove repo')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Refresh interval ───────────────────────────────────────────────────────
|
||||
|
||||
describe('refresh interval', () => {
|
||||
it('calls updatePluginRepoSettings (debounced) when interval changes', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync(); // flush loadRepoSettings
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId('refresh-interval-input'), {
|
||||
target: { value: '12' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(900);
|
||||
});
|
||||
|
||||
expect(updatePluginRepoSettings).toHaveBeenCalledWith({
|
||||
refresh_interval_hours: 12,
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not call updatePluginRepoSettings before the debounce delay', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId('refresh-interval-input'), {
|
||||
target: { value: '12' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
});
|
||||
expect(updatePluginRepoSettings).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit public key ────────────────────────────────────────────────────────
|
||||
|
||||
describe('edit public key', () => {
|
||||
it('shows key editor when Edit button is clicked', () => {
|
||||
setupStore({ repos: [makeRepo({ id: 1 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Edit public key'));
|
||||
expect(screen.getByTestId('textarea')).toBeInTheDocument();
|
||||
expect(screen.getByText('Save Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-fills key editor with existing public key', () => {
|
||||
setupStore({ repos: [makeRepo({ id: 1, public_key: 'existing-key' })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Edit public key'));
|
||||
expect(screen.getByTestId('textarea')).toHaveValue('existing-key');
|
||||
});
|
||||
|
||||
it('hides key editor when Cancel is clicked', () => {
|
||||
setupStore({ repos: [makeRepo({ id: 1 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Edit public key'));
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(screen.queryByText('Save Key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls updateRepo, refreshRepo, and fetchAvailablePlugins on Save Key', async () => {
|
||||
setupStore({ repos: [makeRepo({ id: 7 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Edit public key'));
|
||||
fireEvent.change(screen.getByTestId('textarea'), {
|
||||
target: { value: 'new-key' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save Key'));
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateRepo).toHaveBeenCalledWith(7, {
|
||||
public_key: 'new-key',
|
||||
});
|
||||
expect(mockRefreshRepo).toHaveBeenCalledWith(7);
|
||||
expect(mockFetchAvailablePlugins).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Updated" notification after successful key save', async () => {
|
||||
setupStore({ repos: [makeRepo({ id: 1 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Edit public key'));
|
||||
fireEvent.click(screen.getByText('Save Key'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Updated', color: 'green' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error notification when key save fails', async () => {
|
||||
mockUpdateRepo = vi.fn().mockRejectedValue(new Error('Network error'));
|
||||
vi.mocked(usePluginStore).mockImplementation((sel) =>
|
||||
sel({
|
||||
repos: [makeRepo({ id: 1 })],
|
||||
reposLoading: false,
|
||||
fetchAvailablePlugins: mockFetchAvailablePlugins,
|
||||
refreshRepo: mockRefreshRepo,
|
||||
addRepo: mockAddRepo,
|
||||
removeRepo: mockRemoveRepo,
|
||||
updateRepo: mockUpdateRepo,
|
||||
})
|
||||
);
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Edit public key'));
|
||||
fireEvent.click(screen.getByText('Save Key'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Error', color: 'red' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes key editor after successful save', async () => {
|
||||
setupStore({ repos: [makeRepo({ id: 1 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Edit public key'));
|
||||
fireEvent.click(screen.getByText('Save Key'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Save Key')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete repo ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('delete repo', () => {
|
||||
it('opens ConfirmationDialog when delete button is clicked', () => {
|
||||
setupStore({ repos: [makeRepo({ id: 1 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Remove repo'));
|
||||
expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dialog-title')).toHaveTextContent(
|
||||
'Remove Repository'
|
||||
);
|
||||
});
|
||||
|
||||
it('closes ConfirmationDialog when cancelled', () => {
|
||||
setupStore({ repos: [makeRepo({ id: 1 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Remove repo'));
|
||||
fireEvent.click(screen.getByTestId('dialog-close'));
|
||||
expect(
|
||||
screen.queryByTestId('confirmation-dialog')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls removeRepo and fetchAvailablePlugins when confirmed', async () => {
|
||||
setupStore({ repos: [makeRepo({ id: 5 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Remove repo'));
|
||||
fireEvent.click(screen.getByTestId('dialog-confirm'));
|
||||
await waitFor(() => {
|
||||
expect(mockRemoveRepo).toHaveBeenCalledWith(5);
|
||||
expect(mockFetchAvailablePlugins).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Removed" notification after delete', async () => {
|
||||
setupStore({ repos: [makeRepo({ id: 1 })] });
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTitle('Remove repo'));
|
||||
fireEvent.click(screen.getByTestId('dialog-confirm'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Removed', color: 'green' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Add repository ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('add repository', () => {
|
||||
it('shows "Add Repository" button initially', () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Add Repository')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the add form when "Add Repository" is clicked', () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add Repository'));
|
||||
expect(screen.getByTestId('repo-url-input')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add Repo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the form and resets when Cancel is clicked', () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add Repository'));
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(screen.queryByTestId('repo-url-input')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Add Repository')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('"Add Repo" button is disabled when URL is empty', () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add Repository'));
|
||||
expect(screen.getByText('Add Repo')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('"Add Repo" button is enabled when URL is entered', () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add Repository'));
|
||||
fireEvent.change(screen.getByTestId('repo-url-input'), {
|
||||
target: { value: 'https://example.com/manifest.json' },
|
||||
});
|
||||
expect(screen.getByText('Add Repo')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls addRepo and fetchAvailablePlugins when submitted', async () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add Repository'));
|
||||
fireEvent.change(screen.getByTestId('repo-url-input'), {
|
||||
target: { value: 'https://example.com/manifest.json' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Add Repo'));
|
||||
await waitFor(() => {
|
||||
expect(mockAddRepo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: 'https://example.com/manifest.json' })
|
||||
);
|
||||
expect(mockFetchAvailablePlugins).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Added" notification after successful add', async () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add Repository'));
|
||||
fireEvent.change(screen.getByTestId('repo-url-input'), {
|
||||
target: { value: 'https://example.com/manifest.json' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Add Repo'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Added', color: 'green' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes the form after a successful add', async () => {
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add Repository'));
|
||||
fireEvent.change(screen.getByTestId('repo-url-input'), {
|
||||
target: { value: 'https://example.com/manifest.json' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Add Repo'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('repo-url-input')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers previewPluginRepo (debounced) when a valid URL is entered', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<ManageReposModal {...defaultProps()} />);
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Add Repository'));
|
||||
fireEvent.change(screen.getByTestId('repo-url-input'), {
|
||||
target: { value: 'https://example.com/manifest.json' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
});
|
||||
expect(previewPluginRepo).toHaveBeenCalledWith(
|
||||
'https://example.com/manifest.json',
|
||||
''
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
743
frontend/src/components/modals/__tests__/ProfileModal.test.jsx
Normal file
743
frontend/src/components/modals/__tests__/ProfileModal.test.jsx
Normal file
|
|
@ -0,0 +1,743 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── API mock ───────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../api', () => ({
|
||||
default: {
|
||||
updateChannelProfile: vi.fn(),
|
||||
duplicateChannelProfile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Notification mock ──────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/notificationUtils', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/channels', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Constants mock ─────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../constants', () => ({
|
||||
USER_LEVELS: { STREAMER: 0, STANDARD: 1, ADMIN: 10 },
|
||||
}));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
Copy: () => <svg data-testid="icon-copy" />,
|
||||
SquareMinus: () => <svg data-testid="icon-square-minus" />,
|
||||
SquarePen: () => <svg data-testid="icon-square-pen" />,
|
||||
}));
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
ActionIcon: ({ children, onClick, disabled }) => (
|
||||
<button data-testid="action-icon" onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Alert: ({ children, title, color }) => (
|
||||
<div data-testid="alert" data-color={color}>
|
||||
{title && <div data-testid="alert-title">{title}</div>}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Box: ({ children }) => <div>{children}</div>,
|
||||
Button: ({ children, onClick, variant, size }) => (
|
||||
<button onClick={onClick} data-variant={variant} data-size={size}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children, size }) => <span data-size={size}>{children}</span>,
|
||||
TextInput: ({ label, value, onChange, placeholder }) => (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
<input
|
||||
data-testid="profile-name-input"
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import ProfileModal, { renderProfileOption } from '../ProfileModal';
|
||||
import API from '../../../api';
|
||||
import useChannelsStore from '../../../store/channels';
|
||||
import { showNotification } from '../../../utils/notificationUtils';
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
const makeProfile = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'My Profile',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
opened: true,
|
||||
onClose: vi.fn(),
|
||||
mode: 'edit',
|
||||
profile: makeProfile(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ProfileModal', () => {
|
||||
let mockSetSelectedProfileId;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockSetSelectedProfileId = vi.fn();
|
||||
vi.mocked(useChannelsStore).mockImplementation((sel) =>
|
||||
sel({ setSelectedProfileId: mockSetSelectedProfileId })
|
||||
);
|
||||
vi.mocked(API.updateChannelProfile).mockResolvedValue({
|
||||
id: 1,
|
||||
name: 'Updated',
|
||||
});
|
||||
vi.mocked(API.duplicateChannelProfile).mockResolvedValue({
|
||||
id: 2,
|
||||
name: 'My Profile Copy',
|
||||
});
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when opened is true', () => {
|
||||
render(<ProfileModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the modal when opened is false', () => {
|
||||
render(<ProfileModal {...defaultProps({ opened: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Title ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('title', () => {
|
||||
it('shows "Rename Profile: {name}" title in edit mode', () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'edit',
|
||||
profile: makeProfile({ name: 'Work Profile' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'Rename Profile: Work Profile'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows "Duplicate Profile: {name}" title in duplicate mode', () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'duplicate',
|
||||
profile: makeProfile({ name: 'Work Profile' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent(
|
||||
'Duplicate Profile: Work Profile'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Warning alert ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('warning alert', () => {
|
||||
it('shows the warning alert in edit mode', () => {
|
||||
render(<ProfileModal {...defaultProps({ mode: 'edit' })} />);
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the warning alert in duplicate mode', () => {
|
||||
render(<ProfileModal {...defaultProps({ mode: 'duplicate' })} />);
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Initial name value ─────────────────────────────────────────────────────
|
||||
|
||||
describe('initial name value', () => {
|
||||
it('pre-fills the input with the profile name in edit mode', () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'edit',
|
||||
profile: makeProfile({ name: 'My Profile' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('profile-name-input')).toHaveValue(
|
||||
'My Profile'
|
||||
);
|
||||
});
|
||||
|
||||
it('pre-fills the input with "{name} Copy" in duplicate mode', () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'duplicate',
|
||||
profile: makeProfile({ name: 'My Profile' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('profile-name-input')).toHaveValue(
|
||||
'My Profile Copy'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Button labels ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('button labels', () => {
|
||||
it('shows "Save" button in edit mode', () => {
|
||||
render(<ProfileModal {...defaultProps({ mode: 'edit' })} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Duplicate" button in duplicate mode', () => {
|
||||
render(<ProfileModal {...defaultProps({ mode: 'duplicate' })} />);
|
||||
expect(screen.getByText('Duplicate')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cancel / close ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('cancel / close', () => {
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ProfileModal {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose when the modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ProfileModal {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Submit validation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('submit validation', () => {
|
||||
it('shows notification when name is empty', () => {
|
||||
render(<ProfileModal {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Profile name is required',
|
||||
color: 'red.5',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('shows notification when name is only whitespace', () => {
|
||||
render(<ProfileModal {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: ' ' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Profile name is required' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call API when name is empty', () => {
|
||||
render(<ProfileModal {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
expect(API.updateChannelProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns early without notification or API call when profile is undefined', () => {
|
||||
render(<ProfileModal {...defaultProps({ profile: undefined })} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
expect(API.updateChannelProfile).not.toHaveBeenCalled();
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit mode submission ───────────────────────────────────────────────────
|
||||
|
||||
describe('edit mode submission', () => {
|
||||
it('closes without calling API when name is unchanged', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
onClose,
|
||||
mode: 'edit',
|
||||
profile: makeProfile({ name: 'My Profile' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
expect(API.updateChannelProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls updateChannelProfile with id and new name when name changes', async () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'edit',
|
||||
profile: makeProfile({ id: 7, name: 'Old Name' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: 'New Name' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(API.updateChannelProfile).toHaveBeenCalledWith({
|
||||
id: 7,
|
||||
name: 'New Name',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('trims whitespace from the name before submitting', async () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'edit',
|
||||
profile: makeProfile({ id: 1, name: 'Old' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: ' Trimmed Name ' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(API.updateChannelProfile).toHaveBeenCalledWith({
|
||||
id: 1,
|
||||
name: 'Trimmed Name',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Profile renamed" notification on success', async () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'edit',
|
||||
profile: makeProfile({ name: 'Old Name' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: 'New Name' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Profile renamed',
|
||||
color: 'green.5',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after a successful rename', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
onClose,
|
||||
mode: 'edit',
|
||||
profile: makeProfile({ name: 'Old' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: 'New' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not notify or close when updateChannelProfile returns null', async () => {
|
||||
vi.mocked(API.updateChannelProfile).mockResolvedValue(null);
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
onClose,
|
||||
mode: 'edit',
|
||||
profile: makeProfile({ name: 'Old' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: 'New' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(API.updateChannelProfile).toHaveBeenCalled();
|
||||
});
|
||||
expect(showNotification).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Profile renamed' })
|
||||
);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Duplicate mode submission ──────────────────────────────────────────────
|
||||
|
||||
describe('duplicate mode submission', () => {
|
||||
it('calls duplicateChannelProfile with profile id and default copy name', async () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'duplicate',
|
||||
profile: makeProfile({ id: 5, name: 'My Profile' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Duplicate'));
|
||||
await waitFor(() => {
|
||||
expect(API.duplicateChannelProfile).toHaveBeenCalledWith(
|
||||
5,
|
||||
'My Profile Copy'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the custom name entered by the user', async () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
mode: 'duplicate',
|
||||
profile: makeProfile({ id: 3, name: 'Base' }),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('profile-name-input'), {
|
||||
target: { value: 'Custom Copy Name' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Duplicate'));
|
||||
await waitFor(() => {
|
||||
expect(API.duplicateChannelProfile).toHaveBeenCalledWith(
|
||||
3,
|
||||
'Custom Copy Name'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Profile duplicated" notification on success', async () => {
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({ mode: 'duplicate', profile: makeProfile() })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Duplicate'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Profile duplicated',
|
||||
color: 'green.5',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setSelectedProfileId with the string id of the duplicated profile', async () => {
|
||||
vi.mocked(API.duplicateChannelProfile).mockResolvedValue({
|
||||
id: 99,
|
||||
name: 'Copy',
|
||||
});
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({ mode: 'duplicate', profile: makeProfile() })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Duplicate'));
|
||||
await waitFor(() => {
|
||||
expect(mockSetSelectedProfileId).toHaveBeenCalledWith('99');
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after a successful duplication', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
onClose,
|
||||
mode: 'duplicate',
|
||||
profile: makeProfile(),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Duplicate'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call setSelectedProfileId or close when duplicateChannelProfile returns null', async () => {
|
||||
vi.mocked(API.duplicateChannelProfile).mockResolvedValue(null);
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ProfileModal
|
||||
{...defaultProps({
|
||||
onClose,
|
||||
mode: 'duplicate',
|
||||
profile: makeProfile(),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Duplicate'));
|
||||
await waitFor(() => {
|
||||
expect(API.duplicateChannelProfile).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockSetSelectedProfileId).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('renderProfileOption', () => {
|
||||
const mockTheme = {
|
||||
tailwind: {
|
||||
yellow: { 3: '#f59e0b' },
|
||||
green: { 5: '#22c55e' },
|
||||
red: { 6: '#dc2626' },
|
||||
},
|
||||
};
|
||||
|
||||
const adminUser = { user_level: 10 };
|
||||
const nonAdminUser = { user_level: 1 };
|
||||
|
||||
const makeRenderArgs = (overrides = {}) => ({
|
||||
theme: mockTheme,
|
||||
profiles: [],
|
||||
onEditProfile: vi.fn(),
|
||||
onDeleteProfile: vi.fn(),
|
||||
authUser: adminUser,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderOption = (optionValue, optionLabel, args) => {
|
||||
const renderFn = renderProfileOption(
|
||||
args.theme,
|
||||
args.profiles,
|
||||
args.onEditProfile,
|
||||
args.onDeleteProfile,
|
||||
args.authUser
|
||||
);
|
||||
const { container } = render(
|
||||
renderFn({ option: { value: optionValue, label: optionLabel } })
|
||||
);
|
||||
return container;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the option label', () => {
|
||||
renderOption('1', 'Profile One', makeRenderArgs());
|
||||
expect(screen.getByText('Profile One')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows action icons when option value is not "0"', () => {
|
||||
renderOption('1', 'Profile One', makeRenderArgs());
|
||||
expect(screen.getByTestId('icon-square-pen')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('icon-copy')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('icon-square-minus')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show action icons when option value is "0"', () => {
|
||||
renderOption('0', 'All Profiles', makeRenderArgs());
|
||||
expect(screen.queryByTestId('icon-square-pen')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('icon-copy')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('icon-square-minus')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rename action ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('rename action', () => {
|
||||
it('calls onEditProfile with "edit" and option value when rename is clicked', () => {
|
||||
const args = makeRenderArgs();
|
||||
renderOption('3', 'Profile Three', args);
|
||||
fireEvent.click(screen.getByTestId('icon-square-pen').closest('button'));
|
||||
expect(args.onEditProfile).toHaveBeenCalledWith('edit', '3');
|
||||
});
|
||||
|
||||
it('rename button is enabled for admin user', () => {
|
||||
renderOption('1', 'Profile One', makeRenderArgs({ authUser: adminUser }));
|
||||
expect(
|
||||
screen.getByTestId('icon-square-pen').closest('button')
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('rename button is disabled for non-admin user', () => {
|
||||
renderOption(
|
||||
'1',
|
||||
'Profile One',
|
||||
makeRenderArgs({ authUser: nonAdminUser })
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('icon-square-pen').closest('button')
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Duplicate action ───────────────────────────────────────────────────────
|
||||
|
||||
describe('duplicate action', () => {
|
||||
it('calls onEditProfile with "duplicate" and option value when duplicate is clicked', () => {
|
||||
const args = makeRenderArgs();
|
||||
renderOption('5', 'Profile Five', args);
|
||||
fireEvent.click(screen.getByTestId('icon-copy').closest('button'));
|
||||
expect(args.onEditProfile).toHaveBeenCalledWith('duplicate', '5');
|
||||
});
|
||||
|
||||
it('duplicate button is enabled for admin user', () => {
|
||||
renderOption('1', 'Profile One', makeRenderArgs({ authUser: adminUser }));
|
||||
expect(
|
||||
screen.getByTestId('icon-copy').closest('button')
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('duplicate button is disabled for non-admin user', () => {
|
||||
renderOption(
|
||||
'1',
|
||||
'Profile One',
|
||||
makeRenderArgs({ authUser: nonAdminUser })
|
||||
);
|
||||
expect(screen.getByTestId('icon-copy').closest('button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete action ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('delete action', () => {
|
||||
it('calls onDeleteProfile with option value when delete is clicked', () => {
|
||||
const args = makeRenderArgs();
|
||||
renderOption('7', 'Profile Seven', args);
|
||||
fireEvent.click(
|
||||
screen.getByTestId('icon-square-minus').closest('button')
|
||||
);
|
||||
expect(args.onDeleteProfile).toHaveBeenCalledWith('7');
|
||||
});
|
||||
|
||||
it('delete button is disabled for non-admin user', () => {
|
||||
renderOption(
|
||||
'1',
|
||||
'Profile One',
|
||||
makeRenderArgs({ authUser: nonAdminUser })
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('icon-square-minus').closest('button')
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Event propagation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('event propagation', () => {
|
||||
it('does not propagate click event when rename is clicked', () => {
|
||||
const args = makeRenderArgs();
|
||||
const parentClick = vi.fn();
|
||||
const renderFn = renderProfileOption(
|
||||
mockTheme,
|
||||
[],
|
||||
args.onEditProfile,
|
||||
args.onDeleteProfile,
|
||||
adminUser
|
||||
);
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
{renderFn({ option: { value: '1', label: 'Profile' } })}
|
||||
</div>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('icon-square-pen').closest('button'));
|
||||
expect(parentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not propagate click event when duplicate is clicked', () => {
|
||||
const args = makeRenderArgs();
|
||||
const parentClick = vi.fn();
|
||||
const renderFn = renderProfileOption(
|
||||
mockTheme,
|
||||
[],
|
||||
args.onEditProfile,
|
||||
args.onDeleteProfile,
|
||||
adminUser
|
||||
);
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
{renderFn({ option: { value: '1', label: 'Profile' } })}
|
||||
</div>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('icon-copy').closest('button'));
|
||||
expect(parentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not propagate click event when delete is clicked', () => {
|
||||
const args = makeRenderArgs();
|
||||
const parentClick = vi.fn();
|
||||
const renderFn = renderProfileOption(
|
||||
mockTheme,
|
||||
[],
|
||||
args.onEditProfile,
|
||||
args.onDeleteProfile,
|
||||
adminUser
|
||||
);
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
{renderFn({ option: { value: '1', label: 'Profile' } })}
|
||||
</div>
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByTestId('icon-square-minus').closest('button')
|
||||
);
|
||||
expect(parentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
325
frontend/src/hooks/__tests__/useEpgPreview.test.jsx
Normal file
325
frontend/src/hooks/__tests__/useEpgPreview.test.jsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// ── API mock ───────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../api', () => ({
|
||||
default: {
|
||||
getCurrentProgramForEpg: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import API from '../../api';
|
||||
import { useEpgPreview } from '../useEpgPreview';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useEpgPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Invalid / empty epgDataId ──────────────────────────────────────────────
|
||||
|
||||
describe('invalid epgDataId', () => {
|
||||
it.each([null, undefined, '', '0'])(
|
||||
'returns defaults and skips the API call when epgDataId is %s',
|
||||
async (id) => {
|
||||
const { result } = renderHook(() => useEpgPreview(id));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).toBe(null);
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
expect(result.current.hasFetchedProgram).toBe(false);
|
||||
expect(API.getCurrentProgramForEpg).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// ── Initial state ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('initial state with a valid epgDataId', () => {
|
||||
it('sets isLoadingProgram true and hasFetchedProgram false immediately', () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue(null);
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
expect(result.current.isLoadingProgram).toBe(true);
|
||||
expect(result.current.hasFetchedProgram).toBe(false);
|
||||
expect(result.current.currentProgram).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Successful fetch ───────────────────────────────────────────────────────
|
||||
|
||||
describe('successful fetch', () => {
|
||||
it('calls the API with the provided epgDataId', async () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({ id: 1 });
|
||||
renderHook(() => useEpgPreview('epg-42'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(API.getCurrentProgramForEpg).toHaveBeenCalledWith('epg-42');
|
||||
});
|
||||
|
||||
it('sets currentProgram to the returned program', async () => {
|
||||
const program = { id: 7, title: 'News at Six', channel: 'BBC' };
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue(program);
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).toEqual(program);
|
||||
});
|
||||
|
||||
it('sets isLoadingProgram to false after a successful fetch', async () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({ id: 1 });
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
});
|
||||
|
||||
it('sets hasFetchedProgram to true after a successful fetch', async () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({ id: 1 });
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.hasFetchedProgram).toBe(true);
|
||||
});
|
||||
|
||||
it('sets currentProgram to null and completes when API returns null', async () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue(null);
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).toBe(null);
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
expect(result.current.hasFetchedProgram).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Parsing retry ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('parsing retry', () => {
|
||||
it('retries after a delay when program.parsing is true', async () => {
|
||||
const parsingProgram = { id: 1, parsing: true };
|
||||
const readyProgram = { id: 1, title: 'News', parsing: false };
|
||||
|
||||
vi.mocked(API.getCurrentProgramForEpg)
|
||||
.mockResolvedValueOnce(parsingProgram)
|
||||
.mockResolvedValueOnce(readyProgram);
|
||||
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(API.getCurrentProgramForEpg).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.currentProgram).toEqual(readyProgram);
|
||||
expect(result.current.hasFetchedProgram).toBe(true);
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
});
|
||||
|
||||
it('does not set a still-parsing program as currentProgram', async () => {
|
||||
const parsingProgram = { id: 1, parsing: true };
|
||||
const readyProgram = { id: 1, title: 'Ready', parsing: false };
|
||||
|
||||
vi.mocked(API.getCurrentProgramForEpg)
|
||||
.mockResolvedValueOnce(parsingProgram)
|
||||
.mockResolvedValueOnce(readyProgram);
|
||||
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).toEqual(readyProgram);
|
||||
expect(result.current.currentProgram?.parsing).toBeFalsy();
|
||||
});
|
||||
|
||||
it('resolves to null after all retries are exhausted with parsing: true', async () => {
|
||||
// Always returns parsing: true — the hook will exhaust retries/deadline
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({
|
||||
id: 1,
|
||||
parsing: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).toBe(null);
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
expect(result.current.hasFetchedProgram).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error handling ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('error handling', () => {
|
||||
it('retries after an API error and sets currentProgram on the next success', async () => {
|
||||
const program = { id: 1, title: 'News' };
|
||||
vi.mocked(API.getCurrentProgramForEpg)
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockResolvedValueOnce(program);
|
||||
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(API.getCurrentProgramForEpg).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.currentProgram).toEqual(program);
|
||||
expect(result.current.hasFetchedProgram).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves to null after all retries are exhausted by persistent errors', async () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockRejectedValue(
|
||||
new Error('Persistent error')
|
||||
);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).toBe(null);
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
expect(result.current.hasFetchedProgram).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── epgDataId changes ──────────────────────────────────────────────────────
|
||||
|
||||
describe('epgDataId changes', () => {
|
||||
it('resets to defaults when epgDataId changes to null', async () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({
|
||||
id: 1,
|
||||
title: 'News',
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(({ id }) => useEpgPreview(id), {
|
||||
initialProps: { id: 'epg-1' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).not.toBe(null);
|
||||
|
||||
rerender({ id: null });
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).toBe(null);
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
expect(result.current.hasFetchedProgram).toBe(false);
|
||||
});
|
||||
|
||||
it('resets to defaults when epgDataId changes to "0"', async () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({
|
||||
id: 1,
|
||||
title: 'News',
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(({ id }) => useEpgPreview(id), {
|
||||
initialProps: { id: 'epg-1' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
rerender({ id: '0' });
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).toBe(null);
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
expect(result.current.hasFetchedProgram).toBe(false);
|
||||
});
|
||||
|
||||
it('fetches with the new epgDataId when it changes to another valid value', async () => {
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockResolvedValue({ id: 1 });
|
||||
|
||||
const { rerender } = renderHook(({ id }) => useEpgPreview(id), {
|
||||
initialProps: { id: 'epg-1' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
rerender({ id: 'epg-2' });
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(API.getCurrentProgramForEpg).toHaveBeenCalledWith('epg-1');
|
||||
expect(API.getCurrentProgramForEpg).toHaveBeenCalledWith('epg-2');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cleanup on unmount ─────────────────────────────────────────────────────
|
||||
|
||||
describe('cleanup on unmount', () => {
|
||||
it('does not update currentProgram after unmount', async () => {
|
||||
let resolveProgram;
|
||||
vi.mocked(API.getCurrentProgramForEpg).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveProgram = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const { result, unmount } = renderHook(() => useEpgPreview('epg-1'));
|
||||
|
||||
// Unmount before the API call resolves
|
||||
unmount();
|
||||
|
||||
// Resolve the pending request
|
||||
await act(async () => {
|
||||
resolveProgram({ id: 1, title: 'Late News' });
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
// currentProgram should remain null — the cancelled flag prevented the update
|
||||
expect(result.current.currentProgram).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -7,66 +7,34 @@ import {
|
|||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NativeSelect,
|
||||
NumberInput,
|
||||
Pagination,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import API from '../api.js';
|
||||
import {
|
||||
RefreshCcw,
|
||||
Trash2,
|
||||
Plus,
|
||||
Search,
|
||||
KeyRound,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import { Package, RefreshCcw, Search } from 'lucide-react';
|
||||
import { usePluginStore } from '../store/plugins.jsx';
|
||||
import useSettingsStore from '../store/settings.jsx';
|
||||
import AvailablePluginCard from '../components/cards/AvailablePluginCard.jsx';
|
||||
import ManageReposModal from '../components/modals/ManageReposModal.jsx';
|
||||
import { showNotification } from '../utils/notificationUtils.js';
|
||||
import { reloadPlugins } from '../utils/pages/PluginsUtils.js';
|
||||
import { compareVersions } from '../utils/components/pluginUtils.js';
|
||||
|
||||
export default function PluginBrowsePage() {
|
||||
const repos = usePluginStore((s) => s.repos);
|
||||
const reposLoading = usePluginStore((s) => s.reposLoading);
|
||||
const availablePlugins = usePluginStore((s) => s.availablePlugins);
|
||||
const availableLoading = usePluginStore((s) => s.availableLoading);
|
||||
const fetchRepos = usePluginStore((s) => s.fetchRepos);
|
||||
const fetchAvailablePlugins = usePluginStore((s) => s.fetchAvailablePlugins);
|
||||
const refreshRepo = usePluginStore((s) => s.refreshRepo);
|
||||
const addRepo = usePluginStore((s) => s.addRepo);
|
||||
const removeRepo = usePluginStore((s) => s.removeRepo);
|
||||
const updateRepo = usePluginStore((s) => s.updateRepo);
|
||||
|
||||
const appVersion = useSettingsStore((s) => s.version?.version || '');
|
||||
|
||||
const [repoModalOpen, setRepoModalOpen] = useState(false);
|
||||
const [newRepoUrl, setNewRepoUrl] = useState('');
|
||||
const [newRepoPublicKey, setNewRepoPublicKey] = useState('');
|
||||
const [addingRepo, setAddingRepo] = useState(false);
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
const [editingKeyRepoId, setEditingKeyRepoId] = useState(null);
|
||||
const [editKeyValue, setEditKeyValue] = useState('');
|
||||
const [savingKey, setSavingKey] = useState(false);
|
||||
const [showAddRepo, setShowAddRepo] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
||||
const [gpgKeyFocused, setGpgKeyFocused] = useState(false);
|
||||
const [repoPreview, setRepoPreview] = useState(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const previewTimer = useRef(null);
|
||||
const [refreshInterval, setRefreshInterval] = useState(6);
|
||||
const [savingInterval, setSavingInterval] = useState(false);
|
||||
const saveIntervalTimer = useRef(null);
|
||||
|
||||
const recentlyInstalledSlugs = useRef(new Set());
|
||||
const recentlyUpdatedSlugs = useRef(new Set());
|
||||
|
|
@ -121,130 +89,6 @@ export default function PluginBrowsePage() {
|
|||
}
|
||||
}, [refreshRepo, fetchAvailablePlugins]);
|
||||
|
||||
const handleAddRepo = useCallback(async () => {
|
||||
if (!newRepoUrl.trim()) return;
|
||||
setAddingRepo(true);
|
||||
try {
|
||||
await addRepo({
|
||||
url: newRepoUrl.trim(),
|
||||
public_key: newRepoPublicKey.trim(),
|
||||
});
|
||||
setNewRepoUrl('');
|
||||
setNewRepoPublicKey('');
|
||||
setRepoPreview(null);
|
||||
setShowAddRepo(false);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Added',
|
||||
message: 'Plugin repo added',
|
||||
color: 'green',
|
||||
});
|
||||
} catch {
|
||||
// Error notification handled by API layer
|
||||
} finally {
|
||||
setAddingRepo(false);
|
||||
}
|
||||
}, [newRepoUrl, newRepoPublicKey, addRepo, fetchAvailablePlugins]);
|
||||
|
||||
const handleDeleteRepo = useCallback(
|
||||
async (id) => {
|
||||
await removeRepo(id);
|
||||
setDeleteConfirmId(null);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Removed',
|
||||
message: 'Plugin repo removed',
|
||||
color: 'green',
|
||||
});
|
||||
},
|
||||
[removeRepo, fetchAvailablePlugins]
|
||||
);
|
||||
|
||||
const handleEditKey = useCallback((repo) => {
|
||||
setEditingKeyRepoId(repo.id);
|
||||
setEditKeyValue(repo.public_key || '');
|
||||
}, []);
|
||||
|
||||
const handleSaveKey = useCallback(async () => {
|
||||
if (editingKeyRepoId == null) return;
|
||||
setSavingKey(true);
|
||||
try {
|
||||
await updateRepo(editingKeyRepoId, { public_key: editKeyValue });
|
||||
await refreshRepo(editingKeyRepoId);
|
||||
await fetchAvailablePlugins();
|
||||
showNotification({
|
||||
title: 'Updated',
|
||||
message: 'Public key updated',
|
||||
color: 'green',
|
||||
});
|
||||
setEditingKeyRepoId(null);
|
||||
setEditKeyValue('');
|
||||
} catch {
|
||||
showNotification({
|
||||
title: 'Error',
|
||||
message: 'Failed to update key',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setSavingKey(false);
|
||||
}
|
||||
}, [
|
||||
editingKeyRepoId,
|
||||
editKeyValue,
|
||||
updateRepo,
|
||||
refreshRepo,
|
||||
fetchAvailablePlugins,
|
||||
]);
|
||||
|
||||
const loadRepoSettings = useCallback(async () => {
|
||||
const data = await API.getPluginRepoSettings();
|
||||
if (data) setRefreshInterval(data.refresh_interval_hours ?? 6);
|
||||
}, []);
|
||||
|
||||
const handleSaveInterval = useCallback((val) => {
|
||||
const hours = val ?? 0;
|
||||
setRefreshInterval(hours);
|
||||
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
|
||||
saveIntervalTimer.current = setTimeout(async () => {
|
||||
setSavingInterval(true);
|
||||
try {
|
||||
await API.updatePluginRepoSettings({ refresh_interval_hours: hours });
|
||||
} catch {
|
||||
// Error notification handled by API layer
|
||||
} finally {
|
||||
setSavingInterval(false);
|
||||
}
|
||||
}, 800);
|
||||
}, []);
|
||||
|
||||
// Debounced manifest preview
|
||||
const fetchPreview = useCallback((url, publicKey) => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
if (!url.trim() || !url.match(/^https?:\/\/.+/i)) {
|
||||
setRepoPreview(null);
|
||||
setPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
setPreviewLoading(true);
|
||||
previewTimer.current = setTimeout(async () => {
|
||||
const result = await API.previewPluginRepo(url.trim(), publicKey?.trim());
|
||||
setRepoPreview(result);
|
||||
setPreviewLoading(false);
|
||||
}, 600);
|
||||
}, []);
|
||||
|
||||
// Cleanup any pending timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current);
|
||||
};
|
||||
}, []);
|
||||
// Load settings when modal opens
|
||||
useEffect(() => {
|
||||
if (repoModalOpen) loadRepoSettings();
|
||||
}, [repoModalOpen, loadRepoSettings]);
|
||||
|
||||
const loading = availableLoading && availablePlugins.length === 0;
|
||||
|
||||
// Build repo filter options from available plugins
|
||||
|
|
@ -367,153 +211,152 @@ export default function PluginBrowsePage() {
|
|||
}}
|
||||
>
|
||||
<Box p={16} pb={60} style={{ flex: 1 }}>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={700} size="lg">
|
||||
Find Plugins
|
||||
</Text>
|
||||
{availablePlugins.length > 0 && (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{availablePlugins.length} Plugins Available
|
||||
</Badge>
|
||||
)}
|
||||
{repos.length > 1 && (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{repos.length} Repos
|
||||
</Badge>
|
||||
)}
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={700} size="lg">
|
||||
Find Plugins
|
||||
</Text>
|
||||
{availablePlugins.length > 0 && (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{availablePlugins.length} Plugins Available
|
||||
</Badge>
|
||||
)}
|
||||
{repos.length > 1 && (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{repos.length} Repos
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
component="a"
|
||||
href="https://github.com/Dispatcharr/Plugins?tab=contributing-ov-file"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
leftSection={<Package size={14} />}
|
||||
>
|
||||
Publish Your Plugin
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => setRepoModalOpen(true)}
|
||||
>
|
||||
Manage Repos
|
||||
</Button>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
onClick={handleRefreshAll}
|
||||
title="Refresh all repos"
|
||||
loading={refreshingAll}
|
||||
>
|
||||
<RefreshCcw size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
<Group>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
component="a"
|
||||
href="https://github.com/Dispatcharr/Plugins?tab=contributing-ov-file"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
leftSection={<Package size={14} />}
|
||||
>
|
||||
Publish Your Plugin
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => setRepoModalOpen(true)}
|
||||
>
|
||||
Manage Repos
|
||||
</Button>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
onClick={handleRefreshAll}
|
||||
title="Refresh all repos"
|
||||
loading={refreshingAll}
|
||||
>
|
||||
<RefreshCcw size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{loading && <Loader />}
|
||||
{loading && <Loader />}
|
||||
|
||||
{!loading && (
|
||||
<Group gap="sm" mb="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search plugins..."
|
||||
leftSection={<Search size={14} />}
|
||||
size="xs"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
style={{ flex: 1, minWidth: 180, maxWidth: 300 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
allowDeselect={false}
|
||||
value={sortBy}
|
||||
onChange={setSortBy}
|
||||
data={[
|
||||
{ value: 'name-asc', label: 'Name A-Z' },
|
||||
{ value: 'name-desc', label: 'Name Z-A' },
|
||||
{ value: 'author', label: 'Author' },
|
||||
{ value: 'updated', label: 'Recently Updated' },
|
||||
]}
|
||||
style={{ width: 170 }}
|
||||
/>
|
||||
{repoOptions.length > 2 && (
|
||||
{!loading && (
|
||||
<Group gap="sm" mb="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search plugins..."
|
||||
leftSection={<Search size={14} />}
|
||||
size="xs"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
style={{ flex: 1, minWidth: 180, maxWidth: 300 }}
|
||||
/>
|
||||
<Select
|
||||
size="xs"
|
||||
allowDeselect={false}
|
||||
value={filterRepo}
|
||||
onChange={setFilterRepo}
|
||||
data={repoOptions}
|
||||
style={{ width: 160 }}
|
||||
value={sortBy}
|
||||
onChange={setSortBy}
|
||||
data={[
|
||||
{ value: 'name-asc', label: 'Name A-Z' },
|
||||
{ value: 'name-desc', label: 'Name Z-A' },
|
||||
{ value: 'author', label: 'Author' },
|
||||
{ value: 'updated', label: 'Recently Updated' },
|
||||
]}
|
||||
style={{ width: 170 }}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
size="xs"
|
||||
allowDeselect={false}
|
||||
value={filterStatus}
|
||||
onChange={setFilterStatus}
|
||||
data={[
|
||||
{ value: 'all', label: 'All Plugins' },
|
||||
{ value: 'installed', label: 'Installed' },
|
||||
{ value: 'not-installed', label: 'Not Installed' },
|
||||
{ value: 'compatible', label: 'Compatible' },
|
||||
]}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
{repoOptions.length > 2 && (
|
||||
<Select
|
||||
size="xs"
|
||||
allowDeselect={false}
|
||||
value={filterRepo}
|
||||
onChange={setFilterRepo}
|
||||
data={repoOptions}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
size="xs"
|
||||
allowDeselect={false}
|
||||
value={filterStatus}
|
||||
onChange={setFilterStatus}
|
||||
data={[
|
||||
{ value: 'all', label: 'All Plugins' },
|
||||
{ value: 'installed', label: 'Installed' },
|
||||
{ value: 'not-installed', label: 'Not Installed' },
|
||||
{ value: 'compatible', label: 'Compatible' },
|
||||
]}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
filteredPlugins.length === 0 &&
|
||||
availablePlugins.length > 0 && (
|
||||
{!loading &&
|
||||
filteredPlugins.length === 0 &&
|
||||
availablePlugins.length > 0 && (
|
||||
<Box>
|
||||
<Text c="dimmed">
|
||||
No plugins match your filters. Try adjusting your search or
|
||||
filter criteria.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && availablePlugins.length === 0 && (
|
||||
<Box>
|
||||
<Text c="dimmed">
|
||||
No plugins match your filters. Try adjusting your search or filter
|
||||
criteria.
|
||||
No plugins available. Try refreshing repos or adding a new plugin
|
||||
repository.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && availablePlugins.length === 0 && (
|
||||
<Box>
|
||||
<Text c="dimmed">
|
||||
No plugins available. Try refreshing repos or adding a new plugin
|
||||
repository.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && filteredPlugins.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="md">
|
||||
{paginatedPlugins.map((p) => (
|
||||
<AvailablePluginCard
|
||||
key={`${p.repo_id}-${p.slug}`}
|
||||
plugin={p}
|
||||
appVersion={appVersion}
|
||||
multiRepo={repos.length > 1}
|
||||
onBeforeInstall={(slug) => {
|
||||
if (slug) {
|
||||
if (p.install_status === 'update_available') {
|
||||
recentlyUpdatedSlugs.current.add(slug);
|
||||
} else {
|
||||
recentlyInstalledSlugs.current.add(slug);
|
||||
{!loading && filteredPlugins.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="md">
|
||||
{paginatedPlugins.map((p) => (
|
||||
<AvailablePluginCard
|
||||
key={`${p.repo_id}-${p.slug}`}
|
||||
plugin={p}
|
||||
appVersion={appVersion}
|
||||
multiRepo={repos.length > 1}
|
||||
onBeforeInstall={(slug) => {
|
||||
if (slug) {
|
||||
if (p.install_status === 'update_available') {
|
||||
recentlyUpdatedSlugs.current.add(slug);
|
||||
} else {
|
||||
recentlyInstalledSlugs.current.add(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onInstalled={(slug) => {
|
||||
if (slug) recentlyInstalledSlugs.current.add(slug);
|
||||
fetchAvailablePlugins();
|
||||
}}
|
||||
onUninstalled={(slug) => {
|
||||
if (slug) recentlyUninstalledSlugs.current.add(slug);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
}}
|
||||
onInstalled={(slug) => {
|
||||
if (slug) recentlyInstalledSlugs.current.add(slug);
|
||||
fetchAvailablePlugins();
|
||||
}}
|
||||
onUninstalled={(slug) => {
|
||||
if (slug) recentlyUninstalledSlugs.current.add(slug);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!loading && filteredPlugins.length > 0 && (
|
||||
|
|
@ -551,398 +394,10 @@ export default function PluginBrowsePage() {
|
|||
</Box>
|
||||
)}
|
||||
|
||||
{/* Manage Repos Modal */}
|
||||
<Modal
|
||||
<ManageReposModal
|
||||
opened={repoModalOpen}
|
||||
onClose={() => setRepoModalOpen(false)}
|
||||
title={
|
||||
<Group justify="space-between" align="flex-start" w="100%">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text fw={600}>Plugin Repositories</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
Add third-party plugin repositories or manage existing ones.
|
||||
Manifests are fetched automatically at the configured interval.
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left', flexShrink: 0 }}>
|
||||
<Text size="sm" fw={500} mb={2}>
|
||||
Refresh Interval
|
||||
</Text>
|
||||
<NumberInput
|
||||
value={refreshInterval}
|
||||
onChange={handleSaveInterval}
|
||||
min={0}
|
||||
max={168}
|
||||
size="xs"
|
||||
disabled={savingInterval}
|
||||
w={115}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
Hours, 0 to disable
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
size="lg"
|
||||
styles={{
|
||||
title: { width: '100%' },
|
||||
header: { alignItems: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{reposLoading && repos.length === 0 && <Loader size="sm" />}
|
||||
|
||||
{repos.map((repo) => (
|
||||
<React.Fragment key={repo.id}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--mantine-color-dark-6)',
|
||||
}}
|
||||
>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={500} size="sm" lineClamp={1}>
|
||||
{repo.name}
|
||||
</Text>
|
||||
{repo.is_official && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="filled"
|
||||
style={{ backgroundColor: '#14917E' }}
|
||||
>
|
||||
Official Repo
|
||||
</Badge>
|
||||
)}
|
||||
{repo.signature_verified === true && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Verified Signature
|
||||
</Badge>
|
||||
)}
|
||||
{repo.signature_verified === false && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<ShieldAlert size={10} />}
|
||||
>
|
||||
Invalid Signature
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{repo.registry_url ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
<a
|
||||
href={repo.registry_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--mantine-color-blue-4)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{repo.registry_url}
|
||||
</a>
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{repo.url}
|
||||
</Text>
|
||||
{repo.last_fetched && (
|
||||
<Text
|
||||
size="xs"
|
||||
c={
|
||||
repo.last_fetch_status &&
|
||||
repo.last_fetch_status !== '200'
|
||||
? 'red'
|
||||
: 'dimmed'
|
||||
}
|
||||
>
|
||||
Last fetched:{' '}
|
||||
{new Date(repo.last_fetched).toLocaleString()}
|
||||
{repo.last_fetch_status &&
|
||||
repo.last_fetch_status !== '200'
|
||||
? ` · ${repo.last_fetch_status}`
|
||||
: repo.plugin_count != null
|
||||
? ` · ${repo.plugin_count} plugin${repo.plugin_count !== 1 ? 's' : ''} available`
|
||||
: ''}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{!repo.is_official && (
|
||||
<Stack gap={4} align="center">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
title="Edit public key"
|
||||
onClick={() => handleEditKey(repo)}
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
title="Remove repo"
|
||||
onClick={() => setDeleteConfirmId(repo.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Stack>
|
||||
)}
|
||||
</Group>
|
||||
{editingKeyRepoId === repo.id && (
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Textarea
|
||||
placeholder={
|
||||
'-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nOptional: Paste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
|
||||
}
|
||||
value={editKeyValue}
|
||||
onChange={(e) => setEditKeyValue(e.currentTarget.value)}
|
||||
size="xs"
|
||||
minRows={3}
|
||||
autosize
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={handleSaveKey}
|
||||
loading={savingKey}
|
||||
>
|
||||
Save Key
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setEditingKeyRepoId(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{!showAddRepo ? (
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Plus size={16} />}
|
||||
size="sm"
|
||||
onClick={() => setShowAddRepo(true)}
|
||||
>
|
||||
Add Repository
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Text fw={500} size="sm" mt="sm">
|
||||
Add Repository
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
minHeight: 90,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'var(--mantine-color-dark-6)',
|
||||
border:
|
||||
repoPreview && !previewLoading && !repoPreview.valid
|
||||
? '1px solid var(--mantine-color-red-7)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
{previewLoading ? (
|
||||
<Group gap="xs" align="center">
|
||||
<Loader size={14} />
|
||||
<Text size="xs" c="dimmed">
|
||||
Checking manifest...
|
||||
</Text>
|
||||
</Group>
|
||||
) : repoPreview ? (
|
||||
repoPreview.valid ? (
|
||||
<Box>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={500} size="sm">
|
||||
{repoPreview.registry_name}
|
||||
</Text>
|
||||
{repoPreview.signature_verified === true && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Verified Signature
|
||||
</Badge>
|
||||
)}
|
||||
{repoPreview.signature_verified === false && (
|
||||
<>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<ShieldCheck size={10} />}
|
||||
>
|
||||
Signed Manifest
|
||||
</Badge>
|
||||
<Text
|
||||
size="xs"
|
||||
c="var(--mantine-color-yellow-6)"
|
||||
fs="italic"
|
||||
>
|
||||
Public key required for verification
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{repoPreview.signature_verified == null && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
No Signature
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{repoPreview.registry_url ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
<a
|
||||
href={repoPreview.registry_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--mantine-color-blue-4)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{repoPreview.registry_url}
|
||||
</a>
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{newRepoUrl.trim()}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{repoPreview.plugin_count} plugin
|
||||
{repoPreview.plugin_count !== 1 ? 's' : ''} available
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Text size="xs" c="red">
|
||||
{repoPreview.errors?.join(' ') || 'Invalid manifest'}
|
||||
</Text>
|
||||
)
|
||||
) : (
|
||||
<Text size="xs" c="yellow">
|
||||
Third-party repositories are not reviewed by the Dispatcharr
|
||||
team.
|
||||
<br />
|
||||
Adding sources and installing plugins is done at your own
|
||||
risk.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<TextInput
|
||||
placeholder="Repository Manifest URL (ending in .json)"
|
||||
value={newRepoUrl}
|
||||
onChange={(e) => {
|
||||
setNewRepoUrl(e.currentTarget.value);
|
||||
fetchPreview(e.currentTarget.value, newRepoPublicKey);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
<Textarea
|
||||
placeholder={
|
||||
gpgKeyFocused
|
||||
? '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nPaste public GPG key here\n\n-----END PGP PUBLIC KEY BLOCK-----'
|
||||
: 'Optional: Paste public GPG key here'
|
||||
}
|
||||
value={newRepoPublicKey}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setNewRepoPublicKey(value);
|
||||
fetchPreview(newRepoUrl, value);
|
||||
}}
|
||||
size="sm"
|
||||
minRows={gpgKeyFocused || newRepoPublicKey ? 4 : 1}
|
||||
maxRows={8}
|
||||
autosize
|
||||
onFocus={() => setGpgKeyFocused(true)}
|
||||
onBlur={() => {
|
||||
if (!newRepoPublicKey) setGpgKeyFocused(false);
|
||||
}}
|
||||
styles={
|
||||
repoPreview?.valid &&
|
||||
repoPreview?.signature_verified === false &&
|
||||
!newRepoPublicKey.trim()
|
||||
? {
|
||||
input: { borderColor: 'var(--mantine-color-yellow-6)' },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowAddRepo(false);
|
||||
setNewRepoUrl('');
|
||||
setNewRepoPublicKey('');
|
||||
setRepoPreview(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddRepo}
|
||||
loading={addingRepo}
|
||||
disabled={!newRepoUrl.trim()}
|
||||
leftSection={<Plus size={16} />}
|
||||
size="sm"
|
||||
>
|
||||
Add Repo
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal
|
||||
opened={deleteConfirmId != null}
|
||||
onClose={() => setDeleteConfirmId(null)}
|
||||
title="Remove Repository"
|
||||
size="sm"
|
||||
centered
|
||||
>
|
||||
<Text size="sm">Are you sure you want to remove this repository?</Text>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
Plugins installed from this repo will remain installed but become
|
||||
unmanaged.
|
||||
</Text>
|
||||
<Group mt="md" justify="flex-end" gap="xs">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={() => handleDeleteRepo(deleteConfirmId)}>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
/>
|
||||
</AppShellMain>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
395
frontend/src/pages/__tests__/Connect.test.jsx
Normal file
395
frontend/src/pages/__tests__/Connect.test.jsx
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── API mock ───────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../api', () => ({
|
||||
default: {
|
||||
deleteConnectIntegration: vi.fn(),
|
||||
updateConnectIntegration: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../store/connect', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Constants mock ─────────────────────────────────────────────────────────────
|
||||
vi.mock('../../constants', () => ({
|
||||
SUBSCRIPTION_EVENTS: {
|
||||
channel_start: 'Channel Started',
|
||||
channel_stop: 'Channel Stopped',
|
||||
recording_start: 'Recording Started',
|
||||
},
|
||||
}));
|
||||
|
||||
// ── ConnectionForm mock ────────────────────────────────────────────────────────
|
||||
vi.mock('../../components/forms/Connection', () => ({
|
||||
default: ({ connection, isOpen, onClose }) =>
|
||||
isOpen ? (
|
||||
<div data-testid="connection-form">
|
||||
<div data-testid="connection-form-id">{connection?.id ?? 'new'}</div>
|
||||
<button data-testid="connection-form-close" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
SquarePlus: () => <svg data-testid="icon-square-plus" />,
|
||||
Webhook: () => <svg data-testid="icon-webhook" />,
|
||||
FileCode: () => <svg data-testid="icon-file-code" />,
|
||||
Logs: () => <svg data-testid="icon-logs" />,
|
||||
}));
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Badge: ({ children, color, variant, size }) => (
|
||||
<span
|
||||
data-testid="badge"
|
||||
data-color={color}
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Box: ({ children, display, style }) => (
|
||||
<div data-display={display} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, onClick, variant, color, size, leftSection }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
data-size={size}
|
||||
>
|
||||
{leftSection}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Card: ({ children }) => <div data-testid="card">{children}</div>,
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Switch: ({ label, checked, onChange }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="toggle-switch"
|
||||
checked={checked ?? false}
|
||||
onChange={onChange}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
Text: ({ children, fw }) => <span data-fw={fw}>{children}</span>,
|
||||
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
|
||||
useMantineTheme: () => ({
|
||||
tailwind: { green: { 5: '#22c55e' } },
|
||||
}),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import ConnectPage from '../Connect';
|
||||
import API from '../../api';
|
||||
import useConnectStore from '../../store/connect';
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
const makeIntegration = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'My Webhook',
|
||||
type: 'webhook',
|
||||
enabled: true,
|
||||
config: { url: 'https://example.com/hook' },
|
||||
subscriptions: [
|
||||
{ event: 'channel_start', enabled: true },
|
||||
{ event: 'channel_stop', enabled: false },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupStore = (overrides = {}) => {
|
||||
const fetchIntegrations = vi.fn();
|
||||
vi.mocked(useConnectStore).mockReturnValue({
|
||||
integrations: [],
|
||||
isLoading: false,
|
||||
fetchIntegrations,
|
||||
...overrides,
|
||||
});
|
||||
return { fetchIntegrations };
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ConnectPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(API.deleteConnectIntegration).mockResolvedValue(undefined);
|
||||
vi.mocked(API.updateConnectIntegration).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
// ── Initialization ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('initialization', () => {
|
||||
it('calls fetchIntegrations on mount', () => {
|
||||
const { fetchIntegrations } = setupStore();
|
||||
render(<ConnectPage />);
|
||||
expect(fetchIntegrations).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Loading state ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows loading indicator when isLoading is true', () => {
|
||||
setupStore({ isLoading: true });
|
||||
render(<ConnectPage />);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show loading indicator when isLoading is false', () => {
|
||||
setupStore({ isLoading: false });
|
||||
render(<ConnectPage />);
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Integration list ───────────────────────────────────────────────────────
|
||||
|
||||
describe('integration list', () => {
|
||||
it('renders a card for each integration', () => {
|
||||
setupStore({
|
||||
integrations: [
|
||||
makeIntegration({ id: 1 }),
|
||||
makeIntegration({ id: 2, name: 'Other' }),
|
||||
],
|
||||
});
|
||||
render(<ConnectPage />);
|
||||
expect(screen.getAllByTestId('card')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders integration names', () => {
|
||||
setupStore({ integrations: [makeIntegration({ name: 'Plex Hook' })] });
|
||||
render(<ConnectPage />);
|
||||
expect(screen.getByText('Plex Hook')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no cards when integrations list is empty', () => {
|
||||
setupStore({ integrations: [] });
|
||||
render(<ConnectPage />);
|
||||
expect(screen.queryByTestId('card')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── New Connection button ──────────────────────────────────────────────────
|
||||
|
||||
describe('"New Connection" button', () => {
|
||||
it('renders the New Connection button', () => {
|
||||
setupStore();
|
||||
render(<ConnectPage />);
|
||||
expect(screen.getByText('New Connection')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ConnectionForm is not visible initially', () => {
|
||||
setupStore();
|
||||
render(<ConnectPage />);
|
||||
expect(screen.queryByTestId('connection-form')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens ConnectionForm with no connection when New Connection is clicked', () => {
|
||||
setupStore();
|
||||
render(<ConnectPage />);
|
||||
fireEvent.click(screen.getByText('New Connection'));
|
||||
expect(screen.getByTestId('connection-form')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('connection-form-id')).toHaveTextContent('new');
|
||||
});
|
||||
|
||||
it('closes ConnectionForm when its close button is clicked', () => {
|
||||
setupStore();
|
||||
render(<ConnectPage />);
|
||||
fireEvent.click(screen.getByText('New Connection'));
|
||||
fireEvent.click(screen.getByTestId('connection-form-close'));
|
||||
expect(screen.queryByTestId('connection-form')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit connection ────────────────────────────────────────────────────────
|
||||
|
||||
describe('edit connection', () => {
|
||||
it('opens ConnectionForm with the integration when Edit is clicked', () => {
|
||||
const integration = makeIntegration({ id: 7, name: 'My Hook' });
|
||||
setupStore({ integrations: [integration] });
|
||||
render(<ConnectPage />);
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
expect(screen.getByTestId('connection-form')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('connection-form-id')).toHaveTextContent('7');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete connection ──────────────────────────────────────────────────────
|
||||
|
||||
describe('delete connection', () => {
|
||||
it('calls deleteConnectIntegration with the integration id when Delete is clicked', async () => {
|
||||
setupStore({ integrations: [makeIntegration({ id: 3 })] });
|
||||
render(<ConnectPage />);
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
await waitFor(() => {
|
||||
expect(API.deleteConnectIntegration).toHaveBeenCalledWith(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('IntegrationRow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(API.updateConnectIntegration).mockResolvedValue(undefined);
|
||||
vi.mocked(API.deleteConnectIntegration).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
const renderRow = (integrationOverrides = {}) => {
|
||||
const integration = makeIntegration(integrationOverrides);
|
||||
const { fetchIntegrations } = setupStore({ integrations: [integration] });
|
||||
render(<ConnectPage />);
|
||||
return { integration, fetchIntegrations };
|
||||
};
|
||||
|
||||
// ── Type icons ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('type icons', () => {
|
||||
it('shows webhook icon for webhook type', () => {
|
||||
renderRow({ type: 'webhook' });
|
||||
expect(screen.getByTestId('icon-webhook')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows file code icon for non-webhook type', () => {
|
||||
renderRow({ type: 'script' });
|
||||
expect(screen.getByTestId('icon-file-code')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Target display ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('target display', () => {
|
||||
it('shows webhook URL for webhook type', () => {
|
||||
renderRow({
|
||||
type: 'webhook',
|
||||
config: { url: 'https://hooks.example.com' },
|
||||
});
|
||||
expect(screen.getByText('https://hooks.example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows script path for non-webhook type', () => {
|
||||
renderRow({ type: 'script', config: { path: '/scripts/my-script.sh' } });
|
||||
expect(screen.getByText('/scripts/my-script.sh')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Enabled switch ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('enabled switch', () => {
|
||||
it('renders checked when integration.enabled is true', () => {
|
||||
renderRow({ enabled: true });
|
||||
expect(screen.getByTestId('toggle-switch')).toBeChecked();
|
||||
});
|
||||
|
||||
it('renders unchecked when integration.enabled is false', () => {
|
||||
renderRow({ enabled: false });
|
||||
expect(screen.getByTestId('toggle-switch')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('calls updateConnectIntegration with toggled enabled value on toggle', async () => {
|
||||
renderRow({ id: 5, enabled: true });
|
||||
fireEvent.click(screen.getByTestId('toggle-switch'));
|
||||
await waitFor(() => {
|
||||
expect(API.updateConnectIntegration).toHaveBeenCalledWith(
|
||||
5,
|
||||
expect.objectContaining({ enabled: false })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles from false to true', async () => {
|
||||
renderRow({ id: 5, enabled: false });
|
||||
fireEvent.click(screen.getByTestId('toggle-switch'));
|
||||
await waitFor(() => {
|
||||
expect(API.updateConnectIntegration).toHaveBeenCalledWith(
|
||||
5,
|
||||
expect.objectContaining({ enabled: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not throw when updateConnectIntegration fails', async () => {
|
||||
vi.mocked(API.updateConnectIntegration).mockRejectedValue(
|
||||
new Error('fail')
|
||||
);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
renderRow({ enabled: true });
|
||||
|
||||
await expect(
|
||||
waitFor(() => fireEvent.click(screen.getByTestId('toggle-switch')))
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Subscription badges ────────────────────────────────────────────────────
|
||||
|
||||
describe('subscription badges', () => {
|
||||
it('renders a badge for each enabled subscription', () => {
|
||||
renderRow({
|
||||
subscriptions: [
|
||||
{ event: 'channel_start', enabled: true },
|
||||
{ event: 'recording_start', enabled: true },
|
||||
],
|
||||
});
|
||||
expect(screen.getByText('Channel Started')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recording Started')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render badges for disabled subscriptions', () => {
|
||||
renderRow({
|
||||
subscriptions: [
|
||||
{ event: 'channel_start', enabled: true },
|
||||
{ event: 'channel_stop', enabled: false },
|
||||
],
|
||||
});
|
||||
expect(screen.getByText('Channel Started')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Channel Stopped')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the raw event name when not in SUBSCRIPTION_EVENTS', () => {
|
||||
renderRow({
|
||||
subscriptions: [{ event: 'custom_event', enabled: true }],
|
||||
});
|
||||
expect(screen.getByText('custom_event')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Action buttons ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('action buttons', () => {
|
||||
it('opens ConnectionForm with the integration when Edit is clicked', () => {
|
||||
const integration = makeIntegration({ id: 9, name: 'Test Hook' });
|
||||
setupStore({ integrations: [integration] });
|
||||
render(<ConnectPage />);
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
expect(screen.getByTestId('connection-form-id')).toHaveTextContent('9');
|
||||
});
|
||||
|
||||
it('calls deleteConnectIntegration with the correct id when Delete is clicked', async () => {
|
||||
renderRow({ id: 11 });
|
||||
fireEvent.click(screen.getByText('Delete'));
|
||||
await waitFor(() => {
|
||||
expect(API.deleteConnectIntegration).toHaveBeenCalledWith(11);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
462
frontend/src/pages/__tests__/ConnectLogs.test.jsx
Normal file
462
frontend/src/pages/__tests__/ConnectLogs.test.jsx
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
act,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── API mock ───────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../api', () => ({
|
||||
default: {
|
||||
getConnectLogs: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../store/connect', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Constants mock ─────────────────────────────────────────────────────────────
|
||||
vi.mock('../../constants', () => ({
|
||||
SUBSCRIPTION_EVENTS: {
|
||||
channel_start: 'Channel Started',
|
||||
channel_stop: 'Channel Stopped',
|
||||
},
|
||||
}));
|
||||
|
||||
// ── CustomTable mock ───────────────────────────────────────────────────────────
|
||||
vi.mock('../../components/tables/CustomTable', () => ({
|
||||
CustomTable: () => <div data-testid="custom-table" />,
|
||||
useTable: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
// ── Utils mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../utils', () => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
FileCode: () => <svg data-testid="icon-file-code" />,
|
||||
Webhook: () => <svg data-testid="icon-webhook" />,
|
||||
}));
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Badge: ({ children, color, variant }) => (
|
||||
<span data-testid="badge" data-color={color} data-variant={variant}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Box: ({ children }) => <div>{children}</div>,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
LoadingOverlay: ({ visible }) =>
|
||||
visible ? <div data-testid="loading-overlay" /> : null,
|
||||
NativeSelect: ({ value, onChange, data }) => (
|
||||
<select data-testid="page-size-select" value={value} onChange={onChange}>
|
||||
{data?.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
Pagination: ({ total, value, onChange }) => (
|
||||
<div data-testid="pagination">
|
||||
<span data-testid="pagination-total">{total}</span>
|
||||
<button
|
||||
data-testid="next-page"
|
||||
onClick={() => onChange(value + 1)}
|
||||
disabled={value >= total}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
Paper: ({ children }) => <div>{children}</div>,
|
||||
// Distinguish type filter (has 'webhook' option) from integration filter
|
||||
Select: ({ data, value, onChange }) => {
|
||||
const isTypeFilter = data?.some((d) => d.value === 'webhook');
|
||||
return (
|
||||
<select
|
||||
data-testid={isTypeFilter ? 'select-type' : 'select-integration'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
{data?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
},
|
||||
Text: ({ children, size }) => <span data-size={size}>{children}</span>,
|
||||
Title: ({ children, order }) => <h3 data-order={order}>{children}</h3>,
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import ConnectLogsPage from '../ConnectLogs';
|
||||
import API from '../../api';
|
||||
import useConnectStore from '../../store/connect';
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
const makeIntegration = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'My Webhook',
|
||||
type: 'webhook',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupStore = ({
|
||||
integrations = [],
|
||||
fetchIntegrations = vi.fn(),
|
||||
} = {}) => {
|
||||
vi.mocked(useConnectStore).mockReturnValue({
|
||||
integrations,
|
||||
fetchIntegrations,
|
||||
});
|
||||
return { fetchIntegrations };
|
||||
};
|
||||
|
||||
const setupApiResponse = (overrides = {}) => {
|
||||
vi.mocked(API.getConnectLogs).mockResolvedValue({
|
||||
results: [],
|
||||
count: 0,
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ConnectLogsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupApiResponse();
|
||||
setupStore();
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the "Connect Logs" title', () => {
|
||||
render(<ConnectLogsPage />);
|
||||
expect(screen.getByText('Connect Logs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the type filter select', () => {
|
||||
render(<ConnectLogsPage />);
|
||||
expect(screen.getByTestId('select-type')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the integration filter select', () => {
|
||||
render(<ConnectLogsPage />);
|
||||
expect(screen.getByTestId('select-integration')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the table', () => {
|
||||
render(<ConnectLogsPage />);
|
||||
expect(screen.getByTestId('custom-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the page size select and pagination', () => {
|
||||
render(<ConnectLogsPage />);
|
||||
expect(screen.getByTestId('page-size-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('pagination')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Integration initialization ─────────────────────────────────────────────
|
||||
|
||||
describe('integration initialization', () => {
|
||||
it('calls fetchIntegrations on mount when integrations list is empty', async () => {
|
||||
const { fetchIntegrations } = setupStore({ integrations: [] });
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(fetchIntegrations).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call fetchIntegrations when integrations are already loaded', async () => {
|
||||
const { fetchIntegrations } = setupStore({
|
||||
integrations: [makeIntegration()],
|
||||
});
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalled();
|
||||
});
|
||||
expect(fetchIntegrations).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── API calls ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('API calls', () => {
|
||||
it('calls getConnectLogs on mount with page=1 and page_size=50', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page: 1, page_size: 50 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not include type in params when type filter is empty', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalled();
|
||||
});
|
||||
const callArgs = vi.mocked(API.getConnectLogs).mock.calls[0][0];
|
||||
expect(callArgs).not.toHaveProperty('type');
|
||||
});
|
||||
|
||||
it('does not include integration in params when integration filter is empty', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalled();
|
||||
});
|
||||
const callArgs = vi.mocked(API.getConnectLogs).mock.calls[0][0];
|
||||
expect(callArgs).not.toHaveProperty('integration');
|
||||
});
|
||||
|
||||
it('handles an array API response', async () => {
|
||||
vi.mocked(API.getConnectLogs).mockResolvedValue([]);
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalled();
|
||||
expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts count from a paginated API response', async () => {
|
||||
vi.mocked(API.getConnectLogs).mockResolvedValue({
|
||||
results: [],
|
||||
count: 77,
|
||||
});
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/77/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Loading state ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows LoadingOverlay while the fetch is in progress', async () => {
|
||||
let resolve;
|
||||
vi.mocked(API.getConnectLogs).mockReturnValue(
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
})
|
||||
);
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading-overlay')).toBeInTheDocument();
|
||||
});
|
||||
await act(async () => {
|
||||
resolve({ results: [], count: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
it('hides LoadingOverlay after the fetch completes', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Pagination string ──────────────────────────────────────────────────────
|
||||
|
||||
describe('pagination string', () => {
|
||||
it('shows the correct range and total when results are returned', async () => {
|
||||
vi.mocked(API.getConnectLogs).mockResolvedValue({
|
||||
results: [],
|
||||
count: 120,
|
||||
});
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Showing 1-50 of 120')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Showing 1-0 of 0" when there are no logs', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Showing 1-0 of 0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Page count ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('page count', () => {
|
||||
it('computes pageCount as ceil(count / pageSize)', async () => {
|
||||
vi.mocked(API.getConnectLogs).mockResolvedValue({
|
||||
results: [],
|
||||
count: 110,
|
||||
});
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
// ceil(110 / 50) = 3
|
||||
expect(screen.getByTestId('pagination-total')).toHaveTextContent('3');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows at least 1 page when count is 0', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('pagination-total')).toHaveTextContent('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Type filter ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('type filter', () => {
|
||||
it('populates type filter with All, Webhooks, Scripts options', () => {
|
||||
render(<ConnectLogsPage />);
|
||||
const typeSelect = screen.getByTestId('select-type');
|
||||
expect(
|
||||
within(typeSelect).getByRole('option', { name: 'All' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(typeSelect).getByRole('option', { name: 'Webhooks' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(typeSelect).getByRole('option', { name: 'Scripts' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refetches with type param when type filter changes', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.change(screen.getByTestId('select-type'), {
|
||||
target: { value: 'webhook' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'webhook' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('omits type from params when filter is reset to empty', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
fireEvent.change(screen.getByTestId('select-type'), {
|
||||
target: { value: 'webhook' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'webhook' })
|
||||
);
|
||||
});
|
||||
|
||||
vi.mocked(API.getConnectLogs).mockClear();
|
||||
fireEvent.change(screen.getByTestId('select-type'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalled();
|
||||
const args = vi.mocked(API.getConnectLogs).mock.calls[0][0];
|
||||
expect(args).not.toHaveProperty('type');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Integration filter ─────────────────────────────────────────────────────
|
||||
|
||||
describe('integration filter', () => {
|
||||
it('populates integration select from store integrations', async () => {
|
||||
setupStore({
|
||||
integrations: [makeIntegration({ id: 3, name: 'Plex Hook' })],
|
||||
});
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'Plex Hook' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('refetches with integration param when integration filter changes', async () => {
|
||||
setupStore({
|
||||
integrations: [makeIntegration({ id: 5, name: 'Hook 5' })],
|
||||
});
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.change(screen.getByTestId('select-integration'), {
|
||||
target: { value: '5' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ integration: '5' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Page size change ───────────────────────────────────────────────────────
|
||||
|
||||
describe('page size change', () => {
|
||||
it('refetches with new page_size when page size is changed', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.change(screen.getByTestId('page-size-select'), {
|
||||
target: { value: '100' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page_size: 100 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets to page 1 when page size changes', async () => {
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.change(screen.getByTestId('page-size-select'), {
|
||||
target: { value: '25' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page: 1 })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Page navigation ────────────────────────────────────────────────────────
|
||||
|
||||
describe('page navigation', () => {
|
||||
it('refetches with page 2 when the next page button is clicked', async () => {
|
||||
vi.mocked(API.getConnectLogs).mockResolvedValue({
|
||||
results: [],
|
||||
count: 100,
|
||||
});
|
||||
render(<ConnectLogsPage />);
|
||||
await waitFor(() => expect(API.getConnectLogs).toHaveBeenCalledTimes(1));
|
||||
|
||||
fireEvent.click(screen.getByTestId('next-page'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getConnectLogs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page: 2 })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
631
frontend/src/pages/__tests__/PluginBrowse.test.jsx
Normal file
631
frontend/src/pages/__tests__/PluginBrowse.test.jsx
Normal file
|
|
@ -0,0 +1,631 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../store/plugins.jsx', () => {
|
||||
const mockStore = vi.fn();
|
||||
mockStore.getState = vi.fn(() => ({ repos: [], invalidatePlugins: vi.fn() }));
|
||||
return { usePluginStore: mockStore };
|
||||
});
|
||||
|
||||
vi.mock('../../store/settings.jsx', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Component mocks ────────────────────────────────────────────────────────────
|
||||
vi.mock('../../components/cards/AvailablePluginCard.jsx', () => ({
|
||||
default: ({ plugin }) => (
|
||||
<div data-testid="plugin-card" data-slug={plugin.slug}>
|
||||
{plugin.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/modals/ManageReposModal.jsx', () => ({
|
||||
default: ({ opened, onClose }) =>
|
||||
opened ? (
|
||||
<div data-testid="manage-repos-modal">
|
||||
<button data-testid="manage-repos-close" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/pages/PluginsUtils.js', () => ({
|
||||
reloadPlugins: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/components/pluginUtils.js', () => ({
|
||||
compareVersions: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
Package: () => <svg data-testid="icon-package" />,
|
||||
RefreshCcw: () => <svg data-testid="icon-refresh" />,
|
||||
Search: () => <svg data-testid="icon-search" />,
|
||||
}));
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
ActionIcon: ({ children, onClick, loading, title }) => (
|
||||
<button
|
||||
data-testid="action-icon"
|
||||
data-loading={loading}
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
AppShellMain: ({ children }) => <main>{children}</main>,
|
||||
Badge: ({ children, color, variant }) => (
|
||||
<span data-testid="badge" data-color={color} data-variant={variant}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Box: ({ children }) => <div>{children}</div>,
|
||||
Button: ({ children, onClick, href, variant, color, leftSection }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
data-href={href}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
>
|
||||
{leftSection}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Loader: () => <div data-testid="loader" />,
|
||||
NativeSelect: ({ value, onChange, data }) => (
|
||||
<select data-testid="page-size-select" value={value} onChange={onChange}>
|
||||
{data?.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
Pagination: ({ total, value, onChange }) => (
|
||||
<div data-testid="pagination">
|
||||
<span data-testid="pagination-total">{total}</span>
|
||||
<span data-testid="pagination-value">{value}</span>
|
||||
<button
|
||||
data-testid="next-page"
|
||||
onClick={() => onChange(value + 1)}
|
||||
disabled={value >= total}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
Select: ({ data, value, onChange }) => {
|
||||
const isSort = data?.some((d) => d.value === 'name-asc');
|
||||
const isStatus = data?.some((d) => d.value === 'installed');
|
||||
const testId = isSort
|
||||
? 'sort-select'
|
||||
: isStatus
|
||||
? 'status-select'
|
||||
: 'repo-select';
|
||||
return (
|
||||
<select
|
||||
data-testid={testId}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
{data?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
},
|
||||
SimpleGrid: ({ children }) => <div data-testid="plugin-grid">{children}</div>,
|
||||
Text: ({ children, fw, size, c }) => (
|
||||
<span data-fw={fw} data-size={size} data-color={c}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
TextInput: ({ value, onChange, placeholder }) => (
|
||||
<input
|
||||
data-testid="search-input"
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import PluginBrowsePage from '../PluginBrowse';
|
||||
import { usePluginStore } from '../../store/plugins.jsx';
|
||||
import useSettingsStore from '../../store/settings.jsx';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
import { reloadPlugins } from '../../utils/pages/PluginsUtils.js';
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────────
|
||||
const makePlugin = (overrides = {}) => ({
|
||||
slug: 'my-plugin',
|
||||
repo_id: 1,
|
||||
repo_name: 'Main Repo',
|
||||
name: 'My Plugin',
|
||||
description: 'A test plugin',
|
||||
author: 'Test Author',
|
||||
installed: false,
|
||||
install_status: null,
|
||||
deprecated: false,
|
||||
min_dispatcharr_version: null,
|
||||
max_dispatcharr_version: null,
|
||||
last_updated: '2024-01-01',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeRepo = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'Main Repo',
|
||||
url: 'https://example.com',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
let mockFetchRepos;
|
||||
let mockFetchAvailablePlugins;
|
||||
let mockRefreshRepo;
|
||||
let mockInvalidatePlugins;
|
||||
|
||||
const setupStore = ({
|
||||
repos = [],
|
||||
availablePlugins = [],
|
||||
availableLoading = false,
|
||||
} = {}) => {
|
||||
mockFetchRepos = vi.fn();
|
||||
mockFetchAvailablePlugins = vi.fn();
|
||||
mockRefreshRepo = vi.fn().mockResolvedValue(undefined);
|
||||
mockInvalidatePlugins = vi.fn();
|
||||
|
||||
vi.mocked(usePluginStore).mockImplementation((sel) =>
|
||||
sel({
|
||||
repos,
|
||||
availablePlugins,
|
||||
availableLoading,
|
||||
fetchRepos: mockFetchRepos,
|
||||
fetchAvailablePlugins: mockFetchAvailablePlugins,
|
||||
refreshRepo: mockRefreshRepo,
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(usePluginStore).getState.mockReturnValue({
|
||||
repos,
|
||||
invalidatePlugins: mockInvalidatePlugins,
|
||||
});
|
||||
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ version: { version: '1.0.0' } })
|
||||
);
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PluginBrowsePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(reloadPlugins).mockResolvedValue(undefined);
|
||||
setupStore();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// ── Initialization ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('initialization', () => {
|
||||
it('calls fetchRepos and fetchAvailablePlugins on mount', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
expect(mockFetchRepos).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchAvailablePlugins).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not refetch on re-render (hasFetched guard)', () => {
|
||||
const { rerender } = render(<PluginBrowsePage />);
|
||||
rerender(<PluginBrowsePage />);
|
||||
expect(mockFetchRepos).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders "Find Plugins" title', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.getByText('Find Plugins')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows plugin count badge when plugins are available', () => {
|
||||
setupStore({
|
||||
availablePlugins: [makePlugin(), makePlugin({ slug: 'p2' })],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.getByText('2 Plugins Available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show plugin count badge when no plugins', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.queryByText(/Plugins Available/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows repo count badge when repos > 1', () => {
|
||||
setupStore({ repos: [makeRepo(), makeRepo({ id: 2 })] });
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.getByText('2 Repos')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show repo count badge when only 1 repo', () => {
|
||||
setupStore({ repos: [makeRepo()] });
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.queryByText(/\d+ Repos/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Loader when loading and no plugins yet', () => {
|
||||
setupStore({ availableLoading: true, availablePlugins: [] });
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.getByTestId('loader')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show Loader when not loading', () => {
|
||||
setupStore({ availableLoading: false });
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show Loader when loading but plugins are already loaded', () => {
|
||||
setupStore({ availableLoading: true, availablePlugins: [makePlugin()] });
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Empty states ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('empty states', () => {
|
||||
it('shows "No plugins available" when there are no plugins', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
expect(
|
||||
screen.getByText(/No plugins available. Try refreshing repos/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "No plugins match" when all plugins are filtered out', () => {
|
||||
setupStore({ availablePlugins: [makePlugin({ name: 'Plex' })] });
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('search-input'), {
|
||||
target: { value: 'xyznotfound' },
|
||||
});
|
||||
expect(
|
||||
screen.getByText(/No plugins match your filters/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show "No plugins match" when there are no plugins at all', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
expect(
|
||||
screen.queryByText(/No plugins match your filters/)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Plugin cards ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('plugin cards', () => {
|
||||
it('renders a card for each visible plugin', () => {
|
||||
setupStore({
|
||||
availablePlugins: [
|
||||
makePlugin({ slug: 'a', name: 'Plugin A' }),
|
||||
makePlugin({ slug: 'b', name: 'Plugin B' }),
|
||||
],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.getAllByTestId('plugin-card')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders no cards when no plugins available', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.queryByTestId('plugin-card')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Manage Repos modal ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Manage Repos modal', () => {
|
||||
it('ManageReposModal is not visible initially', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
expect(
|
||||
screen.queryByTestId('manage-repos-modal')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens ManageReposModal when "Manage Repos" button is clicked', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.click(screen.getByText('Manage Repos'));
|
||||
expect(screen.getByTestId('manage-repos-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes ManageReposModal when its close handler is called', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.click(screen.getByText('Manage Repos'));
|
||||
fireEvent.click(screen.getByTestId('manage-repos-close'));
|
||||
expect(
|
||||
screen.queryByTestId('manage-repos-modal')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Refresh all ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('handleRefreshAll', () => {
|
||||
it('calls refreshRepo for each repo in the store', async () => {
|
||||
const repos = [makeRepo({ id: 1 }), makeRepo({ id: 2 })];
|
||||
setupStore({ repos });
|
||||
vi.mocked(usePluginStore).getState.mockReturnValue({
|
||||
repos,
|
||||
invalidatePlugins: mockInvalidatePlugins,
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.click(screen.getByTestId('action-icon'));
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshRepo).toHaveBeenCalledWith(1);
|
||||
expect(mockRefreshRepo).toHaveBeenCalledWith(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchAvailablePlugins, reloadPlugins, and invalidatePlugins after refresh', async () => {
|
||||
setupStore({ repos: [makeRepo()] });
|
||||
vi.mocked(usePluginStore).getState.mockReturnValue({
|
||||
repos: [makeRepo()],
|
||||
invalidatePlugins: mockInvalidatePlugins,
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.click(screen.getByTestId('action-icon'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAvailablePlugins).toHaveBeenCalled();
|
||||
expect(reloadPlugins).toHaveBeenCalled();
|
||||
expect(mockInvalidatePlugins).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success notification on successful refresh', async () => {
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.click(screen.getByTestId('action-icon'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Refreshed', color: 'green' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error notification when refresh fails', async () => {
|
||||
mockRefreshRepo = vi.fn().mockRejectedValue(new Error('Network error'));
|
||||
vi.mocked(usePluginStore).mockImplementation((sel) =>
|
||||
sel({
|
||||
repos: [makeRepo()],
|
||||
availablePlugins: [],
|
||||
availableLoading: false,
|
||||
fetchRepos: vi.fn(),
|
||||
fetchAvailablePlugins: vi.fn(),
|
||||
refreshRepo: mockRefreshRepo,
|
||||
})
|
||||
);
|
||||
vi.mocked(usePluginStore).getState.mockReturnValue({
|
||||
repos: [makeRepo()],
|
||||
invalidatePlugins: vi.fn(),
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.click(screen.getByTestId('action-icon'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Error', color: 'red' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Search filter ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('search filter', () => {
|
||||
it('filters plugins by name', () => {
|
||||
setupStore({
|
||||
availablePlugins: [
|
||||
makePlugin({ slug: 'plex', name: 'Plex Plugin' }),
|
||||
makePlugin({ slug: 'other', name: 'Other Plugin' }),
|
||||
],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('search-input'), {
|
||||
target: { value: 'plex' },
|
||||
});
|
||||
const cards = screen.getAllByTestId('plugin-card');
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]).toHaveTextContent('Plex Plugin');
|
||||
});
|
||||
|
||||
it('filters plugins by description', () => {
|
||||
setupStore({
|
||||
availablePlugins: [
|
||||
makePlugin({
|
||||
slug: 'a',
|
||||
name: 'Alpha',
|
||||
description: 'streaming service',
|
||||
}),
|
||||
makePlugin({ slug: 'b', name: 'Beta', description: 'other thing' }),
|
||||
],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('search-input'), {
|
||||
target: { value: 'streaming' },
|
||||
});
|
||||
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters plugins by author', () => {
|
||||
setupStore({
|
||||
availablePlugins: [
|
||||
makePlugin({ slug: 'a', name: 'A', author: 'alice' }),
|
||||
makePlugin({ slug: 'b', name: 'B', author: 'bob' }),
|
||||
],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('search-input'), {
|
||||
target: { value: 'alice' },
|
||||
});
|
||||
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
setupStore({ availablePlugins: [makePlugin({ name: 'PlexPlugin' })] });
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('search-input'), {
|
||||
target: { value: 'PLEXPLUGIN' },
|
||||
});
|
||||
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Status filter ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('status filter', () => {
|
||||
it('filters to installed plugins only', () => {
|
||||
setupStore({
|
||||
availablePlugins: [
|
||||
makePlugin({ slug: 'a', installed: true }),
|
||||
makePlugin({ slug: 'b', installed: false }),
|
||||
],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('status-select'), {
|
||||
target: { value: 'installed' },
|
||||
});
|
||||
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filters to not-installed plugins only', () => {
|
||||
setupStore({
|
||||
availablePlugins: [
|
||||
makePlugin({ slug: 'a', installed: true }),
|
||||
makePlugin({ slug: 'b', installed: false }),
|
||||
makePlugin({ slug: 'c', installed: false }),
|
||||
],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('status-select'), {
|
||||
target: { value: 'not-installed' },
|
||||
});
|
||||
expect(screen.getAllByTestId('plugin-card')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Repo filter ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('repo filter', () => {
|
||||
it('does not show repo filter when only one repo', () => {
|
||||
setupStore({
|
||||
availablePlugins: [makePlugin({ repo_id: 1, repo_name: 'Repo 1' })],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
// repoOptions.length = 2 (all + 1 repo), which is NOT > 2, so hidden
|
||||
expect(screen.queryByTestId('repo-select')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows repo filter when there are multiple repos', () => {
|
||||
setupStore({
|
||||
availablePlugins: [
|
||||
makePlugin({ slug: 'a', repo_id: 1, repo_name: 'Repo 1' }),
|
||||
makePlugin({ slug: 'b', repo_id: 2, repo_name: 'Repo 2' }),
|
||||
makePlugin({ slug: 'c', repo_id: 3, repo_name: 'Repo 3' }),
|
||||
],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
// repoOptions.length = 4 (all + 3 repos), which IS > 2
|
||||
expect(screen.getByTestId('repo-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters plugins by selected repo', () => {
|
||||
setupStore({
|
||||
availablePlugins: [
|
||||
makePlugin({ slug: 'a', repo_id: 1, repo_name: 'Repo 1' }),
|
||||
makePlugin({ slug: 'b', repo_id: 2, repo_name: 'Repo 2' }),
|
||||
makePlugin({ slug: 'c', repo_id: 3, repo_name: 'Repo 3' }),
|
||||
],
|
||||
});
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('repo-select'), {
|
||||
target: { value: '2' },
|
||||
});
|
||||
expect(screen.getAllByTestId('plugin-card')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Pagination ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('pagination', () => {
|
||||
it('shows pagination bar only when there are filtered plugins', () => {
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.queryByTestId('pagination')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows pagination when plugins are available', () => {
|
||||
setupStore({ availablePlugins: [makePlugin()] });
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.getByTestId('pagination')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows correct pagination range text', () => {
|
||||
const plugins = Array.from({ length: 5 }, (_, i) =>
|
||||
makePlugin({ slug: `plugin-${i}`, name: `Plugin ${i}` })
|
||||
);
|
||||
setupStore({ availablePlugins: plugins });
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.getByText('1 to 5 of 5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves perPage to localStorage when page size changes', () => {
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
setupStore({ availablePlugins: [makePlugin()] });
|
||||
render(<PluginBrowsePage />);
|
||||
fireEvent.change(screen.getByTestId('page-size-select'), {
|
||||
target: { value: '18' },
|
||||
});
|
||||
expect(setItemSpy).toHaveBeenCalledWith('pluginBrowsePerPage', '18');
|
||||
});
|
||||
|
||||
it('reads initial perPage from localStorage', () => {
|
||||
localStorage.setItem('pluginBrowsePerPage', '27');
|
||||
const plugins = Array.from({ length: 27 }, (_, i) =>
|
||||
makePlugin({ slug: `p-${i}`, name: `Plugin ${i}` })
|
||||
);
|
||||
setupStore({ availablePlugins: plugins });
|
||||
render(<PluginBrowsePage />);
|
||||
expect(screen.getByTestId('page-size-select')).toHaveValue('27');
|
||||
});
|
||||
|
||||
it('resets page to 1 when search query changes', () => {
|
||||
const plugins = Array.from({ length: 20 }, (_, i) =>
|
||||
makePlugin({ slug: `p-${i}`, name: `Plugin ${i}` })
|
||||
);
|
||||
setupStore({ availablePlugins: plugins });
|
||||
render(<PluginBrowsePage />);
|
||||
// Go to page 2
|
||||
fireEvent.click(screen.getByTestId('next-page'));
|
||||
expect(screen.getByTestId('pagination-value')).toHaveTextContent('2');
|
||||
// Change search → page resets
|
||||
fireEvent.change(screen.getByTestId('search-input'), {
|
||||
target: { value: 'Plugin' },
|
||||
});
|
||||
expect(screen.getByTestId('pagination-value')).toHaveTextContent('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
36
frontend/src/utils/forms/OutputProfileUtils.js
Normal file
36
frontend/src/utils/forms/OutputProfileUtils.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
|
||||
export const BUILT_IN_COMMANDS = [
|
||||
{ value: 'ffmpeg', label: 'FFmpeg' },
|
||||
{ value: '__custom__', label: 'Custom…' },
|
||||
];
|
||||
|
||||
export const COMMAND_EXAMPLES = {
|
||||
ffmpeg:
|
||||
'-i pipe:0 -c:v libx264 -b:v 2000k -vf scale=-2:720 -c:a copy -f mpegts pipe:1',
|
||||
};
|
||||
|
||||
export const toCommandSelection = (command) =>
|
||||
BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__')
|
||||
? command
|
||||
: '__custom__';
|
||||
|
||||
export const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
command: Yup.string().required('Command is required'),
|
||||
parameters: Yup.string(),
|
||||
});
|
||||
|
||||
export const addOutputProfile = (values) => {
|
||||
return API.addOutputProfile(values);
|
||||
};
|
||||
|
||||
export const updateOutputProfile = (values) => {
|
||||
return API.updateOutputProfile(values);
|
||||
};
|
||||
|
||||
export const getResolver = () => {
|
||||
return yupResolver(schema);
|
||||
};
|
||||
16
frontend/src/utils/forms/ServerGroupUtils.js
Normal file
16
frontend/src/utils/forms/ServerGroupUtils.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
});
|
||||
export const getResolver = () => {
|
||||
return yupResolver(schema);
|
||||
};
|
||||
export const updateServerGroup = (values) => {
|
||||
return API.updateServerGroup(values);
|
||||
};
|
||||
export const addServerGroup = (values) => {
|
||||
return API.addServerGroup(values);
|
||||
};
|
||||
210
frontend/src/utils/forms/__tests__/OutputProfileUtils.test.js
Normal file
210
frontend/src/utils/forms/__tests__/OutputProfileUtils.test.js
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
default: {
|
||||
addOutputProfile: vi.fn(),
|
||||
updateOutputProfile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockResolver = vi.fn();
|
||||
vi.mock('@hookform/resolvers/yup', () => ({
|
||||
yupResolver: vi.fn(() => mockResolver),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
import API from '../../../api';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import {
|
||||
BUILT_IN_COMMANDS,
|
||||
COMMAND_EXAMPLES,
|
||||
toCommandSelection,
|
||||
schema,
|
||||
addOutputProfile,
|
||||
updateOutputProfile,
|
||||
getResolver,
|
||||
} from '../OutputProfileUtils';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('OutputProfileUtils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(yupResolver).mockReturnValue(mockResolver);
|
||||
});
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('BUILT_IN_COMMANDS', () => {
|
||||
it('includes the ffmpeg entry', () => {
|
||||
expect(BUILT_IN_COMMANDS).toContainEqual({
|
||||
value: 'ffmpeg',
|
||||
label: 'FFmpeg',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes the custom entry', () => {
|
||||
expect(BUILT_IN_COMMANDS).toContainEqual({
|
||||
value: '__custom__',
|
||||
label: 'Custom…',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('COMMAND_EXAMPLES', () => {
|
||||
it('has a non-empty string example for ffmpeg', () => {
|
||||
expect(typeof COMMAND_EXAMPLES.ffmpeg).toBe('string');
|
||||
expect(COMMAND_EXAMPLES.ffmpeg.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── toCommandSelection ─────────────────────────────────────────────────────
|
||||
|
||||
describe('toCommandSelection', () => {
|
||||
it('returns the command value when it matches a non-custom built-in', () => {
|
||||
expect(toCommandSelection('ffmpeg')).toBe('ffmpeg');
|
||||
});
|
||||
|
||||
it('returns "__custom__" when command is "__custom__"', () => {
|
||||
// __custom__ is in BUILT_IN_COMMANDS but excluded by the o.value !== '__custom__' guard
|
||||
expect(toCommandSelection('__custom__')).toBe('__custom__');
|
||||
});
|
||||
|
||||
it('returns "__custom__" for an unrecognized command string', () => {
|
||||
expect(toCommandSelection('my-arbitrary-tool')).toBe('__custom__');
|
||||
});
|
||||
|
||||
it('returns "__custom__" for an empty string', () => {
|
||||
expect(toCommandSelection('')).toBe('__custom__');
|
||||
});
|
||||
|
||||
it('returns "__custom__" for undefined', () => {
|
||||
expect(toCommandSelection(undefined)).toBe('__custom__');
|
||||
});
|
||||
});
|
||||
|
||||
// ── schema ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('schema', () => {
|
||||
it('validates a fully populated object', async () => {
|
||||
await expect(
|
||||
schema.validate({
|
||||
name: 'HD Profile',
|
||||
command: 'ffmpeg',
|
||||
parameters: '-c:v copy',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
name: 'HD Profile',
|
||||
command: 'ffmpeg',
|
||||
parameters: '-c:v copy',
|
||||
});
|
||||
});
|
||||
|
||||
it('validates when parameters is omitted (optional)', async () => {
|
||||
await expect(
|
||||
schema.validate({ name: 'HD Profile', command: 'ffmpeg' })
|
||||
).resolves.toMatchObject({ name: 'HD Profile', command: 'ffmpeg' });
|
||||
});
|
||||
|
||||
it('rejects when name is missing', async () => {
|
||||
await expect(schema.validate({ command: 'ffmpeg' })).rejects.toThrow(
|
||||
'Name is required'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when name is an empty string', async () => {
|
||||
await expect(
|
||||
schema.validate({ name: '', command: 'ffmpeg' })
|
||||
).rejects.toThrow('Name is required');
|
||||
});
|
||||
|
||||
it('rejects when command is missing', async () => {
|
||||
await expect(schema.validate({ name: 'HD Profile' })).rejects.toThrow(
|
||||
'Command is required'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when command is an empty string', async () => {
|
||||
await expect(
|
||||
schema.validate({ name: 'HD Profile', command: '' })
|
||||
).rejects.toThrow('Command is required');
|
||||
});
|
||||
});
|
||||
|
||||
// ── addOutputProfile ───────────────────────────────────────────────────────
|
||||
|
||||
describe('addOutputProfile', () => {
|
||||
it('calls API.addOutputProfile with the provided values', async () => {
|
||||
const values = { name: 'New Profile', command: 'ffmpeg', parameters: '' };
|
||||
vi.mocked(API.addOutputProfile).mockResolvedValue({ id: 1, ...values });
|
||||
|
||||
await addOutputProfile(values);
|
||||
|
||||
expect(API.addOutputProfile).toHaveBeenCalledWith(values);
|
||||
expect(API.addOutputProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the API response', async () => {
|
||||
const values = { name: 'New Profile', command: 'ffmpeg' };
|
||||
const response = { id: 42, ...values };
|
||||
vi.mocked(API.addOutputProfile).mockResolvedValue(response);
|
||||
|
||||
const result = await addOutputProfile(values);
|
||||
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
|
||||
it('propagates errors thrown by API.addOutputProfile', async () => {
|
||||
vi.mocked(API.addOutputProfile).mockRejectedValue(
|
||||
new Error('Network error')
|
||||
);
|
||||
|
||||
await expect(addOutputProfile({})).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateOutputProfile ────────────────────────────────────────────────────
|
||||
|
||||
describe('updateOutputProfile', () => {
|
||||
it('calls API.updateOutputProfile with the provided values', async () => {
|
||||
const values = { id: 1, name: 'Updated Profile', command: 'ffmpeg' };
|
||||
vi.mocked(API.updateOutputProfile).mockResolvedValue(values);
|
||||
|
||||
await updateOutputProfile(values);
|
||||
|
||||
expect(API.updateOutputProfile).toHaveBeenCalledWith(values);
|
||||
expect(API.updateOutputProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the API response', async () => {
|
||||
const values = { id: 7, name: 'Updated Profile', command: 'ffmpeg' };
|
||||
vi.mocked(API.updateOutputProfile).mockResolvedValue(values);
|
||||
|
||||
const result = await updateOutputProfile(values);
|
||||
|
||||
expect(result).toEqual(values);
|
||||
});
|
||||
|
||||
it('propagates errors thrown by API.updateOutputProfile', async () => {
|
||||
vi.mocked(API.updateOutputProfile).mockRejectedValue(
|
||||
new Error('Update failed')
|
||||
);
|
||||
|
||||
await expect(updateOutputProfile({})).rejects.toThrow('Update failed');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getResolver ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getResolver', () => {
|
||||
it('returns the result of yupResolver called with schema', () => {
|
||||
const resolver = getResolver();
|
||||
|
||||
expect(yupResolver).toHaveBeenCalledWith(schema);
|
||||
expect(resolver).toBe(mockResolver);
|
||||
});
|
||||
});
|
||||
});
|
||||
114
frontend/src/utils/forms/__tests__/ServerGroupUtils.test.js
Normal file
114
frontend/src/utils/forms/__tests__/ServerGroupUtils.test.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
default: {
|
||||
addServerGroup: vi.fn(),
|
||||
updateServerGroup: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockResolver = vi.fn();
|
||||
vi.mock('@hookform/resolvers/yup', () => ({
|
||||
yupResolver: vi.fn(() => mockResolver),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
import API from '../../../api';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import {
|
||||
getResolver,
|
||||
addServerGroup,
|
||||
updateServerGroup,
|
||||
} from '../ServerGroupUtils';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ServerGroupUtils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(yupResolver).mockReturnValue(mockResolver);
|
||||
});
|
||||
|
||||
// ── getResolver ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getResolver', () => {
|
||||
it('calls yupResolver with a schema and returns the result', () => {
|
||||
const resolver = getResolver();
|
||||
|
||||
expect(yupResolver).toHaveBeenCalledTimes(1);
|
||||
expect(yupResolver).toHaveBeenCalledWith(expect.any(Object));
|
||||
expect(resolver).toBe(mockResolver);
|
||||
});
|
||||
|
||||
it('returns a new resolver on each call', () => {
|
||||
const resolverA = getResolver();
|
||||
const resolverB = getResolver();
|
||||
|
||||
expect(yupResolver).toHaveBeenCalledTimes(2);
|
||||
expect(resolverA).toBe(mockResolver);
|
||||
expect(resolverB).toBe(mockResolver);
|
||||
});
|
||||
});
|
||||
|
||||
// ── addServerGroup ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('addServerGroup', () => {
|
||||
it('calls API.addServerGroup with the provided values', async () => {
|
||||
const values = { name: 'US East' };
|
||||
vi.mocked(API.addServerGroup).mockResolvedValue({ id: 1, ...values });
|
||||
|
||||
await addServerGroup(values);
|
||||
|
||||
expect(API.addServerGroup).toHaveBeenCalledWith(values);
|
||||
expect(API.addServerGroup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the API response', async () => {
|
||||
const values = { name: 'US East' };
|
||||
const response = { id: 1, ...values };
|
||||
vi.mocked(API.addServerGroup).mockResolvedValue(response);
|
||||
|
||||
const result = await addServerGroup(values);
|
||||
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
|
||||
it('propagates errors thrown by API.addServerGroup', async () => {
|
||||
vi.mocked(API.addServerGroup).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(addServerGroup({ name: 'Test' })).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateServerGroup ──────────────────────────────────────────────────────
|
||||
|
||||
describe('updateServerGroup', () => {
|
||||
it('calls API.updateServerGroup with the provided values', async () => {
|
||||
const values = { id: 5, name: 'EU West' };
|
||||
vi.mocked(API.updateServerGroup).mockResolvedValue(values);
|
||||
|
||||
await updateServerGroup(values);
|
||||
|
||||
expect(API.updateServerGroup).toHaveBeenCalledWith(values);
|
||||
expect(API.updateServerGroup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the API response', async () => {
|
||||
const values = { id: 5, name: 'EU West' };
|
||||
vi.mocked(API.updateServerGroup).mockResolvedValue(values);
|
||||
|
||||
const result = await updateServerGroup(values);
|
||||
|
||||
expect(result).toEqual(values);
|
||||
});
|
||||
|
||||
it('propagates errors thrown by API.updateServerGroup', async () => {
|
||||
vi.mocked(API.updateServerGroup).mockRejectedValue(new Error('Update failed'));
|
||||
|
||||
await expect(updateServerGroup({ id: 1, name: 'Test' })).rejects.toThrow('Update failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue