diff --git a/frontend/src/components/forms/__tests__/ProgramRecordingModal.test.jsx b/frontend/src/components/forms/__tests__/ProgramRecordingModal.test.jsx
new file mode 100644
index 00000000..e47b15fd
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/ProgramRecordingModal.test.jsx
@@ -0,0 +1,285 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Dependency mocks ───────────────────────────────────────────────────────────
+vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
+ deleteRecordingById: vi.fn(),
+ deleteSeriesAndRule: vi.fn(),
+}));
+
+vi.mock('../../../utils/guideUtils.js', () => ({
+ deleteSeriesRuleByTvgId: vi.fn(),
+}));
+
+vi.mock('../SeriesRuleEditorModal.jsx', () => ({
+ default: ({ opened, onClose }) =>
+ opened ? (
+
+
+ Close Editor
+
+
+ ) : null,
+}));
+
+vi.mock('@mantine/core', async () => ({
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
×
+ {children}
+
+ ) : null,
+ Flex: ({ children }) => {children}
,
+ Button: ({ children, onClick, disabled, loading, color, variant }) => (
+
+ {children}
+
+ ),
+ Anchor: ({ children, onClick }) => (
+ {children}
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import ProgramRecordingModal from '../ProgramRecordingModal.jsx';
+import { deleteRecordingById, deleteSeriesAndRule } from '../../../utils/cards/RecordingCardUtils.js';
+import { deleteSeriesRuleByTvgId } from '../../../utils/guideUtils.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const makeProgram = (overrides = {}) => ({
+ tvg_id: 'tvg-1',
+ title: 'Test Show',
+ ...overrides,
+});
+
+const makeRecording = (overrides = {}) => ({
+ id: 'rec-1',
+ ...overrides,
+});
+
+const defaultProps = (overrides = {}) => ({
+ opened: true,
+ onClose: vi.fn(),
+ program: makeProgram(),
+ recording: null,
+ existingRuleMode: null,
+ existingRule: null,
+ onRecordOne: vi.fn(),
+ onRecordSeriesAll: vi.fn(),
+ onRecordSeriesNew: vi.fn(),
+ onExistingRuleModeChange: vi.fn(),
+ ...overrides,
+});
+
+// ─── ProgramRecordingModal ─────────────────────────────────────────────────────
+
+describe('ProgramRecordingModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(deleteRecordingById).mockResolvedValue(undefined);
+ vi.mocked(deleteSeriesAndRule).mockResolvedValue(undefined);
+ vi.mocked(deleteSeriesRuleByTvgId).mockResolvedValue(undefined);
+ });
+
+ // ── Visibility ─────────────────────────────────────────────────────────────
+
+ describe('visibility', () => {
+ it('renders the modal when opened is true', () => {
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render the modal when opened is false', () => {
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('calls onClose when the modal close button is clicked', () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── handleRemoveRecording ──────────────────────────────────────────────────
+
+ describe('handleRemoveRecording', () => {
+ it('calls deleteRecordingById with the recording id', async () => {
+ const recording = makeRecording();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Remove this recording'));
+ await waitFor(() => {
+ expect(deleteRecordingById).toHaveBeenCalledWith('rec-1');
+ });
+ });
+
+ it('calls onClose after deleting recording', async () => {
+ const onClose = vi.fn();
+ const recording = makeRecording();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Remove this recording'));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('still calls onClose when deleteRecordingById throws', async () => {
+ vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
+ const onClose = vi.fn();
+ const recording = makeRecording();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Remove this recording'));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── handleRemoveSeries ─────────────────────────────────────────────────────
+
+ describe('handleRemoveSeries', () => {
+ it('calls deleteSeriesAndRule with tvg_id and title', async () => {
+ const program = makeProgram({ tvg_id: 'tvg-2', title: 'My Series' });
+ const recording = makeRecording();
+ render(
+
+ );
+ fireEvent.click(screen.getByText(/Remove this series/i));
+ await waitFor(() => {
+ expect(deleteSeriesAndRule).toHaveBeenCalledWith({
+ tvg_id: 'tvg-2',
+ title: 'My Series',
+ });
+ });
+ });
+
+ it('calls onClose after removing series', async () => {
+ const onClose = vi.fn();
+ const recording = makeRecording();
+ render(
+
+ );
+ fireEvent.click(screen.getByText(/Remove this series/i));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── handleRemoveSeriesRule ─────────────────────────────────────────────────
+
+ describe('handleRemoveSeriesRule', () => {
+ it('calls deleteSeriesRuleByTvgId with tvg_id and title', async () => {
+ const program = makeProgram({ tvg_id: 'tvg-3', title: 'Rule Show' });
+ render(
+
+ );
+ fireEvent.click(screen.getByText(/Remove series rule/i));
+ await waitFor(() => {
+ expect(deleteSeriesRuleByTvgId).toHaveBeenCalledWith('tvg-3', 'Rule Show');
+ });
+ });
+
+ it('calls onExistingRuleModeChange(null) after removing rule', async () => {
+ const onExistingRuleModeChange = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText(/Remove series rule/i));
+ await waitFor(() => {
+ expect(onExistingRuleModeChange).toHaveBeenCalledWith(null);
+ });
+ });
+
+ it('calls onClose after removing rule', async () => {
+ const onClose = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText(/Remove series rule/i));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Record actions ─────────────────────────────────────────────────────────
+
+ describe('record actions', () => {
+ it('calls onRecordOne when "Record Once" is clicked', () => {
+ const onRecordOne = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText('Just this one'));
+ expect(onRecordOne).toHaveBeenCalled();
+ });
+
+ it('calls onRecordSeriesAll when "Record All" is clicked', () => {
+ const onRecordSeriesAll = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText('Every episode'));
+ expect(onRecordSeriesAll).toHaveBeenCalled();
+ });
+
+ it('calls onRecordSeriesNew when "Record New" is clicked', () => {
+ const onRecordSeriesNew = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText('New episodes only'));
+ expect(onRecordSeriesNew).toHaveBeenCalled();
+ });
+ });
+
+ // ── SeriesRuleEditorModal ──────────────────────────────────────────────────
+
+ describe('SeriesRuleEditorModal', () => {
+ it('opens SeriesRuleEditorModal when "Edit Rule" is clicked', () => {
+ render(
+
+ );
+ fireEvent.click(screen.getByText(/Customize rule/i));
+ expect(screen.getByTestId('series-rule-editor-modal')).toBeInTheDocument();
+ });
+
+ it('closes SeriesRuleEditorModal when its onClose is called', () => {
+ render(
+
+ );
+ fireEvent.click(screen.getByText(/Customize rule/i));
+ fireEvent.click(screen.getByTestId('series-rule-editor-close'));
+ expect(screen.queryByTestId('series-rule-editor-modal')).not.toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/__tests__/Recording.test.jsx b/frontend/src/components/forms/__tests__/Recording.test.jsx
new file mode 100644
index 00000000..9c3771bc
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/Recording.test.jsx
@@ -0,0 +1,507 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import RecordingModal from '../Recording';
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/dateTimeUtils.js', () => ({
+ RECURRING_DAY_OPTIONS: [
+ { value: 'mon', label: 'Monday' },
+ { value: 'tue', label: 'Tuesday' },
+ ],
+ toTimeString: vi.fn((val) => val),
+}));
+
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
+ buildRecurringPayload: vi.fn((v) => v),
+ buildSinglePayload: vi.fn((v) => v),
+ createRecording: vi.fn(),
+ createRecurringRule: vi.fn(),
+ sortedChannelOptions: vi.fn(() => [{ value: 'ch-1', label: '501 - HBO' }]),
+ numberedChannelLabel: vi.fn((item) =>
+ item.channel_number ? `${item.channel_number} - ${item.name}` : item.name
+ ),
+ getChannelsSummary: vi.fn(),
+ getRecurringFormDefaults: vi.fn(() => ({
+ channel_id: '',
+ rule_name: '',
+ days_of_week: [],
+ start_date: new Date('2024-01-01'),
+ end_date: null,
+ start_time: '08:00',
+ end_time: '09:00',
+ })),
+ getSingleFormDefaults: vi.fn(() => ({
+ channel_id: 'ch-1',
+ start_time: new Date('2024-06-01T10:00:00'),
+ end_time: new Date('2024-06-01T11:00:00'),
+ })),
+ recurringFormValidators: {},
+ singleFormValidators: {},
+ timeChange: vi.fn((fn) => (e) => fn(e.target.value)),
+ updateRecording: vi.fn(),
+}));
+
+// ── @mantine/form ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/form', () => ({
+ useForm: vi.fn(({ initialValues }) => {
+ const values = { ...initialValues };
+ return {
+ values,
+ key: vi.fn((k) => k),
+ getInputProps: vi.fn((k) => ({ name: k, value: values[k] ?? '', onChange: vi.fn() })),
+ onSubmit: vi.fn((handler) => (e) => { e?.preventDefault?.(); return handler(values); }),
+ reset: vi.fn(),
+ setValues: vi.fn((newVals) => Object.assign(values, newVals)),
+ setFieldValue: vi.fn((k, v) => { values[k] = v; }),
+ validateField: vi.fn(),
+ };
+ }),
+}));
+
+// ── @mantine/core ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Alert: ({ children, title }) => (
+
+ {title}
+ {children}
+
+ ),
+ Button: ({ children, onClick, loading, type }) => (
+
+ {children}
+
+ ),
+ Group: ({ children }) => {children}
,
+ Loader: ({ size, color }) => ,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
×
+ {children}
+
+ ) : null,
+ MultiSelect: ({ label, placeholder, ...props }) => (
+
+ {label}
+
+
+ ),
+ SegmentedControl: ({ value, onChange, data, disabled }) => (
+
+ {data.map((item) => (
+ onChange(item.value)}
+ >
+ {item.label}
+
+ ))}
+
+ ),
+ Select: ({ label, disabled, rightSection, data, ...props }) => (
+
+ {label}
+ {rightSection}
+
+ {(data ?? []).map((opt) => (
+ {opt.label}
+ ))}
+
+
+ ),
+ Stack: ({ children }) => {children}
,
+ TextInput: ({ label, placeholder, ...props }) => (
+
+ {label}
+
+
+ ),
+}));
+
+// ── @mantine/dates ─────────────────────────────────────────────────────────────
+vi.mock('@mantine/dates', () => ({
+ DatePickerInput: ({ label, value, onChange }) => (
+
+ {label}
+ onChange(e.target.value ? new Date(e.target.value) : null)}
+ />
+
+ ),
+ DateTimePicker: ({ label, ...props }) => (
+
+ {label}
+
+
+ ),
+ TimeInput: ({ label, value, onChange, onBlur }) => (
+
+ {label}
+
+
+ ),
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ CircleAlert: () => ,
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import useChannelsStore from '../../../store/channels';
+import { showNotification } from '../../../utils/notificationUtils.js';
+import * as RecordingUtils from '../../../utils/forms/RecordingUtils.js';
+
+const setupStoreMock = () => {
+ const mockFetchRecordings = vi.fn().mockResolvedValue(undefined);
+ const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined);
+
+ vi.mocked(useChannelsStore).mockImplementation((sel) =>
+ sel({ fetchRecordings: mockFetchRecordings, fetchRecurringRules: mockFetchRecurringRules })
+ );
+
+ return { mockFetchRecordings, mockFetchRecurringRules };
+};
+
+const makeRecording = (overrides = {}) => ({
+ id: 'rec-1',
+ start_time: '2024-06-01T10:00:00Z',
+ end_time: '2024-06-01T11:00:00Z',
+ custom_properties: { program: { title: 'Test Show' } },
+ ...overrides,
+});
+
+const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 });
+
+describe('RecordingModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([
+ { id: 'ch-1', name: 'HBO', channel_number: 501 },
+ ]);
+ vi.mocked(RecordingUtils.createRecording).mockResolvedValue(undefined);
+ vi.mocked(RecordingUtils.updateRecording).mockResolvedValue(undefined);
+ vi.mocked(RecordingUtils.createRecurringRule).mockResolvedValue(undefined);
+ setupStoreMock();
+ });
+
+ // ── Visibility ─────────────────────────────────────────────────────────────
+
+ describe('visibility', () => {
+ it('renders the modal when isOpen is true', () => {
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render when isOpen is false', () => {
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Alert ──────────────────────────────────────────────────────────────────
+
+ describe('scheduling conflict alert', () => {
+ it('renders the scheduling conflicts alert', () => {
+ render( );
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
+ expect(screen.getByTestId('alert-title')).toHaveTextContent('Scheduling Conflicts');
+ });
+ });
+
+ // ── Mode switching ─────────────────────────────────────────────────────────
+
+ describe('mode switching', () => {
+ it('defaults to "single" mode', () => {
+ render( );
+ expect(screen.getByTestId('mode-single')).toHaveAttribute('data-active', 'true');
+ });
+
+ it('switches to recurring mode when Recurring button clicked', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('mode-recurring'));
+ expect(screen.getByTestId('mode-recurring')).toHaveAttribute('data-active', 'true');
+ });
+
+ it('shows DateTimePicker fields in single mode', () => {
+ render( );
+ expect(screen.getByTestId('datetimepicker-Start')).toBeInTheDocument();
+ expect(screen.getByTestId('datetimepicker-End')).toBeInTheDocument();
+ });
+
+ it('shows recurring fields when in recurring mode', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('mode-recurring'));
+ expect(screen.getByTestId('textinput-Rule name')).toBeInTheDocument();
+ expect(screen.getByTestId('timeinput-Start time')).toBeInTheDocument();
+ expect(screen.getByTestId('timeinput-End time')).toBeInTheDocument();
+ });
+
+ it('disables mode toggle when editing an existing recording', () => {
+ render( );
+ expect(screen.getByTestId('mode-single')).toBeDisabled();
+ expect(screen.getByTestId('mode-recurring')).toBeDisabled();
+ });
+
+ it('shows "Schedule Recording" submit button in single mode', () => {
+ render( );
+ expect(screen.getByText('Schedule Recording')).toBeInTheDocument();
+ });
+
+ it('shows "Save Rule" submit button in recurring mode', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('mode-recurring'));
+ expect(screen.getByText('Save Rule')).toBeInTheDocument();
+ });
+ });
+
+ // ── Channel loading ────────────────────────────────────────────────────────
+
+ describe('channel loading', () => {
+ it('calls getChannelsSummary when modal opens', async () => {
+ render( );
+ await waitFor(() => {
+ expect(RecordingUtils.getChannelsSummary).toHaveBeenCalled();
+ });
+ });
+
+ it('calls sortedChannelOptions with loaded channels', async () => {
+ const channels = [{ id: 'ch-1', name: 'HBO', channel_number: 501 }];
+ vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue(channels);
+ render( );
+ await waitFor(() => {
+ expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith(
+ channels,
+ RecordingUtils.numberedChannelLabel
+ );
+ });
+ });
+
+ it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => {
+ vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(new Error('fail'));
+ render( );
+ await waitFor(() => {
+ expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith(
+ [],
+ RecordingUtils.numberedChannelLabel
+ );
+ });
+ });
+
+ it('does not load channels when modal is closed', () => {
+ render( );
+ expect(RecordingUtils.getChannelsSummary).not.toHaveBeenCalled();
+ });
+ });
+
+ // ── Single form submit (create) ────────────────────────────────────────────
+
+ describe('single mode – create recording', () => {
+ it('calls buildSinglePayload with form values on submit', async () => {
+ render( );
+ fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
+ await waitFor(() => {
+ expect(RecordingUtils.buildSinglePayload).toHaveBeenCalled();
+ });
+ });
+
+ it('calls createRecording when no existing recording', async () => {
+ render( );
+ fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
+ await waitFor(() => {
+ expect(RecordingUtils.createRecording).toHaveBeenCalled();
+ });
+ });
+
+ it('shows "Recording scheduled" notification after successful create', async () => {
+ render( );
+ fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Recording scheduled', color: 'green' })
+ );
+ });
+ });
+
+ it('calls fetchRecordings after successful create', async () => {
+ const { mockFetchRecordings } = setupStoreMock();
+ render( );
+ fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
+ await waitFor(() => {
+ expect(mockFetchRecordings).toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClose after successful create', async () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('does not call showNotification when createRecording throws', async () => {
+ vi.mocked(RecordingUtils.createRecording).mockRejectedValue(new Error('fail'));
+ render( );
+ fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Single form submit (update) ────────────────────────────────────────────
+
+ describe('single mode – update recording', () => {
+ it('calls updateRecording when editing an existing recording', async () => {
+ render(
+
+ );
+ fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
+ await waitFor(() => {
+ expect(RecordingUtils.updateRecording).toHaveBeenCalledWith(
+ 'rec-1',
+ expect.anything()
+ );
+ });
+ });
+
+ it('does not call createRecording when updating', async () => {
+ render(
+
+ );
+ fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
+ await waitFor(() => {
+ expect(RecordingUtils.createRecording).not.toHaveBeenCalled();
+ });
+ });
+
+ it('shows "Recording updated" notification after successful update', async () => {
+ render(
+
+ );
+ fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Recording updated', color: 'green' })
+ );
+ });
+ });
+ });
+
+ // ── Recurring form submit ──────────────────────────────────────────────────
+
+ describe('recurring mode – create rule', () => {
+ it('calls buildRecurringPayload with form values on submit', async () => {
+ render( );
+ fireEvent.click(screen.getByTestId('mode-recurring'));
+ fireEvent.submit(screen.getByText('Save Rule').closest('form'));
+ await waitFor(() => {
+ expect(RecordingUtils.buildRecurringPayload).toHaveBeenCalled();
+ });
+ });
+
+ it('calls createRecurringRule on submit', async () => {
+ render( );
+ fireEvent.click(screen.getByTestId('mode-recurring'));
+ fireEvent.submit(screen.getByText('Save Rule').closest('form'));
+ await waitFor(() => {
+ expect(RecordingUtils.createRecurringRule).toHaveBeenCalled();
+ });
+ });
+
+ it('calls fetchRecurringRules and fetchRecordings on success', async () => {
+ const { mockFetchRecurringRules, mockFetchRecordings } = setupStoreMock();
+ render( );
+ fireEvent.click(screen.getByTestId('mode-recurring'));
+ fireEvent.submit(screen.getByText('Save Rule').closest('form'));
+ await waitFor(() => {
+ expect(mockFetchRecurringRules).toHaveBeenCalled();
+ expect(mockFetchRecordings).toHaveBeenCalled();
+ });
+ });
+
+ it('shows "Recurring rule saved" notification on success', async () => {
+ render( );
+ fireEvent.click(screen.getByTestId('mode-recurring'));
+ fireEvent.submit(screen.getByText('Save Rule').closest('form'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Recurring rule saved', color: 'green' })
+ );
+ });
+ });
+
+ it('calls onClose after successful recurring submit', async () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId('mode-recurring'));
+ fireEvent.submit(screen.getByText('Save Rule').closest('form'));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('does not show notification when createRecurringRule throws', async () => {
+ vi.mocked(RecordingUtils.createRecurringRule).mockRejectedValue(new Error('fail'));
+ render( );
+ fireEvent.click(screen.getByTestId('mode-recurring'));
+ fireEvent.submit(screen.getByText('Save Rule').closest('form'));
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Form initialization ────────────────────────────────────────────────────
+
+ describe('form initialization', () => {
+ it('calls getSingleFormDefaults with recording and channel when opening with existing recording', () => {
+ const recording = makeRecording();
+ const channel = makeChannel();
+ render( );
+ expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(recording, channel);
+ });
+
+ it('calls getSingleFormDefaults with null when opening for new recording', () => {
+ render( );
+ expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(null, null);
+ });
+
+ it('calls getRecurringFormDefaults with channel on open', () => {
+ const channel = makeChannel();
+ render( );
+ expect(RecordingUtils.getRecurringFormDefaults).toHaveBeenCalledWith(channel);
+ });
+ });
+
+ // ── Close / reset ──────────────────────────────────────────────────────────
+
+ describe('close and reset', () => {
+ it('calls onClose when modal close button is clicked', () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx b/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx
new file mode 100644
index 00000000..91216ac1
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx
@@ -0,0 +1,770 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
+vi.mock('../../../store/useVideoStore.jsx', () => ({
+ default: Object.assign(vi.fn(), {
+ getState: vi.fn(() => ({ showVideo: vi.fn() })),
+ }),
+}));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/dateTimeUtils.js', () => ({
+ format: vi.fn(),
+ isAfter: vi.fn(),
+ isBefore: vi.fn(),
+ useDateTimeFormat: vi.fn(),
+ useTimeHelpers: vi.fn(),
+}));
+
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
+ deleteRecordingById: vi.fn(),
+ getChannelLogoUrl: vi.fn(),
+ getPosterUrl: vi.fn(),
+ getRecordingUrl: vi.fn(),
+ getSeasonLabel: vi.fn(),
+ getShowVideoUrl: vi.fn(),
+ runComSkip: vi.fn(),
+}));
+
+vi.mock('../../../utils/forms/RecordingDetailsModalUtils.js', () => ({
+ getChannel: vi.fn(),
+ getRating: vi.fn(),
+ getStatRows: vi.fn(),
+ getUpcomingEpisodes: vi.fn(),
+ refreshArtwork: vi.fn(),
+ updateRecordingMetadata: vi.fn(),
+}));
+
+vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' }));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ Check: () => ,
+ Pencil: () => ,
+ RefreshCcw: () => ,
+ X: () => ,
+}));
+
+// ── @mantine/core ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick, disabled }) => (
+
+ {children}
+
+ ),
+ Badge: ({ children, color }) => (
+ {children}
+ ),
+ Button: ({ children, onClick, disabled, loading, size, variant, color }) => (
+
+ {children}
+
+ ),
+ Card: ({ children, onClick, style }) => (
+ {children}
+ ),
+ Flex: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ Image: ({ src, alt, fallbackSrc }) => (
+
+ ),
+ Modal: ({ children, opened, onClose, title, size }) =>
+ opened ? (
+
+
{title}
+
×
+ {children}
+
+ ) : null,
+ Stack: ({ children }) => {children}
,
+ Text: ({ children, size, c, fw, style }) => (
+ {children}
+ ),
+ Textarea: ({ label, value, onChange, placeholder, ...props }) => (
+
+ {label}
+
+
+ ),
+ TextInput: ({ label, value, onChange, placeholder, ...props }) => (
+
+
+
+
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import RecordingDetailsModal from '../RecordingDetailsModal';
+import useChannelsStore from '../../../store/channels.jsx';
+import useVideoStore from '../../../store/useVideoStore.jsx';
+import {
+ format,
+ isAfter,
+ isBefore,
+ useDateTimeFormat,
+ useTimeHelpers,
+} from '../../../utils/dateTimeUtils.js';
+import { showNotification } from '../../../utils/notificationUtils.js';
+import * as RecordingCardUtils from '../../../utils/cards/RecordingCardUtils.js';
+import * as RecordingDetailsModalUtils from '../../../utils/forms/RecordingDetailsModalUtils.js';
+import dayjs from 'dayjs';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const PAST = '2020-01-01T10:00:00Z';
+const FUTURE = '2099-01-01T10:00:00Z';
+const NOW = '2024-06-01T12:00:00Z';
+
+const makeMoment = (isoString) => {
+ const d = dayjs(isoString);
+ return {
+ isAfter: (other) => d.isAfter(other?._d ?? other),
+ isBefore: (other) => d.isBefore(other?._d ?? other),
+ format: vi.fn((fmt) => d.format(fmt)),
+ _d: d.toDate(),
+ };
+};
+
+const makeRecording = (overrides = {}) => ({
+ id: 'rec-1',
+ channel: 'ch-1',
+ start_time: PAST,
+ end_time: PAST,
+ _group_count: 1,
+ custom_properties: {
+ status: 'completed',
+ file_url: '/recordings/test.ts',
+ program: {
+ title: 'Test Show',
+ description: 'A test description',
+ sub_title: 'Pilot',
+ },
+ ...overrides.custom_properties,
+ },
+ ...overrides,
+});
+
+const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 });
+
+const mockShowVideo = vi.fn();
+
+const setupMocks = ({ recording = makeRecording(), now = NOW } = {}) => {
+ const nowMoment = makeMoment(now);
+ const startMoment = makeMoment(recording.start_time);
+ const endMoment = makeMoment(recording.end_time);
+
+ vi.mocked(useTimeHelpers).mockReturnValue({
+ toUserTime: (iso) => {
+ if (iso === recording.start_time) return startMoment;
+ if (iso === recording.end_time) return endMoment;
+ return makeMoment(iso);
+ },
+ userNow: () => nowMoment,
+ });
+
+ vi.mocked(useDateTimeFormat).mockReturnValue({
+ timeFormat: 'HH:mm',
+ dateFormat: 'MM/DD',
+ });
+
+ vi.mocked(format).mockImplementation((moment, fmt) => moment.format(fmt));
+ vi.mocked(isAfter).mockImplementation((a, b) => a.isAfter(b));
+ vi.mocked(isBefore).mockImplementation((a, b) => a.isBefore(b));
+
+ vi.mocked(useChannelsStore).mockImplementation((sel) =>
+ sel({ recordings: [recording] })
+ );
+
+ mockShowVideo.mockReset();
+ vi.mocked(useVideoStore).mockImplementation((sel) =>
+ sel({ showVideo: mockShowVideo })
+ );
+ useVideoStore.getState = vi.fn(() => ({ showVideo: mockShowVideo }));
+
+ vi.mocked(RecordingCardUtils.getPosterUrl).mockReturnValue('/poster.jpg');
+ vi.mocked(RecordingCardUtils.getChannelLogoUrl).mockReturnValue('/logo.png');
+ vi.mocked(RecordingCardUtils.getRecordingUrl).mockReturnValue('/recordings/test.ts');
+ vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('');
+ vi.mocked(RecordingCardUtils.getShowVideoUrl).mockReturnValue('/live/ch-1');
+
+ vi.mocked(RecordingDetailsModalUtils.getRating).mockReturnValue(null);
+ vi.mocked(RecordingDetailsModalUtils.getStatRows).mockReturnValue([]);
+ vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue([]);
+ vi.mocked(RecordingDetailsModalUtils.getChannel).mockResolvedValue(null);
+};
+
+const defaultProps = () => ({
+ opened: true,
+ onClose: vi.fn(),
+ recording: makeRecording(),
+ channel: makeChannel(),
+ posterUrl: '/poster.jpg',
+ onWatchLive: vi.fn(),
+ onWatchRecording: vi.fn(),
+ env_mode: 'production',
+ onEdit: vi.fn(),
+});
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+describe('RecordingDetailsModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupMocks();
+ vi.mocked(RecordingDetailsModalUtils.updateRecordingMetadata).mockResolvedValue(undefined);
+ vi.mocked(RecordingDetailsModalUtils.refreshArtwork).mockResolvedValue(undefined);
+ vi.mocked(RecordingCardUtils.runComSkip).mockResolvedValue(undefined);
+ vi.mocked(RecordingCardUtils.deleteRecordingById).mockResolvedValue(undefined);
+ });
+
+ // ── Visibility ───────────────────────────────────────────────────────────────
+
+ describe('visibility', () => {
+ it('renders the modal when opened is true', () => {
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render when opened is false', () => {
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('returns null when recording is not provided', () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('calls onClose when modal close button is clicked', () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Content rendering ────────────────────────────────────────────────────────
+
+ describe('content rendering', () => {
+ it('renders the recording title', () => {
+ render( );
+ expect(screen.getByText(/Test Show/i)).toBeInTheDocument();
+ });
+
+ it('renders "Custom Recording" when no program title', () => {
+ const recording = makeRecording({
+ custom_properties: { status: 'completed', program: {} },
+ });
+ setupMocks({ recording });
+ render(
+
+ );
+ expect(screen.getByText('Custom Recording')).toBeInTheDocument();
+ });
+
+ it('renders the description', () => {
+ render( );
+ expect(screen.getByText('A test description')).toBeInTheDocument();
+ });
+
+ it('renders the poster image', () => {
+ render( );
+ expect(screen.getByAltText('Test Show')).toHaveAttribute('src', '/poster.jpg');
+ });
+
+ it('renders the season/episode label when present', () => {
+ const seriesRecording = makeRecording({ _group_count: 2 });
+ const mockEpisode = makeRecording({ id: 'ep-1', _group_count: 1 });
+
+ vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue([mockEpisode]);
+ vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('S01E02');
+
+ render(
+
+ );
+ expect(screen.getByText('S01E02')).toBeInTheDocument();
+ });
+
+ it('renders rating when present', () => {
+ vi.mocked(RecordingDetailsModalUtils.getRating).mockReturnValue('TV-MA');
+ render( );
+ expect(screen.getByText('TV-MA')).toBeInTheDocument();
+ });
+
+ it('renders stat rows when available', () => {
+ vi.mocked(RecordingDetailsModalUtils.getStatRows).mockReturnValue([
+ ['Video Codec', 'H264'],
+ ['Audio Codec', 'AAC'],
+ ]);
+ render( );
+ expect(screen.getByText('Stream Stats')).toBeInTheDocument();
+ expect(screen.getByText('Video Codec')).toBeInTheDocument();
+ expect(screen.getByText('H264')).toBeInTheDocument();
+ });
+ });
+
+ // ── Watch buttons ────────────────────────────────────────────────────────────
+
+ describe('Watch button', () => {
+ it('renders Watch button for completed recording', () => {
+ render( );
+ expect(screen.getByText('Watch')).toBeInTheDocument();
+ });
+
+ it('calls onWatchRecording when Watch is clicked', () => {
+ const onWatchRecording = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Watch'));
+ expect(onWatchRecording).toHaveBeenCalled();
+ });
+
+ it('Watch button is disabled when no file_url', () => {
+ const recording = makeRecording({
+ custom_properties: {
+ status: 'completed',
+ program: { title: 'Test Show' },
+ },
+ });
+ setupMocks({ recording });
+ render(
+
+ );
+ expect(screen.getByText('Watch')).toBeDisabled();
+ });
+ });
+
+ describe('Watch Live button', () => {
+ it('renders Watch Live button for in-progress recording', () => {
+ const recording = makeRecording({
+ start_time: PAST,
+ end_time: FUTURE,
+ custom_properties: {
+ status: 'recording',
+ file_url: '/f.ts',
+ program: { title: 'Live Show' },
+ },
+ });
+ setupMocks({ recording });
+ render(
+
+ );
+ expect(screen.getByText('Watch Live')).toBeInTheDocument();
+ });
+
+ it('calls onWatchLive when Watch Live is clicked', () => {
+ const recording = makeRecording({
+ start_time: PAST,
+ end_time: FUTURE,
+ custom_properties: {
+ status: 'recording',
+ file_url: '/f.ts',
+ program: { title: 'Live Show' },
+ },
+ });
+ setupMocks({ recording });
+ const onWatchLive = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Watch Live'));
+ expect(onWatchLive).toHaveBeenCalled();
+ });
+ });
+
+ // ── Edit button ──────────────────────────────────────────────────────────────
+
+ describe('Edit button', () => {
+ it('renders the Edit button', () => {
+ const futureRecording = makeRecording({ start_time: FUTURE, end_time: FUTURE });
+ setupMocks({ recording: futureRecording });
+ render(
+
+ );
+ expect(screen.getByText('Edit')).toBeInTheDocument();
+ });
+
+ it('calls onEdit when Edit is clicked', () => {
+ const futureRecording = makeRecording({ start_time: FUTURE, end_time: FUTURE });
+ setupMocks({ recording: futureRecording });
+ const onEdit = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Edit'));
+ expect(onEdit).toHaveBeenCalledWith(futureRecording);
+ });
+ });
+
+ // ── Inline editing ───────────────────────────────────────────────────────────
+
+ describe('inline metadata editing', () => {
+ it('shows edit inputs when pencil icon is clicked', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ expect(screen.getByTestId('textinput-Recording title')).toBeInTheDocument();
+ });
+
+ it('pre-fills title input with current title', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ expect(screen.getByTestId('textinput-Recording title')).toHaveValue('Test Show');
+ });
+
+ it('pre-fills description input with current description', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ expect(screen.getByTestId('textarea-Description (optional)')).toHaveValue('A test description');
+ });
+
+ it('cancels editing when X icon is clicked', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ expect(screen.getByTestId('textinput-Recording title')).toBeInTheDocument();
+ fireEvent.click(screen.getByTestId('icon-x').closest('button'));
+ expect(screen.queryByTestId('textinput-Recording title')).not.toBeInTheDocument();
+ });
+
+ it('calls updateRecordingMetadata with new title and description on save', async () => {
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+
+ fireEvent.change(screen.getByTestId('textinput-Recording title'), {
+ target: { value: 'New Title' },
+ });
+ fireEvent.change(screen.getByTestId('textarea-Description (optional)'), {
+ target: { value: 'New Description' },
+ });
+ fireEvent.click(screen.getByTestId('icon-check').closest('button'));
+
+ await waitFor(() => {
+ expect(
+ RecordingDetailsModalUtils.updateRecordingMetadata
+ ).toHaveBeenCalledWith(makeRecording(), 'New Title', 'New Description');
+ });
+ });
+
+ it('shows saved title optimistically after save', async () => {
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ fireEvent.change(screen.getByTestId('textinput-Recording title'), {
+ target: { value: 'Updated Title' },
+ });
+ fireEvent.click(screen.getByTestId('icon-check').closest('button'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Updated Title')).toBeInTheDocument();
+ });
+ });
+
+ it('shows success notification after save', async () => {
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ fireEvent.click(screen.getByTestId('icon-check').closest('button'));
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ color: 'green' })
+ );
+ });
+ });
+
+ it('does not show notification when updateRecordingMetadata throws', async () => {
+ vi.mocked(
+ RecordingDetailsModalUtils.updateRecordingMetadata
+ ).mockRejectedValue(new Error('fail'));
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ fireEvent.click(screen.getByTestId('icon-check').closest('button'));
+
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+
+ it('uses "Custom Recording" when title is cleared on save', async () => {
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ fireEvent.change(screen.getByTestId('textinput-Recording title'), {
+ target: { value: '' },
+ });
+ fireEvent.click(screen.getByTestId('icon-check').closest('button'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Custom Recording')).toBeInTheDocument();
+ });
+ });
+ });
+
+ // ── Refresh artwork ──────────────────────────────────────────────────────────
+
+ describe('refresh artwork', () => {
+ it('calls refreshArtwork with recording id on click', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Refresh artwork'));
+
+ await waitFor(() => {
+ expect(RecordingDetailsModalUtils.refreshArtwork).toHaveBeenCalledWith(
+ 'rec-1'
+ );
+ });
+ });
+
+ it('shows success notification after refreshArtwork', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Refresh artwork'));
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ color: 'blue.5' })
+ );
+ });
+ });
+
+ it('does not show notification when refreshArtwork throws', async () => {
+ vi.mocked(RecordingDetailsModalUtils.refreshArtwork).mockRejectedValue(
+ new Error('fail')
+ );
+ render( );
+ fireEvent.click(screen.getByText('Refresh artwork'));
+
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── ComSkip ──────────────────────────────────────────────────────────────────
+
+ describe('run comskip', () => {
+ it('calls runComSkip with the recording', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Remove commercials'));
+
+ await waitFor(() => {
+ expect(RecordingCardUtils.runComSkip).toHaveBeenCalledWith(
+ makeRecording()
+ );
+ });
+ });
+
+ it('shows success notification after runComSkip', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Remove commercials'));
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ color: expect.any(String) })
+ );
+ });
+ });
+
+ it('does not show notification when runComSkip throws', async () => {
+ vi.mocked(RecordingCardUtils.runComSkip).mockRejectedValue(
+ new Error('fail')
+ );
+ render( );
+ fireEvent.click(screen.getByText('Remove commercials'));
+
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+
+ it('does not show Remove commercials when comskip is completed', () => {
+ const recording = makeRecording({
+ custom_properties: {
+ status: 'completed',
+ file_url: '/f.ts',
+ comskip: { status: 'completed' },
+ program: { title: 'Test Show' },
+ },
+ });
+ setupMocks({ recording });
+ render(
+
+ );
+ expect(screen.queryByText('Remove commercials')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Series / episodes ────────────────────────────────────────────────────────
+
+ describe('series group', () => {
+ const makeSeriesRecording = () =>
+ makeRecording({
+ _group_count: 3,
+ custom_properties: {
+ status: 'scheduled',
+ program: { title: 'Series Show', tvg_id: 'series-1' },
+ },
+ start_time: FUTURE,
+ end_time: FUTURE,
+ });
+
+ const makeEpisode = (id = 'ep-1') => ({
+ id,
+ channel: 'ch-1',
+ start_time: FUTURE,
+ end_time: FUTURE,
+ custom_properties: {
+ status: 'scheduled',
+ program: { title: 'Episode', sub_title: `Sub ${id}` },
+ },
+ });
+
+ it('renders upcoming episodes list when isSeriesGroup is true', () => {
+ const recording = makeSeriesRecording();
+ const episode = makeEpisode();
+ setupMocks({ recording });
+ vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
+ [episode]
+ );
+ render(
+
+ );
+ expect(screen.getByText('Sub ep-1')).toBeInTheDocument();
+ });
+
+ it('shows "No upcoming episodes" when episode list is empty for series', () => {
+ const recording = makeSeriesRecording();
+ setupMocks({ recording });
+ vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
+ []
+ );
+ render(
+
+ );
+ expect(screen.getByText(/no upcoming episodes/i)).toBeInTheDocument();
+ });
+
+ it('opens child modal when an episode card is clicked', async () => {
+ const recording = makeSeriesRecording();
+ const episode = makeEpisode();
+ setupMocks({ recording });
+ vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
+ [episode]
+ );
+ render(
+
+ );
+ const episodeCards = screen.getAllByTestId('card');
+ fireEvent.click(episodeCards[episodeCards.length - 1]);
+ await waitFor(() => {
+ expect(screen.getAllByTestId('modal').length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ it('calls deleteRecordingById when episode remove is clicked', async () => {
+ const recording = makeSeriesRecording();
+ const episode = makeEpisode();
+ setupMocks({ recording });
+ vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
+ [episode]
+ );
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Remove'));
+ await waitFor(() => {
+ expect(RecordingCardUtils.deleteRecordingById).toHaveBeenCalledWith(
+ 'ep-1'
+ );
+ });
+ });
+ });
+
+ // ── safeRecording merging ────────────────────────────────────────────────────
+
+ describe('safeRecording store merge', () => {
+ it('picks updated recording from store while preserving _group_count', () => {
+ const original = makeRecording({ _group_count: 3 });
+ const storeVersion = {
+ ...makeRecording(),
+ id: 'rec-1',
+ custom_properties: {
+ ...makeRecording().custom_properties,
+ program: { title: 'Updated Title' },
+ },
+ };
+ vi.mocked(useChannelsStore).mockImplementation((sel) =>
+ sel({ recordings: [storeVersion] })
+ );
+ render(
+
+ );
+ expect(screen.getByText(/Updated Title/i)).toBeInTheDocument();
+ });
+ });
+
+ // ── Optimistic state reset ───────────────────────────────────────────────────
+
+ describe('optimistic state reset on recording change', () => {
+ it('resets saved title when recording id changes', async () => {
+ const { rerender } = render(
+
+ );
+ // Save a new title
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ fireEvent.change(screen.getByTestId('textinput-Recording title'), {
+ target: { value: 'Custom' },
+ });
+ fireEvent.click(screen.getByTestId('icon-check').closest('button'));
+ await waitFor(() => expect(screen.getByText('Custom')).toBeInTheDocument());
+
+ // Switch to a different recording
+ const newRecording = makeRecording({ id: 'rec-2' });
+ setupMocks({ recording: newRecording });
+ rerender(
+
+ );
+ expect(screen.getByText(/Test Show/i)).toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/__tests__/RecurringRuleModal.test.jsx b/frontend/src/components/forms/__tests__/RecurringRuleModal.test.jsx
new file mode 100644
index 00000000..6bfc8927
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/RecurringRuleModal.test.jsx
@@ -0,0 +1,937 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/dateTimeUtils.js', () => ({
+ format: vi.fn((moment, fmt) => `formatted-${fmt}`),
+ getNow: vi.fn(() => '2024-06-01T12:00:00Z'),
+ RECURRING_DAY_OPTIONS: [
+ { value: 'mon', label: 'Monday' },
+ { value: 'tue', label: 'Tuesday' },
+ { value: 'wed', label: 'Wednesday' },
+ ],
+ toDate: vi.fn((val) => new Date(val)),
+ toTimeString: vi.fn((val) => val),
+ useDateTimeFormat: vi.fn(),
+ useTimeHelpers: vi.fn(),
+}));
+
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
+ deleteRecordingById: vi.fn(),
+}));
+
+vi.mock('../../../utils/forms/RecurringRuleModalUtils.js', () => ({
+ deleteRecurringRuleById: vi.fn(),
+ getFormDefaults: vi.fn(),
+ getUpcomingOccurrences: vi.fn(),
+ updateRecurringRule: vi.fn(),
+ updateRecurringRuleEnabled: vi.fn(),
+}));
+
+vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
+ getChannelsSummary: vi.fn(),
+ getRecurringFormDefaults: vi.fn(),
+ recurringFormValidators: {},
+ sortedChannelOptions: vi.fn(() => [{ value: 'ch-1', label: 'HBO' }]),
+}));
+
+// ── @mantine/form ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/form', async () => {
+ const React = await import('react');
+ return {
+ useForm: vi.fn(({ initialValues }) => {
+ const [values, setValuesState] = React.useState({ ...initialValues });
+ return {
+ values,
+ key: vi.fn((k) => k),
+ getInputProps: vi.fn((k) => ({
+ name: k,
+ value: values[k] ?? '',
+ onChange: vi.fn((e) => {
+ const v = e?.currentTarget?.value ?? e?.target?.value ?? e;
+ setValuesState((prev) => ({ ...prev, [k]: v }));
+ }),
+ })),
+ onSubmit: vi.fn(
+ (handler) => (e) => {
+ e?.preventDefault?.();
+ return handler(values);
+ }
+ ),
+ reset: vi.fn(() => setValuesState({ ...initialValues })),
+ setValues: vi.fn((newVals) =>
+ setValuesState((prev) => ({ ...prev, ...newVals }))
+ ),
+ setFieldValue: vi.fn((k, v) =>
+ setValuesState((prev) => ({ ...prev, [k]: v }))
+ ),
+ };
+ }),
+ };
+});
+
+// ── @mantine/core ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Badge: ({ children, color }) => (
+
+ {children}
+
+ ),
+ Button: ({ children, onClick, loading, type, variant, color, size, disabled }) => (
+
+ {children}
+
+ ),
+ Card: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ ×
+
+ {children}
+
+ ) : null,
+ MultiSelect: ({ label, data, ...props }) => (
+
+ {label}
+
+ {(data ?? []).map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ ),
+ Select: ({ label, data, ...props }) => (
+
+ {label}
+
+ {(data ?? []).map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ ),
+ Stack: ({ children }) => {children}
,
+ Switch: ({ checked, onChange, label, disabled }) => (
+
+ {
+ // Mirror the event shape the component expects: event.currentTarget.checked
+ onChange({ currentTarget: { checked: e.target.checked } });
+ }}
+ />
+ {label}
+
+ ),
+ Text: ({ children, size, c, fw }) => (
+
+ {children}
+
+ ),
+ TextInput: ({ label, placeholder, ...props }) => (
+
+ {label}
+
+
+ ),
+}));
+
+// ── @mantine/dates ─────────────────────────────────────────────────────────────
+vi.mock('@mantine/dates', () => ({
+ DatePickerInput: ({ label, value, onChange }) => (
+
+ {label}
+ onChange(e.target.value ? new Date(e.target.value) : null)}
+ />
+
+ ),
+ TimeInput: ({ label, value, onChange }) => (
+
+ {label}
+ onChange(e.target.value)}
+ />
+
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import useChannelsStore from '../../../store/channels.jsx';
+import {
+ format,
+ toTimeString,
+ useDateTimeFormat,
+ useTimeHelpers,
+} from '../../../utils/dateTimeUtils.js';
+import { showNotification } from '../../../utils/notificationUtils.js';
+import { deleteRecordingById } from '../../../utils/cards/RecordingCardUtils.js';
+import * as RecurringRuleModalUtils from '../../../utils/forms/RecurringRuleModalUtils.js';
+import * as RecordingUtils from '../../../utils/forms/RecordingUtils.js';
+import { useForm } from '@mantine/form';
+import RecurringRuleModal from '../RecurringRuleModal';
+import dayjs from 'dayjs';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const FUTURE = '2099-01-01T10:00:00Z';
+const NOW = '2024-06-01T12:00:00Z';
+
+const makeMoment = (isoString) => {
+ const d = dayjs(isoString);
+ return {
+ isAfter: (other) => d.isAfter(other?._d ?? other),
+ isBefore: (other) => d.isBefore(other?._d ?? other),
+ format: vi.fn((fmt) => `formatted-${fmt}`),
+ _d: d.toDate(),
+ };
+};
+
+const makeRule = (overrides = {}) => ({
+ id: 'rule-1',
+ name: 'Morning News',
+ channel: 'ch-1',
+ channel_id: 'ch-1',
+ days_of_week: ['mon', 'tue'],
+ start_date: new Date('2024-01-01'),
+ end_date: null,
+ start_time: '08:00',
+ end_time: '09:00',
+ enabled: true,
+ ...overrides,
+});
+
+const makeOccurrence = (id = 'occ-1') => ({
+ id,
+ start_time: FUTURE,
+ end_time: FUTURE,
+ custom_properties: { program: { title: 'Episode' } },
+});
+
+const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 });
+
+const makeRecording = (overrides = {}) => ({
+ id: 'rec-1',
+ start_time: FUTURE,
+ end_time: FUTURE,
+ custom_properties: { program: { title: 'Morning News' } },
+ ...overrides,
+});
+
+const setupMocks = ({ rule = makeRule(), occurrences = [] } = {}) => {
+ const nowMoment = makeMoment(NOW);
+ const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined);
+
+ vi.mocked(useChannelsStore).mockImplementation((sel) =>
+ sel({
+ recurringRules: rule ? [rule] : [],
+ fetchRecurringRules: mockFetchRecurringRules,
+ recordings: [],
+ })
+ );
+
+ vi.mocked(useTimeHelpers).mockReturnValue({
+ toUserTime: (iso) => makeMoment(iso),
+ userNow: () => nowMoment,
+ });
+
+ vi.mocked(useDateTimeFormat).mockReturnValue({
+ timeFormat: 'HH:mm',
+ dateFormat: 'MM/DD',
+ });
+
+ vi.mocked(format).mockImplementation((moment, fmt) => `formatted-${fmt}`);
+
+ vi.mocked(RecordingUtils.getRecurringFormDefaults).mockReturnValue({
+ channel_id: '',
+ rule_name: '',
+ days_of_week: [],
+ start_date: new Date('2024-01-01'),
+ end_date: null,
+ start_time: '08:00',
+ end_time: '09:00',
+ enabled: rule?.enabled ?? true,
+ });
+
+ vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([makeChannel()]);
+
+ vi.mocked(RecurringRuleModalUtils.getFormDefaults).mockReturnValue({
+ channel_id: rule?.channel_id ?? '',
+ rule_name: rule?.name ?? '',
+ days_of_week: rule?.days_of_week ?? [],
+ start_date: rule?.start_date ?? new Date('2024-01-01'),
+ end_date: rule?.end_date ?? null,
+ start_time: rule?.start_time ?? '08:00',
+ end_time: rule?.end_time ?? '09:00',
+ enabled: rule?.enabled ?? true,
+ });
+
+ vi.mocked(RecurringRuleModalUtils.getUpcomingOccurrences).mockReturnValue(occurrences);
+ vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockResolvedValue(undefined);
+ vi.mocked(RecurringRuleModalUtils.updateRecurringRuleEnabled).mockResolvedValue(undefined);
+ vi.mocked(RecurringRuleModalUtils.deleteRecurringRuleById).mockResolvedValue(undefined);
+ vi.mocked(deleteRecordingById).mockResolvedValue(undefined);
+
+ return { mockFetchRecurringRules };
+};
+
+const defaultProps = (overrides = {}) => ({
+ opened: true,
+ onClose: vi.fn(),
+ ruleId: 'rule-1',
+ recording: null,
+ onEditOccurrence: vi.fn(),
+ ...overrides,
+});
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+describe('RecurringRuleModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupMocks();
+ });
+
+ // ── Visibility ───────────────────────────────────────────────────────────────
+
+ describe('visibility', () => {
+ it('renders the modal when opened is true', () => {
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render when opened is false', () => {
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('uses rule name as modal title', () => {
+ render( );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Morning News');
+ });
+
+ it('falls back to "Recurring Rule" when rule has no name', () => {
+ setupMocks({ rule: makeRule({ name: '' }) });
+ render( );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Recurring Rule');
+ });
+
+ it('calls onClose when modal close button is clicked', () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Missing rule fallback ────────────────────────────────────────────────────
+
+ describe('missing rule fallback', () => {
+ beforeEach(() => {
+ setupMocks({ rule: null });
+ });
+
+ it('renders fallback message when rule does not exist', () => {
+ render( );
+ expect(
+ screen.getByText('The recurring rule for this recording no longer exists.')
+ ).toBeInTheDocument();
+ });
+
+ it('shows Delete Recording button when sourceRecording is provided', () => {
+ render(
+
+ );
+ expect(screen.getByText('Delete Recording')).toBeInTheDocument();
+ });
+
+ it('does not show Delete Recording button when sourceRecording is null', () => {
+ render( );
+ expect(screen.queryByText('Delete Recording')).not.toBeInTheDocument();
+ });
+
+ it('calls deleteRecordingById when Delete Recording is confirmed', async () => {
+ const onClose = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Delete Recording'));
+ await waitFor(() => {
+ expect(deleteRecordingById).toHaveBeenCalledWith('rec-1');
+ });
+ });
+
+ it('shows "Recording deleted" notification after deleting orphaned recording', async () => {
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Delete Recording'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Recording deleted', color: 'green' })
+ );
+ });
+ });
+
+ it('calls onClose after deleting orphaned recording', async () => {
+ const onClose = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Delete Recording'));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClose when Cancel is clicked in fallback', () => {
+ const onClose = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('does not show notification when deleteRecordingById throws', async () => {
+ vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Delete Recording'));
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Form fields ──────────────────────────────────────────────────────────────
+
+ describe('form fields', () => {
+ it('renders the Channel select', () => {
+ render( );
+ expect(screen.getByTestId('select-Channel')).toBeInTheDocument();
+ });
+
+ it('renders the Rule name input', () => {
+ render( );
+ expect(screen.getByTestId('textinput-Rule name')).toBeInTheDocument();
+ });
+
+ it('renders the Every (days of week) multiselect', () => {
+ render( );
+ expect(screen.getByTestId('multiselect-Every')).toBeInTheDocument();
+ });
+
+ it('renders Start date and End date pickers', () => {
+ render( );
+ expect(screen.getByTestId('datepicker-Start date')).toBeInTheDocument();
+ expect(screen.getByTestId('datepicker-End date')).toBeInTheDocument();
+ });
+
+ it('renders Start time and End time inputs', () => {
+ render( );
+ expect(screen.getByTestId('timeinput-Start time')).toBeInTheDocument();
+ expect(screen.getByTestId('timeinput-End time')).toBeInTheDocument();
+ });
+
+ it('renders the enabled/paused Switch', () => {
+ render( );
+ expect(screen.getByTestId('switch')).toBeInTheDocument();
+ });
+
+ it('switch reflects rule enabled state', () => {
+ render( );
+ expect(screen.getByTestId('switch')).toBeChecked();
+ });
+
+ it('switch is unchecked when rule is paused', () => {
+ setupMocks({ rule: makeRule({ enabled: false }) });
+ render( );
+ expect(screen.getByTestId('switch')).not.toBeChecked();
+ });
+
+ it('renders channel name in header', () => {
+ render( );
+ expect(screen.getByText('HBO')).toBeInTheDocument();
+ });
+
+ it('renders channel number fallback when channel not in allChannels', async () => {
+ vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([]);
+ render( );
+ await waitFor(() => {
+ expect(screen.getByText('Channel ch-1')).toBeInTheDocument();
+ });
+ });
+ });
+
+ // ── Channel loading ──────────────────────────────────────────────────────────
+
+ describe('channel loading', () => {
+ it('calls getChannelsSummary when opened', async () => {
+ render( );
+ await waitFor(() => {
+ expect(RecordingUtils.getChannelsSummary).toHaveBeenCalled();
+ });
+ });
+
+ it('does not call getChannelsSummary when closed', () => {
+ render( );
+ expect(RecordingUtils.getChannelsSummary).not.toHaveBeenCalled();
+ });
+
+ it('calls sortedChannelOptions with loaded channels', async () => {
+ render( );
+ await waitFor(() => {
+ expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith([
+ makeChannel(),
+ ]);
+ });
+ });
+
+ it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => {
+ vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(
+ new Error('fail')
+ );
+ render( );
+ await waitFor(() => {
+ expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith([]);
+ });
+ });
+ });
+
+ // ── Form initialization from rule ─────────────────────────────────────────────
+
+ describe('form initialization', () => {
+ it('calls getFormDefaults with rule when opened', () => {
+ render( );
+ expect(RecurringRuleModalUtils.getFormDefaults).toHaveBeenCalledWith(
+ makeRule()
+ );
+ });
+
+ it('calls form.setValues with getFormDefaults result', () => {
+ const formMock = vi.mocked(useForm).mock.results[0]?.value;
+ render( );
+ expect(formMock?.setValues ?? vi.fn()).toBeDefined();
+ });
+ });
+
+ // ── Save changes ─────────────────────────────────────────────────────────────
+
+ describe('save changes', () => {
+ it('calls updateRecurringRule on form submit', async () => {
+ render( );
+ fireEvent.submit(screen.getByText('Save changes').closest('form'));
+ await waitFor(() => {
+ expect(RecurringRuleModalUtils.updateRecurringRule).toHaveBeenCalledWith(
+ 'rule-1',
+ expect.any(Object)
+ );
+ });
+ });
+
+ it('calls fetchRecurringRules after save', async () => {
+ const { mockFetchRecurringRules } = setupMocks();
+ render( );
+ fireEvent.submit(screen.getByText('Save changes').closest('form'));
+ await waitFor(() => {
+ expect(mockFetchRecurringRules).toHaveBeenCalled();
+ });
+ });
+
+ it('shows "Recurring rule updated" notification on success', async () => {
+ render( );
+ fireEvent.submit(screen.getByText('Save changes').closest('form'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Recurring rule updated',
+ color: 'green',
+ })
+ );
+ });
+ });
+
+ it('calls onClose after successful save', async () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.submit(screen.getByText('Save changes').closest('form'));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('does not show notification when updateRecurringRule throws', async () => {
+ vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockRejectedValue(
+ new Error('fail')
+ );
+ render( );
+ fireEvent.submit(screen.getByText('Save changes').closest('form'));
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+
+ it('Save changes button shows loading state while saving', async () => {
+ let resolve;
+ vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockReturnValue(
+ new Promise((r) => { resolve = r; })
+ );
+ render( );
+ fireEvent.submit(screen.getByText('Save changes').closest('form'));
+ await waitFor(() => {
+ expect(screen.getByText('Save changes')).toHaveAttribute('data-loading', 'true');
+ });
+ resolve();
+ });
+ });
+
+ // ── Delete rule ──────────────────────────────────────────────────────────────
+
+ describe('delete rule', () => {
+ it('renders the Delete rule button', () => {
+ render( );
+ expect(screen.getByText('Delete rule')).toBeInTheDocument();
+ });
+
+ it('calls deleteRecurringRuleById when Delete rule is clicked', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Delete rule'));
+ await waitFor(() => {
+ expect(RecurringRuleModalUtils.deleteRecurringRuleById).toHaveBeenCalledWith(
+ 'rule-1'
+ );
+ });
+ });
+
+ it('calls fetchRecurringRules after delete', async () => {
+ const { mockFetchRecurringRules } = setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Delete rule'));
+ await waitFor(() => {
+ expect(mockFetchRecurringRules).toHaveBeenCalled();
+ });
+ });
+
+ it('shows "Recurring rule removed" notification on success', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Delete rule'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Recurring rule removed',
+ color: 'red',
+ })
+ );
+ });
+ });
+
+ it('calls onClose after successful delete', async () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText('Delete rule'));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('does not show notification when deleteRecurringRuleById throws', async () => {
+ vi.mocked(RecurringRuleModalUtils.deleteRecurringRuleById).mockRejectedValue(
+ new Error('fail')
+ );
+ render( );
+ fireEvent.click(screen.getByText('Delete rule'));
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Toggle enabled ───────────────────────────────────────────────────────────
+
+ describe('toggle enabled', () => {
+ it('calls updateRecurringRuleEnabled with true when switch is toggled on', async () => {
+ setupMocks({ rule: makeRule({ enabled: false }) });
+ render( );
+ const sw = screen.getByTestId('switch');
+ expect(sw).not.toBeChecked();
+ fireEvent.click(sw);
+ await waitFor(() => {
+ expect(
+ RecurringRuleModalUtils.updateRecurringRuleEnabled
+ ).toHaveBeenCalledWith('rule-1', true);
+ });
+ });
+
+ it('calls updateRecurringRuleEnabled with false when switch is toggled off', async () => {
+ render( );
+ const sw = screen.getByTestId('switch');
+ expect(sw).toBeChecked();
+ fireEvent.click(sw);
+ await waitFor(() => {
+ expect(
+ RecurringRuleModalUtils.updateRecurringRuleEnabled
+ ).toHaveBeenCalledWith('rule-1', false);
+ });
+ });
+
+ it('shows "Recurring rule enabled" notification when enabling', async () => {
+ setupMocks({ rule: makeRule({ enabled: false }) });
+ render( );
+ fireEvent.click(screen.getByTestId('switch'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Recurring rule enabled',
+ color: 'green',
+ })
+ );
+ });
+ });
+
+ it('shows "Recurring rule paused" notification when disabling', async () => {
+ render( );
+ fireEvent.click(screen.getByTestId('switch'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Recurring rule paused',
+ color: 'yellow',
+ })
+ );
+ });
+ });
+
+ it('calls fetchRecurringRules after toggle', async () => {
+ const { mockFetchRecurringRules } = setupMocks();
+ render( );
+ fireEvent.click(screen.getByTestId('switch'));
+ await waitFor(() => {
+ expect(mockFetchRecurringRules).toHaveBeenCalled();
+ });
+ });
+
+ it('does not show notification when updateRecurringRuleEnabled throws', async () => {
+ vi.mocked(
+ RecurringRuleModalUtils.updateRecurringRuleEnabled
+ ).mockRejectedValue(new Error('fail'));
+ render( );
+ fireEvent.click(screen.getByTestId('switch'));
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Upcoming occurrences ─────────────────────────────────────────────────────
+
+ describe('upcoming occurrences', () => {
+ it('renders the occurrence count badge', () => {
+ setupMocks({ occurrences: [makeOccurrence(), makeOccurrence('occ-2')] });
+ render( );
+ expect(screen.getByTestId('badge')).toHaveTextContent('2');
+ });
+
+ it('shows "No future airings" when no upcoming occurrences', () => {
+ setupMocks({ occurrences: [] });
+ render( );
+ expect(
+ screen.getByText('No future airings currently scheduled.')
+ ).toBeInTheDocument();
+ });
+
+ it('renders occurrence cards when occurrences exist', () => {
+ setupMocks({
+ occurrences: [makeOccurrence(), makeOccurrence('occ-2')],
+ });
+ render( );
+ expect(screen.getAllByTestId('occurrence-card')).toHaveLength(2);
+ });
+
+ it('renders Edit and Cancel buttons for each occurrence', () => {
+ setupMocks({ occurrences: [makeOccurrence()] });
+ render( );
+ expect(screen.getByText('Edit')).toBeInTheDocument();
+ expect(screen.getByText('Cancel')).toBeInTheDocument();
+ });
+
+ it('calls getUpcomingOccurrences with recordings, userNow, ruleId, toUserTime', () => {
+ render( );
+ expect(RecurringRuleModalUtils.getUpcomingOccurrences).toHaveBeenCalledWith(
+ expect.any(Array),
+ expect.any(Function),
+ 'rule-1',
+ expect.any(Function)
+ );
+ });
+ });
+
+ // ── Edit occurrence ──────────────────────────────────────────────────────────
+
+ describe('edit occurrence', () => {
+ it('calls onClose and onEditOccurrence when Edit is clicked', () => {
+ const occ = makeOccurrence();
+ setupMocks({ occurrences: [occ] });
+ const onClose = vi.fn();
+ const onEditOccurrence = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Edit'));
+ expect(onClose).toHaveBeenCalled();
+ expect(onEditOccurrence).toHaveBeenCalledWith(occ);
+ });
+
+ it('does not throw when onEditOccurrence is not provided', () => {
+ const occ = makeOccurrence();
+ setupMocks({ occurrences: [occ] });
+ render(
+
+ );
+ expect(() => fireEvent.click(screen.getByText('Edit'))).not.toThrow();
+ });
+ });
+
+ // ── Cancel occurrence ────────────────────────────────────────────────────────
+
+ describe('cancel occurrence', () => {
+ it('calls deleteRecordingById when Cancel is clicked', async () => {
+ const occ = makeOccurrence();
+ setupMocks({ occurrences: [occ] });
+ render( );
+ fireEvent.click(screen.getByText('Cancel'));
+ await waitFor(() => {
+ expect(deleteRecordingById).toHaveBeenCalledWith('occ-1');
+ });
+ });
+
+ it('shows "Occurrence cancelled" notification on success', async () => {
+ const occ = makeOccurrence();
+ setupMocks({ occurrences: [occ] });
+ render( );
+ fireEvent.click(screen.getByText('Cancel'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Occurrence cancelled',
+ color: 'yellow',
+ })
+ );
+ });
+ });
+
+ it('does not show notification when deleteRecordingById throws', async () => {
+ vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
+ setupMocks({ occurrences: [makeOccurrence()] });
+ render( );
+ fireEvent.click(screen.getByText('Cancel'));
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+
+ it('shows loading state on Cancel button while deleting occurrence', async () => {
+ let resolve;
+ vi.mocked(deleteRecordingById).mockReturnValue(
+ new Promise((r) => { resolve = r; })
+ );
+ setupMocks({ occurrences: [makeOccurrence()] });
+ render( );
+ fireEvent.click(screen.getByText('Cancel'));
+ await waitFor(() => {
+ expect(screen.getByText('Cancel')).toHaveAttribute('data-loading', 'true');
+ });
+ resolve();
+ });
+ });
+
+ // ── Time/date field handlers ─────────────────────────────────────────────────
+
+ describe('field change handlers', () => {
+ it('calls toTimeString when start time changes', () => {
+ render( );
+ fireEvent.change(screen.getByTestId('timeinput-Start time'), {
+ target: { value: '09:00' },
+ });
+ expect(toTimeString).toHaveBeenCalledWith('09:00');
+ });
+
+ it('calls toTimeString when end time changes', () => {
+ render( );
+ fireEvent.change(screen.getByTestId('timeinput-End time'), {
+ target: { value: '10:00' },
+ });
+ expect(toTimeString).toHaveBeenCalledWith('10:00');
+ });
+
+ it('updates end_date field when end date picker changes', () => {
+ render( );
+ const picker = screen.getByTestId('datepicker-End date');
+ fireEvent.change(picker, { target: { value: '2024-12-31' } });
+ // No throw — handler wired correctly
+ });
+
+ it('updates start_date field when start date picker changes', () => {
+ render( );
+ const picker = screen.getByTestId('datepicker-Start date');
+ fireEvent.change(picker, { target: { value: '2024-07-01' } });
+ // No throw — handler wired correctly
+ });
+ });
+});
+
diff --git a/frontend/src/components/forms/__tests__/ScheduleInput.test.jsx b/frontend/src/components/forms/__tests__/ScheduleInput.test.jsx
new file mode 100644
index 00000000..85dc55d9
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/ScheduleInput.test.jsx
@@ -0,0 +1,373 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import ScheduleInput from '../ScheduleInput';
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ TextInput: ({ label, placeholder, description, value, onChange, error, disabled }) => (
+
+ {typeof label === 'string' ? label : 'Cron Expression'}
+
+ {description && {description} }
+ {error && {error} }
+
+ ),
+ NumberInput: ({ label, description, value, onChange, min, disabled }) => (
+
+ {label}
+ onChange(Number(e.target.value))}
+ min={min}
+ disabled={disabled}
+ />
+ {description && {description} }
+
+ ),
+ Anchor: ({ children, onClick }) => (
+
+ {children}
+
+ ),
+ Stack: ({ children }) => {children}
,
+ Text: ({ children }) => {children} ,
+ Code: ({ children }) => {children},
+ Group: ({ children }) => {children}
,
+ Popover: ({ children }) => {children}
,
+ PopoverTarget: ({ children }) => {children}
,
+ PopoverDropdown: ({ children }) => {children}
,
+ ActionIcon: ({ children, onClick }) => (
+ {children}
+ ),
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ Info: () => ,
+}));
+
+// ── cronUtils ─────────────────────────────────────────────────────────────────
+vi.mock('../../../utils/cronUtils', () => ({
+ validateCronExpression: vi.fn(),
+}));
+
+// ── CronBuilder ────────────────────────────────────────────────────────────────
+vi.mock('../CronBuilder', () => ({
+ default: ({ opened, onClose, onApply, currentValue }) =>
+ opened ? (
+
+ {currentValue}
+ onApply('0 3 * * *')}>
+ Apply
+
+
+ Close
+
+
+ ) : null,
+}));
+
+// ──────────────────────────────────────────────────────────────────────────────
+import { validateCronExpression } from '../../../utils/cronUtils';
+
+const defaultIntervalProps = () => ({
+ scheduleType: 'interval',
+ onScheduleTypeChange: vi.fn(),
+ intervalValue: 6,
+ onIntervalChange: vi.fn(),
+ cronValue: '',
+ onCronChange: vi.fn(),
+});
+
+const defaultCronProps = () => ({
+ scheduleType: 'cron',
+ onScheduleTypeChange: vi.fn(),
+ cronValue: '0 3 * * *',
+ onCronChange: vi.fn(),
+ intervalValue: 0,
+ onIntervalChange: vi.fn(),
+});
+
+describe('ScheduleInput', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
+ });
+
+ // ── Interval mode ────────────────────────────────────────────────────────
+
+ describe('interval mode', () => {
+ it('renders NumberInput in interval mode', () => {
+ render( );
+ expect(screen.getByTestId('numberinput-interval')).toBeInTheDocument();
+ });
+
+ it('displays the intervalValue', () => {
+ render( );
+ expect(screen.getByTestId('numberinput-interval')).toHaveValue(6);
+ });
+
+ it('renders default interval label', () => {
+ render( );
+ expect(screen.getByText('Refresh Interval (hours)')).toBeInTheDocument();
+ });
+
+ it('renders custom intervalLabel', () => {
+ render( );
+ expect(screen.getByText('My Custom Label')).toBeInTheDocument();
+ });
+
+ it('renders default interval description', () => {
+ render( );
+ expect(screen.getByTestId('interval-description')).toHaveTextContent(
+ 'How often to refresh (0 to disable)'
+ );
+ });
+
+ it('calls onIntervalChange when value changes', () => {
+ const props = defaultIntervalProps();
+ render( );
+ fireEvent.change(screen.getByTestId('numberinput-interval'), {
+ target: { value: '12' },
+ });
+ expect(props.onIntervalChange).toHaveBeenCalledWith(12);
+ });
+
+ it('shows "Use cron schedule" link in interval mode', () => {
+ render( );
+ expect(screen.getByTestId('anchor-use-cron-schedule')).toBeInTheDocument();
+ });
+
+ it('calls onScheduleTypeChange("cron") when "Use cron schedule" is clicked', () => {
+ const props = defaultIntervalProps();
+ render( );
+ fireEvent.click(screen.getByTestId('anchor-use-cron-schedule'));
+ expect(props.onScheduleTypeChange).toHaveBeenCalledWith('cron');
+ });
+
+ it('does not show cron switch link when disabled', () => {
+ render( );
+ expect(screen.queryByTestId('anchor-use-cron-schedule')).not.toBeInTheDocument();
+ });
+
+ it('disables the NumberInput when disabled prop is true', () => {
+ render( );
+ expect(screen.getByTestId('numberinput-interval')).toBeDisabled();
+ });
+
+ it('renders custom switchToCronLabel', () => {
+ render(
+
+ );
+ expect(screen.getByText('Use custom cron schedule')).toBeInTheDocument();
+ });
+ });
+
+ // ── Children (simple) mode ────────────────────────────────────────────────
+
+ describe('children (simple) mode', () => {
+ it('renders children instead of NumberInput', () => {
+ render(
+
+ Custom content
+
+ );
+ expect(screen.getByTestId('custom-child')).toBeInTheDocument();
+ expect(screen.queryByTestId('numberinput-interval')).not.toBeInTheDocument();
+ });
+
+ it('shows cron toggle link below children', () => {
+ render(
+
+ Child
+
+ );
+ expect(screen.getByTestId('anchor-use-cron-schedule')).toBeInTheDocument();
+ });
+
+ it('does not show cron link when disabled with children', () => {
+ render(
+
+ Child
+
+ );
+ expect(screen.queryByTestId('anchor-use-cron-schedule')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Cron mode ─────────────────────────────────────────────────────────────
+
+ describe('cron mode', () => {
+ it('renders TextInput in cron mode', () => {
+ render( );
+ expect(screen.getByTestId('textinput-cron')).toBeInTheDocument();
+ });
+
+ it('displays the cronValue', () => {
+ render( );
+ expect(screen.getByTestId('textinput-cron')).toHaveValue('0 3 * * *');
+ });
+
+ it('shows "Use interval schedule" link in cron mode', () => {
+ render( );
+ expect(screen.getByTestId('anchor-use-interval-schedule')).toBeInTheDocument();
+ });
+
+ it('shows "Open Cron Builder" link', () => {
+ render( );
+ expect(screen.getByTestId('anchor-open-cron-builder')).toBeInTheDocument();
+ });
+
+ it('calls onScheduleTypeChange("interval") and onCronChange("") when switching to interval', () => {
+ const props = defaultCronProps();
+ render( );
+ fireEvent.click(screen.getByTestId('anchor-use-interval-schedule'));
+ expect(props.onScheduleTypeChange).toHaveBeenCalledWith('interval');
+ expect(props.onCronChange).toHaveBeenCalledWith('');
+ });
+
+ it('calls onCronChange when cron input changes', () => {
+ const props = defaultCronProps();
+ render( );
+ fireEvent.change(screen.getByTestId('textinput-cron'), {
+ target: { value: '0 5 * * *' },
+ });
+ expect(props.onCronChange).toHaveBeenCalledWith('0 5 * * *');
+ });
+
+ it('disables the TextInput when disabled prop is true', () => {
+ render( );
+ expect(screen.getByTestId('textinput-cron')).toBeDisabled();
+ });
+
+ it('does not show cron links when disabled', () => {
+ render( );
+ expect(screen.queryByTestId('anchor-use-interval-schedule')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('anchor-open-cron-builder')).not.toBeInTheDocument();
+ });
+
+ it('renders custom switchToIntervalLabel', () => {
+ render(
+
+ );
+ expect(screen.getByText('Use simple schedule')).toBeInTheDocument();
+ });
+ });
+
+ // ── Cron validation ───────────────────────────────────────────────────────
+
+ describe('cron validation', () => {
+ it('shows no error when cron is valid', () => {
+ vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
+ render( );
+ expect(screen.queryByTestId('cron-error')).not.toBeInTheDocument();
+ });
+
+ it('shows error message when cron is invalid', async () => {
+ vi.mocked(validateCronExpression).mockReturnValue({
+ valid: false,
+ error: 'Invalid cron expression',
+ });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId('cron-error')).toHaveTextContent('Invalid cron expression');
+ });
+ });
+
+ it('clears error when cron input is cleared', async () => {
+ vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
+ const props = { ...defaultCronProps(), cronValue: '' };
+ render( );
+ await waitFor(() => {
+ expect(screen.queryByTestId('cron-error')).not.toBeInTheDocument();
+ });
+ });
+
+ it('calls validateCronExpression on cron value change', () => {
+ vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
+ const props = defaultCronProps();
+ render( );
+ fireEvent.change(screen.getByTestId('textinput-cron'), {
+ target: { value: '0 6 * * 1' },
+ });
+ expect(validateCronExpression).toHaveBeenCalledWith('0 6 * * 1');
+ });
+
+ it('does not call validateCronExpression in interval mode', () => {
+ render( );
+ expect(validateCronExpression).not.toHaveBeenCalled();
+ });
+ });
+
+ // ── Cron Builder ──────────────────────────────────────────────────────────
+
+ describe('CronBuilder', () => {
+ it('CronBuilder is not visible initially', () => {
+ render( );
+ expect(screen.queryByTestId('cron-builder')).not.toBeInTheDocument();
+ });
+
+ it('opens CronBuilder when "Open Cron Builder" is clicked', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
+ expect(screen.getByTestId('cron-builder')).toBeInTheDocument();
+ });
+
+ it('passes current cronValue to CronBuilder', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
+ expect(screen.getByTestId('cron-builder-value')).toHaveTextContent('0 4 * * *');
+ });
+
+ it('closes CronBuilder when onClose is triggered', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
+ fireEvent.click(screen.getByTestId('cron-builder-close'));
+ expect(screen.queryByTestId('cron-builder')).not.toBeInTheDocument();
+ });
+
+ it('calls onCronChange with applied value from CronBuilder', () => {
+ vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
+ const props = defaultCronProps();
+ render( );
+ fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
+ fireEvent.click(screen.getByTestId('cron-builder-apply'));
+ expect(props.onCronChange).toHaveBeenCalledWith('0 3 * * *');
+ });
+ });
+
+ // ── Default props ─────────────────────────────────────────────────────────
+
+ describe('default props', () => {
+ it('defaults to interval mode when scheduleType is not provided', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('numberinput-interval')).toBeInTheDocument();
+ });
+
+ it('defaults intervalValue to 0', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('numberinput-interval')).toHaveValue(0);
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/__tests__/SeriesRecordingModal.test.jsx b/frontend/src/components/forms/__tests__/SeriesRecordingModal.test.jsx
new file mode 100644
index 00000000..5e7f3c09
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/SeriesRecordingModal.test.jsx
@@ -0,0 +1,417 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import SeriesRecordingModal from '../SeriesRecordingModal';
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ ×
+
+ {children}
+
+ ) : null,
+ Stack: ({ children }) => {children}
,
+ Text: ({ children, size, c, fw }) => (
+
+ {children}
+
+ ),
+ Flex: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ Button: ({ children, onClick, variant, color, disabled }) => (
+
+ {children}
+
+ ),
+ Badge: ({ children, color }) => (
+
+ {children}
+
+ ),
+}));
+
+// ── Store ──────────────────────────────────────────────────────────────────────
+vi.mock('../../../store/channels.jsx', () => ({
+ default: { getState: vi.fn() },
+}));
+
+// ── Utils ──────────────────────────────────────────────────────────────────────
+vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
+ deleteSeriesAndRule: vi.fn(),
+}));
+
+vi.mock('../../../utils/guideUtils.js', () => ({
+ evaluateSeriesRulesByTvgId: vi.fn(),
+ fetchRules: vi.fn(),
+}));
+
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+// ── SeriesRuleEditorModal ──────────────────────────────────────────────────────
+vi.mock('../SeriesRuleEditorModal', () => ({
+ default: ({ opened, onClose, initialRule, onSaved }) =>
+ opened ? (
+
+ {initialRule ? initialRule.title : 'new'}
+ Close
+ Save
+
+ ) : null,
+}));
+
+// ──────────────────────────────────────────────────────────────────────────────
+import useChannelsStore from '../../../store/channels.jsx';
+import { deleteSeriesAndRule } from '../../../utils/cards/RecordingCardUtils.js';
+import { evaluateSeriesRulesByTvgId, fetchRules } from '../../../utils/guideUtils.js';
+import { showNotification } from '../../../utils/notificationUtils.js';
+
+const makeRule = (overrides = {}) => ({
+ tvg_id: 'tvg-1',
+ title: 'Test Show',
+ mode: 'new',
+ title_mode: 'exact',
+ description: '',
+ channel_id: null,
+ ...overrides,
+});
+
+const defaultProps = (overrides = {}) => ({
+ opened: true,
+ onClose: vi.fn(),
+ rules: [makeRule()],
+ onRulesUpdate: vi.fn(),
+ ...overrides,
+});
+
+const mockFetchRecordings = vi.fn().mockResolvedValue(undefined);
+
+const setupMocks = () => {
+ vi.mocked(useChannelsStore.getState).mockReturnValue({
+ fetchRecordings: mockFetchRecordings,
+ });
+ vi.mocked(fetchRules).mockResolvedValue([makeRule()]);
+ vi.mocked(evaluateSeriesRulesByTvgId).mockResolvedValue(undefined);
+ vi.mocked(deleteSeriesAndRule).mockResolvedValue(undefined);
+};
+
+describe('SeriesRecordingModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupMocks();
+ });
+
+ // ── Visibility ─────────────────────────────────────────────────────────────
+
+ describe('visibility', () => {
+ it('renders when opened is true', () => {
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render when opened is false', () => {
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('renders modal title "Series Recording Rules"', () => {
+ render( );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Series Recording Rules');
+ });
+
+ it('calls onClose when modal close button is clicked', () => {
+ const props = defaultProps();
+ render( );
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(props.onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Empty state ────────────────────────────────────────────────────────────
+
+ describe('empty state', () => {
+ it('shows "No series rules configured" when rules is empty', () => {
+ render( );
+ expect(screen.getByText('No series rules configured')).toBeInTheDocument();
+ });
+
+ it('shows "No series rules configured" when rules is null', () => {
+ render( );
+ expect(screen.getByText('No series rules configured')).toBeInTheDocument();
+ });
+
+ it('does not show "No series rules configured" when rules are present', () => {
+ render( );
+ expect(screen.queryByText('No series rules configured')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Rule rendering ─────────────────────────────────────────────────────────
+
+ describe('rule rendering', () => {
+ it('renders rule title', () => {
+ render( );
+ expect(screen.getByText('Test Show')).toBeInTheDocument();
+ });
+
+ it('falls back to tvg_id when rule has no title', () => {
+ render(
+
+ );
+ expect(screen.getByText('tvg-fallback')).toBeInTheDocument();
+ });
+
+ it('renders "Pinned channel" badge when channel_id is set', () => {
+ render(
+
+ );
+ expect(screen.getByText('Pinned channel')).toBeInTheDocument();
+ });
+
+ it('does not render "Pinned channel" badge when channel_id is null', () => {
+ render( );
+ expect(screen.queryByText('Pinned channel')).not.toBeInTheDocument();
+ });
+
+ it('renders multiple rules', () => {
+ const rules = [
+ makeRule({ tvg_id: 'tvg-1', title: 'Show One' }),
+ makeRule({ tvg_id: 'tvg-2', title: 'Show Two' }),
+ ];
+ render( );
+ expect(screen.getByText('Show One')).toBeInTheDocument();
+ expect(screen.getByText('Show Two')).toBeInTheDocument();
+ });
+
+ it('renders Edit, Evaluate Now, and Remove buttons for each rule', () => {
+ render( );
+ expect(screen.getByText('Edit')).toBeInTheDocument();
+ expect(screen.getByText('Evaluate Now')).toBeInTheDocument();
+ expect(screen.getByText('Remove')).toBeInTheDocument();
+ });
+
+ it('renders "Add rule" button', () => {
+ render( );
+ expect(screen.getByText('Add rule')).toBeInTheDocument();
+ });
+ });
+
+ // ── Rule summary ───────────────────────────────────────────────────────────
+
+ describe('rule summary', () => {
+ it('shows "New episodes" for mode new', () => {
+ render( );
+ expect(screen.getByText(/New episodes/)).toBeInTheDocument();
+ });
+
+ it('shows "Every episode" for mode all', () => {
+ render( );
+ expect(screen.getByText(/Every episode/)).toBeInTheDocument();
+ });
+
+ it('shows exact title label in summary', () => {
+ render(
+
+ );
+ expect(screen.getByText(/Exact title: "Test Show"/)).toBeInTheDocument();
+ });
+
+ it('shows contains label in summary', () => {
+ render(
+
+ );
+ expect(screen.getByText(/Title contains: "Test"/)).toBeInTheDocument();
+ });
+
+ it('shows regex label in summary', () => {
+ render(
+
+ );
+ expect(screen.getByText(/Title regex: "\^Test"/)).toBeInTheDocument();
+ });
+
+ it('shows description in summary when present', () => {
+ render(
+
+ );
+ expect(screen.getByText(/Description: "A drama"/)).toBeInTheDocument();
+ });
+
+ it('does not show description in summary when empty', () => {
+ render( );
+ expect(screen.queryByText(/Description:/)).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Evaluate Now ───────────────────────────────────────────────────────────
+
+ describe('Evaluate Now', () => {
+ it('calls evaluateSeriesRulesByTvgId with rule tvg_id', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Evaluate Now'));
+ await waitFor(() => {
+ expect(evaluateSeriesRulesByTvgId).toHaveBeenCalledWith('tvg-1');
+ });
+ });
+
+ it('calls fetchRecordings after evaluation', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Evaluate Now'));
+ await waitFor(() => {
+ expect(mockFetchRecordings).toHaveBeenCalled();
+ });
+ });
+
+ it('shows "Evaluated" notification after evaluation', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Evaluate Now'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Evaluated', message: 'Checked for episodes' })
+ );
+ });
+ });
+
+ it('still shows notification when fetchRecordings rejects', async () => {
+ mockFetchRecordings.mockRejectedValueOnce(new Error('network'));
+ render( );
+ fireEvent.click(screen.getByText('Evaluate Now'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Evaluated' })
+ );
+ });
+ });
+ });
+
+ // ── Remove series ──────────────────────────────────────────────────────────
+
+ describe('Remove series', () => {
+ it('calls deleteSeriesAndRule with tvg_id and title', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Remove'));
+ await waitFor(() => {
+ expect(deleteSeriesAndRule).toHaveBeenCalledWith({
+ tvg_id: 'tvg-1',
+ title: 'Test Show',
+ });
+ });
+ });
+
+ it('calls fetchRecordings after removal', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Remove'));
+ await waitFor(() => {
+ expect(mockFetchRecordings).toHaveBeenCalled();
+ });
+ });
+
+ it('calls fetchRules after removal', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Remove'));
+ await waitFor(() => {
+ expect(fetchRules).toHaveBeenCalled();
+ });
+ });
+
+ it('calls onRulesUpdate with fetched rules after removal', async () => {
+ const updated = [makeRule({ title: 'Updated Show' })];
+ vi.mocked(fetchRules).mockResolvedValue(updated);
+ const props = defaultProps();
+ render( );
+ fireEvent.click(screen.getByText('Remove'));
+ await waitFor(() => {
+ expect(props.onRulesUpdate).toHaveBeenCalledWith(updated);
+ });
+ });
+
+ it('still calls onRulesUpdate when fetchRecordings rejects', async () => {
+ mockFetchRecordings.mockRejectedValueOnce(new Error('network'));
+ const props = defaultProps();
+ render( );
+ fireEvent.click(screen.getByText('Remove'));
+ await waitFor(() => {
+ expect(props.onRulesUpdate).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Add rule / Editor ──────────────────────────────────────────────────────
+
+ describe('Add rule', () => {
+ it('opens SeriesRuleEditorModal when "Add rule" is clicked', () => {
+ render( );
+ fireEvent.click(screen.getByText('Add rule'));
+ expect(screen.getByTestId('series-rule-editor')).toBeInTheDocument();
+ });
+
+ it('passes null initialRule when opening via "Add rule"', () => {
+ render( );
+ fireEvent.click(screen.getByText('Add rule'));
+ expect(screen.getByTestId('editor-rule')).toHaveTextContent('new');
+ });
+
+ it('closes SeriesRuleEditorModal when editor onClose fires', () => {
+ render( );
+ fireEvent.click(screen.getByText('Add rule'));
+ fireEvent.click(screen.getByTestId('editor-close'));
+ expect(screen.queryByTestId('series-rule-editor')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Edit rule ──────────────────────────────────────────────────────────────
+
+ describe('Edit rule', () => {
+ it('opens SeriesRuleEditorModal with the selected rule when Edit is clicked', () => {
+ render( );
+ fireEvent.click(screen.getByText('Edit'));
+ expect(screen.getByTestId('series-rule-editor')).toBeInTheDocument();
+ expect(screen.getByTestId('editor-rule')).toHaveTextContent('Test Show');
+ });
+
+ it('calls fetchRules and onRulesUpdate when editor onSaved fires', async () => {
+ const updated = [makeRule({ title: 'Saved Show' })];
+ vi.mocked(fetchRules).mockResolvedValue(updated);
+ const props = defaultProps();
+ render( );
+ fireEvent.click(screen.getByText('Edit'));
+ fireEvent.click(screen.getByTestId('editor-save'));
+ await waitFor(() => {
+ expect(fetchRules).toHaveBeenCalled();
+ expect(props.onRulesUpdate).toHaveBeenCalledWith(updated);
+ });
+ });
+
+ it('closes editor after save', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Edit'));
+ fireEvent.click(screen.getByTestId('editor-save'));
+ await waitFor(() => {
+ // editor is still open (onSaved doesn't auto-close) — just verify no error
+ expect(fetchRules).toHaveBeenCalled();
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/__tests__/SeriesRuleEditorModal.test.jsx b/frontend/src/components/forms/__tests__/SeriesRuleEditorModal.test.jsx
new file mode 100644
index 00000000..59a98bd9
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/SeriesRuleEditorModal.test.jsx
@@ -0,0 +1,682 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+// ── Store mocks ────────────────────────────────────────────────────────────
+vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
+vi.mock('../../../store/epgs.jsx', () => ({ default: vi.fn() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────
+vi.mock('../../../utils.js', () => ({
+ useDebounce: vi.fn((val) => val),
+}));
+
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
+ getChannelsSummary: vi.fn(),
+}));
+
+vi.mock('../../../utils/guideUtils.js', () => ({
+ createSeriesRule: vi.fn(),
+ evaluateSeriesRulesByTvgId: vi.fn(),
+}));
+
+vi.mock('../../../utils/forms/SeriesRuleEditorModalUtils.js', () => ({
+ TITLE_MODES: [
+ { label: 'Exact', value: 'exact' },
+ { label: 'Contains', value: 'contains' },
+ ],
+ DESCRIPTION_MODES: [
+ { label: 'Contains', value: 'contains' },
+ { label: 'Exact', value: 'exact' },
+ ],
+ EPISODE_MODES: [
+ { label: 'All', value: 'all' },
+ { label: 'New only', value: 'new' },
+ ],
+ formatRange: vi.fn((start, end) => `${start} - ${end}`),
+ getChannelOptions: vi.fn(() => [{ value: '10', label: '501 - HBO' }]),
+ getTvgOptions: vi.fn(),
+ previewSeriesRule: vi.fn(),
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Alert: ({ children, color }) => (
+
+ {children}
+
+ ),
+ Badge: ({ children, color }) => (
+
+ {children}
+
+ ),
+ Button: ({ children, onClick, disabled, loading, variant }) => (
+
+ {children}
+
+ ),
+ Divider: () => ,
+ Group: ({ children }) => {children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ ×
+
+ {children}
+
+ ) : null,
+ ScrollAreaAutosize: ({ children }) => {children}
,
+ SegmentedControl: ({ data, value, onChange, size }) => (
+
+ {data.map((item) => (
+ onChange(item.value)}
+ >
+ {item.label}
+
+ ))}
+
+ ),
+ Select: ({
+ label,
+ placeholder,
+ data,
+ value,
+ onChange,
+ description,
+ }) => (
+
+ {label}
+ {description && (
+ {description}
+ )}
+ onChange(e.target.value || null)}
+ >
+ {placeholder}
+ {(data || []).map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ ),
+ Stack: ({ children }) => {children}
,
+ Text: ({ children, size, c, fw }) => (
+
+ {children}
+
+ ),
+ Textarea: ({ label, placeholder, value, onChange, description }) => (
+
+ {label}
+ {description && {description} }
+
+
+ ),
+ TextInput: ({ label, placeholder, value, onChange, description }) => (
+
+ {label}
+ {description && {description} }
+
+
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────
+import SeriesRuleEditorModal from '../SeriesRuleEditorModal';
+import useChannelsStore from '../../../store/channels.jsx';
+import useEPGsStore from '../../../store/epgs.jsx';
+import { useDebounce } from '../../../utils.js';
+import { showNotification } from '../../../utils/notificationUtils.js';
+import { getChannelsSummary } from '../../../utils/forms/RecordingUtils.js';
+import { createSeriesRule, evaluateSeriesRulesByTvgId } from '../../../utils/guideUtils.js';
+import {
+ getTvgOptions,
+ previewSeriesRule,
+ formatRange,
+} from '../../../utils/forms/SeriesRuleEditorModalUtils.js';
+
+// ── Shared test data ───────────────────────────────────────────────────────
+const mockTvgs = [
+ { id: 1, tvg_id: 'tvg-1', name: 'Channel One' },
+ { id: 2, tvg_id: 'tvg-2', name: 'Channel Two' },
+];
+
+const mockTvgsById = {
+ 1: { tvg_id: 'tvg-1', name: 'Channel One' },
+ 2: { tvg_id: 'tvg-2', name: 'Channel Two' },
+};
+
+const mockChannels = [
+ { id: 10, name: 'HBO', channel_number: 501, epg_data_id: 1 },
+ { id: 20, name: 'CNN', channel_number: 102, epg_data_id: 2 },
+];
+
+const mockTvgOptions = [
+ { value: 'tvg-1', label: 'Channel One (tvg-1)' },
+ { value: 'tvg-2', label: 'Channel Two (tvg-2)' },
+];
+
+const mockPreviewResponse = {
+ matches: [
+ {
+ id: 'p1',
+ title: 'Match Show',
+ sub_title: 'Episode 1',
+ description: 'A great show',
+ start_time: '2024-01-01T10:00:00Z',
+ end_time: '2024-01-01T11:00:00Z',
+ tvg_id: 'tvg-1',
+ is_new: true,
+ },
+ ],
+ total: 1,
+ epg_found: true,
+};
+
+// ── Setup helper ───────────────────────────────────────────────────────────
+const setupMocks = () => {
+ const mockFetchRecordings = vi.fn().mockResolvedValue(undefined);
+
+ vi.mocked(useEPGsStore).mockImplementation((selector) =>
+ selector({ tvgs: mockTvgs, tvgsById: mockTvgsById })
+ );
+
+ // Mock getState for the imperative call in handleSave
+ useChannelsStore.getState = vi.fn().mockReturnValue({
+ fetchRecordings: mockFetchRecordings,
+ });
+
+ vi.mocked(getTvgOptions).mockReturnValue(mockTvgOptions);
+ vi.mocked(getChannelsSummary).mockResolvedValue(mockChannels);
+ vi.mocked(previewSeriesRule).mockResolvedValue({ matches: [], total: 0, epg_found: true });
+ vi.mocked(createSeriesRule).mockResolvedValue({ id: 'rule-1' });
+ vi.mocked(evaluateSeriesRulesByTvgId).mockResolvedValue(undefined);
+ vi.mocked(useDebounce).mockImplementation((val) => val);
+ vi.mocked(formatRange).mockImplementation((s, e) => `${s} - ${e}`);
+
+ return { mockFetchRecordings };
+};
+
+// ──────────────────────────────────────────────────────────────────────────
+describe('SeriesRuleEditorModal', () => {
+ let defaultProps;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupMocks();
+
+ defaultProps = {
+ opened: true,
+ onClose: vi.fn(),
+ initialRule: null,
+ onSaved: vi.fn(),
+ };
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ── Rendering ────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the modal when opened is true', () => {
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render the modal when opened is false', () => {
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('shows "New Series Rule" title when initialRule is null', () => {
+ render( );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('New Series Rule');
+ });
+
+ it('shows "Edit Series Rule" title when initialRule is provided', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Edit Series Rule');
+ });
+
+ it('renders the title input', () => {
+ render( );
+ expect(screen.getByTestId('input-title')).toBeInTheDocument();
+ });
+
+ it('renders the description textarea', () => {
+ render( );
+ expect(screen.getByTestId('textarea-description')).toBeInTheDocument();
+ });
+
+ it('renders Cancel and Save rule buttons', () => {
+ render( );
+ expect(screen.getByText('Cancel')).toBeInTheDocument();
+ expect(screen.getByText('Save rule')).toBeInTheDocument();
+ });
+
+ it('Save rule button is disabled when no title or description', () => {
+ render( );
+ expect(screen.getByText('Save rule')).toBeDisabled();
+ });
+
+ it('Save rule button is enabled when title is provided', async () => {
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'My Show' },
+ });
+ expect(screen.getByText('Save rule')).not.toBeDisabled();
+ });
+
+ it('Save rule button is enabled when description is provided', async () => {
+ render( );
+ fireEvent.change(screen.getByTestId('textarea-description'), {
+ target: { value: 'some description' },
+ });
+ expect(screen.getByText('Save rule')).not.toBeDisabled();
+ });
+ });
+
+ // ── State hydration from initialRule ─────────────────────────────────────
+
+ describe('state hydration', () => {
+ const initialRule = {
+ tvg_id: 'tvg-1',
+ mode: 'new',
+ title: 'Pre-filled Title',
+ title_mode: 'contains',
+ description: 'Pre-filled description',
+ description_mode: 'exact',
+ channel_id: 10,
+ };
+
+ it('pre-fills title from initialRule', () => {
+ render( );
+ expect(screen.getByTestId('input-title')).toHaveValue('Pre-filled Title');
+ });
+
+ it('pre-fills description from initialRule', () => {
+ render( );
+ expect(screen.getByTestId('textarea-description')).toHaveValue('Pre-filled description');
+ });
+
+ it('uses default mode "all" when initialRule has no mode', () => {
+ render( );
+ // "All" segmented button should be active (data-active=true)
+ const allBtn = screen.getAllByText('All').find(
+ (el) => el.closest('[data-testid="segmented-control"]')
+ );
+ expect(allBtn).toBeTruthy();
+ });
+
+ it('resets fields when modal is reopened without initialRule', async () => {
+ const { rerender } = render(
+
+ );
+ rerender(
+
+ );
+ rerender(
+
+ );
+ expect(screen.getByTestId('input-title')).toHaveValue('');
+ expect(screen.getByTestId('textarea-description')).toHaveValue('');
+ });
+ });
+
+ // ── Channel loading ───────────────────────────────────────────────────────
+
+ describe('channel loading', () => {
+ it('calls getChannelsSummary when modal opens', async () => {
+ render( );
+ await waitFor(() => {
+ expect(getChannelsSummary).toHaveBeenCalled();
+ });
+ });
+
+ it('does not call getChannelsSummary when modal is closed', () => {
+ render( );
+ expect(getChannelsSummary).not.toHaveBeenCalled();
+ });
+
+ it('handles getChannelsSummary rejection gracefully', async () => {
+ vi.mocked(getChannelsSummary).mockRejectedValue(new Error('Network error'));
+ render( );
+ // Should not throw; channel options just empty
+ await waitFor(() => {
+ expect(getChannelsSummary).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── TVG options ───────────────────────────────────────────────────────────
+
+ describe('EPG channel options', () => {
+ it('calls getTvgOptions with tvgs from store', () => {
+ render( );
+ expect(getTvgOptions).toHaveBeenCalledWith(mockTvgs);
+ });
+ });
+
+ // ── Preview ───────────────────────────────────────────────────────────────
+
+ describe('preview', () => {
+ it('does not call previewSeriesRule when title, description, and tvg_id are all empty', async () => {
+ render( );
+ await waitFor(() => expect(getChannelsSummary).toHaveBeenCalled());
+ expect(previewSeriesRule).not.toHaveBeenCalled();
+ });
+
+ it('calls previewSeriesRule when title is entered', async () => {
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'My Show' },
+ });
+ await waitFor(() => {
+ expect(previewSeriesRule).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'My Show' }),
+ expect.any(Object)
+ );
+ });
+ });
+
+ it('calls previewSeriesRule when description is entered', async () => {
+ render( );
+ fireEvent.change(screen.getByTestId('textarea-description'), {
+ target: { value: 'some words' },
+ });
+ await waitFor(() => {
+ expect(previewSeriesRule).toHaveBeenCalledWith(
+ expect.objectContaining({ description: 'some words' }),
+ expect.any(Object)
+ );
+ });
+ });
+
+ it('renders preview match results', async () => {
+ vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'Match Show' },
+ });
+ await waitFor(() => {
+ expect(screen.getByText(/Match Show/)).toBeInTheDocument();
+ });
+ });
+
+ it('renders preview badge with match count', async () => {
+ vi.mocked(previewSeriesRule).mockResolvedValue({ matches: [{ id: 'p1', title: 'X' }], total: 5, epg_found: true });
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'X' },
+ });
+ await waitFor(() => {
+ expect(screen.getByTestId('badge')).toHaveTextContent('1 of 5');
+ });
+ });
+
+ it('shows preview error alert when previewSeriesRule rejects', async () => {
+ vi.mocked(previewSeriesRule).mockRejectedValue(new Error('Preview failed'));
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'Boom' },
+ });
+ await waitFor(() => {
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
+ expect(screen.getByTestId('alert')).toHaveTextContent('Preview failed');
+ });
+ });
+
+ it('shows "No EPG channel matches" warning when epg_found is false and tvg_id is set', async () => {
+ vi.mocked(previewSeriesRule).mockResolvedValue({
+ matches: [],
+ total: 0,
+ epg_found: false,
+ });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByText(/No EPG channel matches this tvg_id/)).toBeInTheDocument();
+ });
+ });
+
+ it('shows warn alert when preview.warn is true', async () => {
+ vi.mocked(previewSeriesRule).mockResolvedValue({
+ matches: Array(50).fill({ id: 'x', title: 'X' }),
+ total: 50,
+ epg_found: true,
+ warn: true,
+ });
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'X' },
+ });
+ await waitFor(() => {
+ expect(screen.getByText(/This rule matches many programs/)).toBeInTheDocument();
+ });
+ });
+
+ it('shows "No matching upcoming programs" when matches empty and filter set', async () => {
+ vi.mocked(previewSeriesRule).mockResolvedValue({
+ matches: [],
+ total: 0,
+ epg_found: true,
+ });
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'Unknown Show' },
+ });
+ await waitFor(() => {
+ expect(
+ screen.getByText(/No matching upcoming programs/)
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('renders sub_title in preview match', async () => {
+ vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'Match Show' },
+ });
+ await waitFor(() => {
+ expect(screen.getByText(/Episode 1/)).toBeInTheDocument();
+ });
+ });
+
+ it('renders "(NEW)" marker for new episodes in preview', async () => {
+ vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'Match Show' },
+ });
+ await waitFor(() => {
+ expect(screen.getByText(/(NEW)/)).toBeInTheDocument();
+ });
+ });
+ });
+
+ // ── handleSave ────────────────────────────────────────────────────────────
+
+ describe('handleSave', () => {
+ const renderWithTitle = () => {
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'My Show' },
+ });
+ };
+
+ it('calls createSeriesRule with correct payload', async () => {
+ renderWithTitle();
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ expect(createSeriesRule).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'My Show', mode: 'all' })
+ );
+ });
+ });
+
+ it('calls evaluateSeriesRulesByTvgId after createSeriesRule', async () => {
+ renderWithTitle();
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ expect(evaluateSeriesRulesByTvgId).toHaveBeenCalled();
+ });
+ });
+
+ it('calls fetchRecordings after save', async () => {
+ const { mockFetchRecordings } = setupMocks();
+ renderWithTitle();
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ expect(mockFetchRecordings).toHaveBeenCalled();
+ });
+ });
+
+ it('calls showNotification on success', async () => {
+ renderWithTitle();
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Series rule saved' })
+ );
+ });
+ });
+
+ it('calls onSaved callback on success', async () => {
+ renderWithTitle();
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ expect(defaultProps.onSaved).toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClose after save', async () => {
+ renderWithTitle();
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ expect(defaultProps.onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('does not call showNotification when createSeriesRule throws', async () => {
+ vi.mocked(createSeriesRule).mockRejectedValue(new Error('Server error'));
+ renderWithTitle();
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ expect(createSeriesRule).toHaveBeenCalled();
+ });
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+
+ it('does not crash when evaluateSeriesRulesByTvgId throws', async () => {
+ vi.mocked(evaluateSeriesRulesByTvgId).mockRejectedValue(new Error('eval fail'));
+ renderWithTitle();
+ await expect(
+ waitFor(() => {
+ fireEvent.click(screen.getByText('Save rule'));
+ })
+ ).resolves.not.toThrow();
+ });
+
+ it('does not crash when fetchRecordings throws', async () => {
+ useChannelsStore.getState = vi.fn().mockReturnValue({
+ fetchRecordings: vi.fn().mockRejectedValue(new Error('fetch fail')),
+ });
+ renderWithTitle();
+ await expect(
+ waitFor(() => {
+ fireEvent.click(screen.getByText('Save rule'));
+ })
+ ).resolves.not.toThrow();
+ });
+
+ it('includes channel_id in payload when channelId is set', async () => {
+ vi.mocked(getChannelsSummary).mockResolvedValue(mockChannels);
+ render( );
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ expect(createSeriesRule).toHaveBeenCalledWith(
+ expect.objectContaining({ channel_id: 10 })
+ );
+ });
+ });
+
+ it('omits channel_id from payload when no channel selected', async () => {
+ renderWithTitle();
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ const call = vi.mocked(createSeriesRule).mock.calls[0][0];
+ expect(call).not.toHaveProperty('channel_id');
+ });
+ });
+ });
+
+ // ── Cancel button ─────────────────────────────────────────────────────────
+
+ describe('Cancel button', () => {
+ it('calls onClose when Cancel is clicked', () => {
+ render( );
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(defaultProps.onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── SegmentedControl interactions ─────────────────────────────────────────
+
+ describe('SegmentedControl', () => {
+ it('changes title mode when SegmentedControl is clicked', () => {
+ render( );
+ fireEvent.click(screen.getAllByText('Contains').find((el) => el.closest('[data-testid="segmented-control"]')));
+ // No error thrown; state updated without crash
+ });
+
+ it('changes episode mode to "New only"', () => {
+ render( );
+ fireEvent.click(screen.getByText('New only'));
+ // Payload mode becomes 'new'; verify via save
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'Test' },
+ });
+ fireEvent.click(screen.getByText('Save rule'));
+ waitFor(() => {
+ expect(createSeriesRule).toHaveBeenCalledWith(
+ expect.objectContaining({ mode: 'new' })
+ );
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/__tests__/Stream.test.jsx b/frontend/src/components/forms/__tests__/Stream.test.jsx
new file mode 100644
index 00000000..18fe3b75
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/Stream.test.jsx
@@ -0,0 +1,566 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Module-level form state (survives re-renders, accessible in beforeEach) ───
+const __form = { values: {}, resetSpy: null };
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/streamProfiles.jsx', () => ({ default: vi.fn() }));
+vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/forms/StreamUtils.js', () => ({
+ addStream: vi.fn(),
+ getResolver: vi.fn(() => undefined),
+ updateStream: vi.fn(),
+}));
+
+// ── 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 });
+ };
+
+ // Keep reset stable across re-renders so useEffect([defaultValues, reset])
+ // in Stream.jsx does not fire on every render and cause an infinite loop.
+ 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;
+ }
+ const reset = resetRef.current;
+
+ return {
+ register,
+ handleSubmit,
+ formState: { errors: {}, isSubmitting: false },
+ reset,
+ setValue,
+ watch,
+ };
+ }),
+ };
+});
+
+// ── @mantine/core ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Button: ({ children, type, disabled }) => (
+
+ {children}
+
+ ),
+ Flex: ({ children }) => {children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ ×
+
+ {children}
+
+ ) : null,
+ Select: ({ label, value, onChange, data, placeholder, error }) => (
+
+ {label}
+ onChange(e.target.value || null)}
+ >
+ {placeholder ?? `-- ${label} --`}
+ {(data ?? []).map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+ {error && {error} }
+
+ ),
+ TextInput: ({ label, name, value, onChange, error, ...rest }) => (
+
+ {label}
+
+ {error && {error} }
+
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import Stream from '../Stream';
+import useStreamProfilesStore from '../../../store/streamProfiles.jsx';
+import useChannelsStore from '../../../store/channels.jsx';
+import * as StreamUtils from '../../../utils/forms/StreamUtils.js';
+
+// ── Shared test data ───────────────────────────────────────────────────────────
+const mockProfiles = [
+ { id: 1, name: 'Default' },
+ { id: 2, name: 'HD Profile' },
+];
+
+const mockChannelGroups = {
+ 10: { id: 10, name: 'Sports' },
+ 20: { id: 20, name: 'News' },
+};
+
+const makeStream = (overrides = {}) => ({
+ id: 'stream-1',
+ name: 'Test Stream',
+ url: 'http://example.com/stream',
+ channel_group: 10,
+ stream_profile_id: 1,
+ ...overrides,
+});
+
+const setupMocks = () => {
+ vi.mocked(useStreamProfilesStore).mockImplementation((sel) =>
+ sel({ profiles: mockProfiles })
+ );
+ vi.mocked(useChannelsStore).mockImplementation((sel) =>
+ sel({ channelGroups: mockChannelGroups })
+ );
+ vi.mocked(StreamUtils.addStream).mockResolvedValue(undefined);
+ vi.mocked(StreamUtils.updateStream).mockResolvedValue(undefined);
+};
+
+const defaultProps = (overrides = {}) => ({
+ stream: null,
+ isOpen: true,
+ onClose: vi.fn(),
+ ...overrides,
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('Stream', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Reset module-level form state before each test
+ __form.values = {};
+ setupMocks();
+ });
+
+ // ── Visibility ─────────────────────────────────────────────────────────────
+
+ describe('visibility', () => {
+ it('renders the modal when isOpen is true', () => {
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render when isOpen is false', () => {
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('renders "Stream" as the modal title', () => {
+ render( );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Stream');
+ });
+
+ it('calls onClose when modal close button is clicked', () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Form fields ────────────────────────────────────────────────────────────
+
+ describe('form fields', () => {
+ it('renders Stream Name input', () => {
+ render( );
+ expect(screen.getByTestId('input-stream-name')).toBeInTheDocument();
+ });
+
+ it('renders Stream URL input', () => {
+ render( );
+ expect(screen.getByTestId('input-stream-url')).toBeInTheDocument();
+ });
+
+ it('renders Group select', () => {
+ render( );
+ expect(screen.getByTestId('select-group')).toBeInTheDocument();
+ });
+
+ it('renders Stream Profile select', () => {
+ render( );
+ expect(screen.getByTestId('select-stream-profile')).toBeInTheDocument();
+ });
+
+ it('renders Submit button', () => {
+ render( );
+ expect(screen.getByText('Submit')).toBeInTheDocument();
+ });
+
+ it('populates Group select with channel groups', () => {
+ render( );
+ expect(screen.getByText('Sports')).toBeInTheDocument();
+ expect(screen.getByText('News')).toBeInTheDocument();
+ });
+
+ it('populates Stream Profile select with profiles', () => {
+ render( );
+ expect(screen.getByText('Default')).toBeInTheDocument();
+ expect(screen.getByText('HD Profile')).toBeInTheDocument();
+ });
+
+ it('shows "Optional" placeholder for Stream Profile', () => {
+ render( );
+ expect(screen.getByText('Optional')).toBeInTheDocument();
+ });
+ });
+
+ // ── Default values ─────────────────────────────────────────────────────────
+
+ describe('default values', () => {
+ it('pre-fills name from stream prop', () => {
+ render( );
+ expect(screen.getByTestId('input-stream-name')).toHaveValue('Test Stream');
+ });
+
+ it('pre-fills url from stream prop', () => {
+ render( );
+ expect(screen.getByTestId('input-stream-url')).toHaveValue(
+ 'http://example.com/stream'
+ );
+ });
+
+ it('pre-selects group from stream prop', () => {
+ render( );
+ expect(screen.getByTestId('select-group')).toHaveValue('10');
+ });
+
+ it('pre-selects stream profile from stream prop', () => {
+ render( );
+ expect(screen.getByTestId('select-stream-profile')).toHaveValue('1');
+ });
+
+ it('renders empty name when no stream provided', () => {
+ render( );
+ expect(screen.getByTestId('input-stream-name')).toHaveValue('');
+ });
+
+ it('renders empty url when no stream provided', () => {
+ render( );
+ expect(screen.getByTestId('input-stream-url')).toHaveValue('');
+ });
+
+ it('renders empty group when stream has no channel_group', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('select-group')).toHaveValue('');
+ });
+
+ it('renders empty profile when stream has no stream_profile_id', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('select-stream-profile')).toHaveValue('');
+ });
+ });
+
+ // ── Create stream ──────────────────────────────────────────────────────────
+
+ describe('create stream (no existing stream)', () => {
+ it('calls addStream on submit when no stream id', async () => {
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-stream-name'), {
+ target: { value: 'New Stream' },
+ });
+ fireEvent.change(screen.getByTestId('input-stream-url'), {
+ target: { value: 'http://new.stream/live' },
+ });
+
+ fireEvent.submit(screen.getByRole('button', { name: 'Submit' }).closest('form'));
+
+ await waitFor(() => {
+ expect(StreamUtils.addStream).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'New Stream',
+ url: 'http://new.stream/live',
+ })
+ );
+ });
+ });
+
+ it('does not call updateStream when creating', async () => {
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-stream-name'), {
+ target: { value: 'New Stream' },
+ });
+ fireEvent.change(screen.getByTestId('input-stream-url'), {
+ target: { value: 'http://new.stream' },
+ });
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(StreamUtils.updateStream).not.toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClose after successful create', async () => {
+ const onClose = vi.fn();
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-stream-name'), {
+ target: { value: 'New Stream' },
+ });
+ fireEvent.change(screen.getByTestId('input-stream-url'), {
+ target: { value: 'http://new.stream' },
+ });
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('converts channel_group string to integer in payload', async () => {
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-stream-name'), {
+ target: { value: 'S' },
+ });
+ fireEvent.change(screen.getByTestId('input-stream-url'), {
+ target: { value: 'http://x.com' },
+ });
+ fireEvent.change(screen.getByTestId('select-group'), {
+ target: { value: '10' },
+ });
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(StreamUtils.addStream).toHaveBeenCalledWith(
+ expect.objectContaining({ channel_group: 10 })
+ );
+ });
+ });
+
+ it('sets channel_group to null when no group selected', async () => {
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-stream-name'), {
+ target: { value: 'S' },
+ });
+ fireEvent.change(screen.getByTestId('input-stream-url'), {
+ target: { value: 'http://x.com' },
+ });
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ const call = vi.mocked(StreamUtils.addStream).mock.calls[0][0];
+ expect(call.channel_group).toBeNull();
+ });
+ });
+
+ it('converts stream_profile_id string to integer in payload', async () => {
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-stream-name'), {
+ target: { value: 'S' },
+ });
+ fireEvent.change(screen.getByTestId('input-stream-url'), {
+ target: { value: 'http://x.com' },
+ });
+ fireEvent.change(screen.getByTestId('select-stream-profile'), {
+ target: { value: '2' },
+ });
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(StreamUtils.addStream).toHaveBeenCalledWith(
+ expect.objectContaining({ stream_profile_id: 2 })
+ );
+ });
+ });
+
+ it('sets stream_profile_id to null when no profile selected', async () => {
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-stream-name'), {
+ target: { value: 'S' },
+ });
+ fireEvent.change(screen.getByTestId('input-stream-url'), {
+ target: { value: 'http://x.com' },
+ });
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ const call = vi.mocked(StreamUtils.addStream).mock.calls[0][0];
+ expect(call.stream_profile_id).toBeNull();
+ });
+ });
+ });
+
+ // ── Update stream ──────────────────────────────────────────────────────────
+
+ describe('update stream (existing stream)', () => {
+ it('calls updateStream with the stream id on submit', async () => {
+ render( );
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(StreamUtils.updateStream).toHaveBeenCalledWith(
+ 'stream-1',
+ expect.objectContaining({
+ name: 'Test Stream',
+ url: 'http://example.com/stream',
+ })
+ );
+ });
+ });
+
+ it('does not call addStream when updating', async () => {
+ render( );
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(StreamUtils.addStream).not.toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClose after successful update', async () => {
+ const onClose = vi.fn();
+ render( );
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('converts channel_group back to integer for update payload', async () => {
+ render( );
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(StreamUtils.updateStream).toHaveBeenCalledWith(
+ 'stream-1',
+ expect.objectContaining({ channel_group: 10 })
+ );
+ });
+ });
+
+ it('converts stream_profile_id back to integer for update payload', async () => {
+ render( );
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(StreamUtils.updateStream).toHaveBeenCalledWith(
+ 'stream-1',
+ expect.objectContaining({ stream_profile_id: 1 })
+ );
+ });
+ });
+ });
+
+ // ── getResolver ────────────────────────────────────────────────────────────
+
+ describe('getResolver', () => {
+ it('calls getResolver on mount', () => {
+ render( );
+ expect(StreamUtils.getResolver).toHaveBeenCalled();
+ });
+ });
+
+ // ── Form reset on stream change ────────────────────────────────────────────
+
+ describe('form reset', () => {
+ it('resets the form when stream prop changes', () => {
+ const { rerender } = render( );
+
+ rerender( );
+
+ expect(__form.resetSpy).toHaveBeenCalled();
+ });
+ });
+
+ // ── Submit button state ────────────────────────────────────────────────────
+
+ describe('submit button', () => {
+ it('Submit button is not disabled by default', () => {
+ render( );
+ expect(screen.getByText('Submit')).not.toBeDisabled();
+ });
+ });
+});
diff --git a/frontend/src/components/forms/__tests__/StreamProfile.test.jsx b/frontend/src/components/forms/__tests__/StreamProfile.test.jsx
new file mode 100644
index 00000000..50237950
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/StreamProfile.test.jsx
@@ -0,0 +1,607 @@
+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 };
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/userAgents', () => ({ default: vi.fn() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/forms/StreamProfileUtils.js', () => ({
+ addStreamProfile: vi.fn(),
+ updateStreamProfile: vi.fn(),
+ getResolver: vi.fn(() => undefined),
+ toCommandSelection: vi.fn((cmd) => {
+ const builtins = ['ffmpeg', 'streamlink', 'cvlc', 'yt-dlp'];
+ return builtins.includes(cmd) ? cmd : '__custom__';
+ }),
+ BUILT_IN_COMMANDS: [
+ { value: 'ffmpeg', label: 'FFmpeg' },
+ { value: 'streamlink', label: 'Streamlink' },
+ { value: 'cvlc', label: 'VLC' },
+ { value: 'yt-dlp', label: 'yt-dlp' },
+ { value: '__custom__', label: 'Custom…' },
+ ],
+ COMMAND_EXAMPLES: {
+ ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1',
+ streamlink: '{streamUrl} --http-header User-Agent={userAgent} best --stdout',
+ },
+}));
+
+// ── 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 }) => (
+
+ {children}
+
+ ),
+ Checkbox: ({ label, checked, onChange }) => (
+
+ {label}
+ onChange({ currentTarget: { checked: e.target.checked } })}
+ />
+
+ ),
+ Flex: ({ children }) => {children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ ×
+
+ {children}
+
+ ) : null,
+ Select: ({ label, value, onChange, data, placeholder, error, clearable, disabled, description }) => (
+
+
{label}
+ {description &&
{description}
}
+
onChange(e.target.value || (clearable ? null : ''))}
+ >
+ {placeholder ?? `-- ${label} --`}
+ {(data ?? []).map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+ {error &&
{error} }
+
+ ),
+ Stack: ({ children }) => {children}
,
+ Textarea: ({ label, name, value, onChange, error, placeholder, description, disabled, ...rest }) => (
+
+
{label}
+ {description &&
{description}
}
+
+ {error &&
{error} }
+
+ ),
+ TextInput: ({ label, name, value, onChange, error, disabled, ...rest }) => (
+
+ {label}
+
+ {error && {error} }
+
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import StreamProfile from '../StreamProfile';
+import useUserAgentsStore from '../../../store/userAgents';
+import * as StreamProfileUtils from '../../../utils/forms/StreamProfileUtils.js';
+
+// ── Shared test data ───────────────────────────────────────────────────────────
+const mockUserAgents = [
+ { id: 1, name: 'Chrome' },
+ { id: 2, name: 'Firefox' },
+];
+
+const makeProfile = (overrides = {}) => ({
+ id: 'sp-1',
+ name: 'My Profile',
+ command: 'ffmpeg',
+ parameters: '-i {streamUrl}',
+ is_active: true,
+ user_agent: '1',
+ locked: false,
+ ...overrides,
+});
+
+const setupMocks = () => {
+ vi.mocked(useUserAgentsStore).mockImplementation((sel) =>
+ sel({ userAgents: mockUserAgents })
+ );
+ vi.mocked(StreamProfileUtils.addStreamProfile).mockResolvedValue(undefined);
+ vi.mocked(StreamProfileUtils.updateStreamProfile).mockResolvedValue(undefined);
+ vi.mocked(StreamProfileUtils.getResolver).mockReturnValue(undefined);
+ vi.mocked(StreamProfileUtils.toCommandSelection).mockImplementation((cmd) => {
+ const builtins = ['ffmpeg', 'streamlink', 'cvlc', 'yt-dlp'];
+ return builtins.includes(cmd) ? cmd : '__custom__';
+ });
+};
+
+const defaultProps = (overrides = {}) => ({
+ profile: null,
+ isOpen: true,
+ onClose: vi.fn(),
+ ...overrides,
+});
+
+describe('StreamProfile', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ __form.values = {};
+ __form.resetSpy = null;
+ setupMocks();
+ });
+
+ // ── Visibility ──────────────────────────────────────────────────────────────
+
+ describe('visibility', () => {
+ it('renders the modal when isOpen is true', () => {
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render the modal when isOpen is false', () => {
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('renders "Stream Profile" as the modal title', () => {
+ render( );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Stream Profile');
+ });
+
+ it('calls onClose when modal close button is clicked', () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Form fields ─────────────────────────────────────────────────────────────
+
+ describe('form fields', () => {
+ it('renders Name input', () => {
+ render( );
+ expect(screen.getByTestId('input-name')).toBeInTheDocument();
+ });
+
+ it('renders Command select', () => {
+ render( );
+ expect(screen.getByTestId('select-command')).toBeInTheDocument();
+ });
+
+ it('renders Parameters textarea', () => {
+ render( );
+ expect(screen.getByTestId('textarea-parameters')).toBeInTheDocument();
+ });
+
+ it('renders User-Agent select', () => {
+ render( );
+ expect(screen.getByTestId('select-user-agent')).toBeInTheDocument();
+ });
+
+ it('renders Is Active checkbox', () => {
+ render( );
+ expect(screen.getByTestId('checkbox-is-active')).toBeInTheDocument();
+ });
+
+ it('renders Save button', () => {
+ render( );
+ expect(screen.getByText('Save')).toBeInTheDocument();
+ });
+
+ it('populates Command select with built-in options', () => {
+ render( );
+ expect(screen.getByText('FFmpeg')).toBeInTheDocument();
+ expect(screen.getByText('Streamlink')).toBeInTheDocument();
+ expect(screen.getAllByText('Custom…').length).toBeGreaterThan(0);
+ });
+
+ it('populates User-Agent select with user agents', () => {
+ render( );
+ expect(screen.getByText('Chrome')).toBeInTheDocument();
+ expect(screen.getByText('Firefox')).toBeInTheDocument();
+ });
+ });
+
+ // ── Default values ──────────────────────────────────────────────────────────
+
+ describe('default values', () => {
+ it('pre-fills name from profile prop', () => {
+ render( );
+ expect(screen.getByTestId('input-name')).toHaveValue('My Profile');
+ });
+
+ it('pre-fills parameters from profile prop', () => {
+ render( );
+ expect(screen.getByTestId('textarea-parameters')).toHaveValue('-i {streamUrl}');
+ });
+
+ it('pre-selects command from profile prop', () => {
+ render( );
+ expect(screen.getByTestId('select-command')).toHaveValue('ffmpeg');
+ });
+
+ it('pre-selects user-agent from profile prop', () => {
+ render( );
+ expect(screen.getByTestId('select-user-agent')).toHaveValue('1');
+ });
+
+ it('checkbox is checked when is_active is true', () => {
+ render( );
+ expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
+ });
+
+ it('checkbox is unchecked when is_active is false', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
+ });
+
+ it('defaults name to empty string when no profile', () => {
+ render( );
+ expect(screen.getByTestId('input-name')).toHaveValue('');
+ });
+
+ it('defaults parameters to empty string when no profile', () => {
+ render( );
+ expect(screen.getByTestId('textarea-parameters')).toHaveValue('');
+ });
+ });
+
+ // ── Command selection ───────────────────────────────────────────────────────
+
+ describe('command selection', () => {
+ it('does not show Custom Command input when a built-in is selected', () => {
+ render( );
+ expect(screen.queryByTestId('input-custom-command')).not.toBeInTheDocument();
+ });
+
+ it('shows Custom Command input when Custom… is selected', () => {
+ render( );
+ fireEvent.change(screen.getByTestId('select-command'), {
+ target: { value: '__custom__' },
+ });
+ expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
+ });
+
+ it('hides Custom Command input when switching from custom back to built-in', () => {
+ render( );
+ fireEvent.change(screen.getByTestId('select-command'), {
+ target: { value: '__custom__' },
+ });
+ fireEvent.change(screen.getByTestId('select-command'), {
+ target: { value: 'ffmpeg' },
+ });
+ expect(screen.queryByTestId('input-custom-command')).not.toBeInTheDocument();
+ });
+
+ it('shows Custom Command input when profile has a custom command', () => {
+ vi.mocked(StreamProfileUtils.toCommandSelection).mockReturnValue('__custom__');
+ render(
+
+ );
+ expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
+ });
+
+ it('sets command form value when built-in is selected', () => {
+ render( );
+ fireEvent.change(screen.getByTestId('select-command'), {
+ target: { value: 'streamlink' },
+ });
+ expect(__form.values.command).toBe('streamlink');
+ });
+
+ it('clears command form value when Custom… is selected', () => {
+ render( );
+ fireEvent.change(screen.getByTestId('select-command'), {
+ target: { value: '__custom__' },
+ });
+ expect(__form.values.command).toBe('');
+ });
+
+ it('shows parameters example hint for ffmpeg', () => {
+ const { container } = render(
+
+ );
+ expect(container.textContent).toMatch(/-user_agent \{userAgent\}/);
+ });
+ });
+
+ // ── Is Active checkbox ──────────────────────────────────────────────────────
+
+ describe('Is Active checkbox', () => {
+ it('toggles is_active to false when unchecked', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('checkbox-is-active'));
+ expect(__form.values.is_active).toBe(false);
+ });
+
+ it('toggles is_active to true when checked', () => {
+ render(
+
+ );
+ fireEvent.click(screen.getByTestId('checkbox-is-active'));
+ expect(__form.values.is_active).toBe(true);
+ });
+ });
+
+ // ── User-Agent select ───────────────────────────────────────────────────────
+
+ describe('User-Agent select', () => {
+ it('updates user_agent form value when changed', () => {
+ render( );
+ fireEvent.change(screen.getByTestId('select-user-agent'), {
+ target: { value: '2' },
+ });
+ expect(__form.values.user_agent).toBe('2');
+ });
+
+ it('sets user_agent to empty string when cleared', () => {
+ render( );
+ fireEvent.change(screen.getByTestId('select-user-agent'), {
+ target: { value: '' },
+ });
+ expect(__form.values.user_agent).toBe('');
+ });
+ });
+
+ // ── Locked profile ──────────────────────────────────────────────────────────
+
+ describe('locked profile', () => {
+ it('disables Name input when profile is locked', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('input-name')).toBeDisabled();
+ });
+
+ it('disables Command select when profile is locked', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('select-command')).toBeDisabled();
+ });
+
+ it('disables Parameters textarea when profile is locked', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('textarea-parameters')).toBeDisabled();
+ });
+
+ it('does not disable inputs when profile is not locked', () => {
+ render( );
+ expect(screen.getByTestId('input-name')).not.toBeDisabled();
+ });
+
+ it('does not disable inputs when no profile is provided', () => {
+ render( );
+ expect(screen.getByTestId('input-name')).not.toBeDisabled();
+ });
+ });
+
+ // ── Create profile ──────────────────────────────────────────────────────────
+
+ describe('create profile (no existing profile)', () => {
+ it('calls addStreamProfile on submit when no profile id', async () => {
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-name'), {
+ target: { value: 'New Profile' },
+ });
+ fireEvent.change(screen.getByTestId('select-command'), {
+ target: { value: 'ffmpeg' },
+ });
+
+ fireEvent.submit(screen.getByText('Save').closest('form'));
+
+ await waitFor(() => {
+ expect(StreamProfileUtils.addStreamProfile).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'New Profile' })
+ );
+ });
+ });
+
+ it('does not call updateStreamProfile when creating', async () => {
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-name'), {
+ target: { value: 'New Profile' },
+ });
+
+ fireEvent.submit(screen.getByText('Save').closest('form'));
+
+ await waitFor(() => {
+ expect(StreamProfileUtils.updateStreamProfile).not.toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClose after successful create', async () => {
+ const onClose = vi.fn();
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-name'), {
+ target: { value: 'New Profile' },
+ });
+ fireEvent.submit(screen.getByText('Save').closest('form'));
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Update profile ──────────────────────────────────────────────────────────
+
+ describe('update profile (existing profile)', () => {
+ it('calls updateStreamProfile with the profile id on submit', async () => {
+ render( );
+
+ fireEvent.submit(screen.getByText('Save').closest('form'));
+
+ await waitFor(() => {
+ expect(StreamProfileUtils.updateStreamProfile).toHaveBeenCalledWith(
+ 'sp-1',
+ expect.objectContaining({ name: 'My Profile' })
+ );
+ });
+ });
+
+ it('does not call addStreamProfile when updating', async () => {
+ render( );
+
+ fireEvent.submit(screen.getByText('Save').closest('form'));
+
+ await waitFor(() => {
+ expect(StreamProfileUtils.addStreamProfile).not.toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClose after successful update', async () => {
+ const onClose = vi.fn();
+ render(
+
+ );
+
+ fireEvent.submit(screen.getByText('Save').closest('form'));
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Form reset ──────────────────────────────────────────────────────────────
+
+ describe('form reset', () => {
+ it('resets the form when profile prop changes', () => {
+ const { rerender } = render( );
+
+ rerender( );
+
+ expect(__form.resetSpy).toHaveBeenCalled();
+ });
+
+ it('calls reset after successful submit', async () => {
+ render( );
+
+ fireEvent.submit(screen.getByText('Save').closest('form'));
+
+ await waitFor(() => {
+ expect(__form.resetSpy).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── getResolver ─────────────────────────────────────────────────────────────
+
+ describe('getResolver', () => {
+ it('calls getResolver on mount', () => {
+ render( );
+ expect(StreamProfileUtils.getResolver).toHaveBeenCalled();
+ });
+ });
+});
+
diff --git a/frontend/src/components/forms/__tests__/SuperuserForm.test.jsx b/frontend/src/components/forms/__tests__/SuperuserForm.test.jsx
new file mode 100644
index 00000000..b9b0dcbb
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/SuperuserForm.test.jsx
@@ -0,0 +1,332 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Asset mock ─────────────────────────────────────────────────────────────────
+vi.mock('../../../assets/logo.png', () => ({ default: 'logo.png' }));
+
+// ── API mock ───────────────────────────────────────────────────────────────────
+vi.mock('../../../api', () => ({
+ default: {
+ createSuperUser: vi.fn(),
+ },
+}));
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/auth', () => ({ default: vi.fn() }));
+vi.mock('../../../store/settings', () => ({ default: vi.fn() }));
+
+// ── @mantine/core ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Button: ({ children, type, fullWidth }) => (
+
+ {children}
+
+ ),
+ Center: ({ children, style }) => {children}
,
+ Divider: ({ style }) => ,
+ Image: ({ src, alt }) => ,
+ Paper: ({ children, style }) => {children}
,
+ Stack: ({ children }) => {children}
,
+ Text: ({ children, size, color, align }) => (
+
+ {children}
+
+ ),
+ TextInput: ({ label, name, value, onChange, required, type }) => (
+
+ {label}
+
+
+ ),
+ Title: ({ children, order, align }) => {
+ const Tag = `h${order ?? 1}`;
+ return {children} ;
+ },
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import SuperuserForm from '../SuperuserForm';
+import API from '../../../api';
+import useAuthStore from '../../../store/auth';
+import useSettingsStore from '../../../store/settings';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const setupMocks = ({
+ version = {},
+ fetchVersion = vi.fn(),
+ setSuperuserExists = vi.fn(),
+} = {}) => {
+ vi.mocked(useAuthStore).mockImplementation((sel) =>
+ sel({ setSuperuserExists })
+ );
+ vi.mocked(useSettingsStore).mockImplementation((sel) =>
+ sel({ fetchVersion, version })
+ );
+ return { fetchVersion, setSuperuserExists };
+};
+
+describe('SuperuserForm', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the Dispatcharr title', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('Dispatcharr')).toBeInTheDocument();
+ });
+
+ it('renders the welcome message', () => {
+ setupMocks();
+ render( );
+ expect(
+ screen.getByText(
+ 'Welcome! Create your Super User Account to get started.'
+ )
+ ).toBeInTheDocument();
+ });
+
+ it('renders the logo image', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByAltText('Dispatcharr Logo')).toBeInTheDocument();
+ });
+
+ it('renders Username input', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('input-username')).toBeInTheDocument();
+ });
+
+ it('renders Password input with type="password"', () => {
+ setupMocks();
+ render( );
+ const input = screen.getByTestId('input-password');
+ expect(input).toBeInTheDocument();
+ expect(input).toHaveAttribute('type', 'password');
+ });
+
+ it('renders Email input with type="email"', () => {
+ setupMocks();
+ render( );
+ const input = screen.getByTestId('input-email');
+ expect(input).toBeInTheDocument();
+ expect(input).toHaveAttribute('type', 'email');
+ });
+
+ it('renders the Create Account button', () => {
+ setupMocks();
+ render( );
+ expect(
+ screen.getByRole('button', { name: 'Create Account' })
+ ).toBeInTheDocument();
+ });
+
+ it('does not render version text when version is not loaded', () => {
+ setupMocks({ version: {} });
+ render( );
+ expect(screen.queryByText(/^v/)).not.toBeInTheDocument();
+ });
+
+ it('renders version text when version is loaded', () => {
+ setupMocks({ version: { version: '1.2.3' } });
+ render( );
+ expect(screen.getByText('v1.2.3')).toBeInTheDocument();
+ });
+ });
+
+ // ── useEffect ─────────────────────────────────────────────────────────────
+
+ describe('useEffect', () => {
+ it('calls fetchVersion on mount', () => {
+ const { fetchVersion } = setupMocks();
+ render( );
+ expect(fetchVersion).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call fetchVersion again on re-render with same fetchVersion ref', () => {
+ const { fetchVersion } = setupMocks();
+ const { rerender } = render( );
+ rerender( );
+ // fetchVersion is stable, so useEffect should only fire once
+ expect(fetchVersion).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ // ── Form field interactions ────────────────────────────────────────────────
+
+ describe('form field interactions', () => {
+ it('updates username field when typed', () => {
+ setupMocks();
+ render( );
+ fireEvent.change(screen.getByTestId('input-username'), {
+ target: { name: 'username', value: 'admin' },
+ });
+ expect(screen.getByTestId('input-username')).toHaveValue('admin');
+ });
+
+ it('updates password field when typed', () => {
+ setupMocks();
+ render( );
+ fireEvent.change(screen.getByTestId('input-password'), {
+ target: { name: 'password', value: 'secret123' },
+ });
+ expect(screen.getByTestId('input-password')).toHaveValue('secret123');
+ });
+
+ it('updates email field when typed', () => {
+ setupMocks();
+ render( );
+ fireEvent.change(screen.getByTestId('input-email'), {
+ target: { name: 'email', value: 'admin@example.com' },
+ });
+ expect(screen.getByTestId('input-email')).toHaveValue('admin@example.com');
+ });
+
+ it('initializes all fields as empty strings', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('input-username')).toHaveValue('');
+ expect(screen.getByTestId('input-password')).toHaveValue('');
+ expect(screen.getByTestId('input-email')).toHaveValue('');
+ });
+ });
+
+ // ── Form submission ────────────────────────────────────────────────────────
+
+ describe('form submission', () => {
+ it('calls API.createSuperUser with form values on submit', async () => {
+ setupMocks();
+ vi.mocked(API.createSuperUser).mockResolvedValue({
+ superuser_exists: false,
+ });
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-username'), {
+ target: { name: 'username', value: 'admin' },
+ });
+ fireEvent.change(screen.getByTestId('input-password'), {
+ target: { name: 'password', value: 'secret' },
+ });
+ fireEvent.change(screen.getByTestId('input-email'), {
+ target: { name: 'email', value: 'admin@test.com' },
+ });
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Create Account' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(API.createSuperUser).toHaveBeenCalledWith({
+ username: 'admin',
+ password: 'secret',
+ email: 'admin@test.com',
+ });
+ });
+ });
+
+ it('calls setSuperuserExists(true) when response.superuser_exists is true', async () => {
+ const { setSuperuserExists } = setupMocks();
+ vi.mocked(API.createSuperUser).mockResolvedValue({
+ superuser_exists: true,
+ });
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-username'), {
+ target: { name: 'username', value: 'admin' },
+ });
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Create Account' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(setSuperuserExists).toHaveBeenCalledWith(true);
+ });
+ });
+
+ it('does not call setSuperuserExists when response.superuser_exists is false', async () => {
+ const { setSuperuserExists } = setupMocks();
+ vi.mocked(API.createSuperUser).mockResolvedValue({
+ superuser_exists: false,
+ });
+ render( );
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Create Account' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(API.createSuperUser).toHaveBeenCalled();
+ });
+
+ expect(setSuperuserExists).not.toHaveBeenCalled();
+ });
+
+ it('does not throw when API.createSuperUser rejects', async () => {
+ setupMocks();
+ vi.mocked(API.createSuperUser).mockRejectedValue(new Error('Network'));
+ render( );
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Create Account' }).closest('form')
+ );
+
+ await expect(
+ waitFor(() => expect(API.createSuperUser).toHaveBeenCalled())
+ ).resolves.not.toThrow();
+ });
+
+ it('does not call setSuperuserExists when API throws', async () => {
+ const { setSuperuserExists } = setupMocks();
+ vi.mocked(API.createSuperUser).mockRejectedValue(new Error('Network'));
+ render( );
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Create Account' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(API.createSuperUser).toHaveBeenCalled();
+ });
+
+ expect(setSuperuserExists).not.toHaveBeenCalled();
+ });
+
+ it('submits with empty email when email field is left blank', async () => {
+ setupMocks();
+ vi.mocked(API.createSuperUser).mockResolvedValue({
+ superuser_exists: false,
+ });
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-username'), {
+ target: { name: 'username', value: 'admin' },
+ });
+ fireEvent.change(screen.getByTestId('input-password'), {
+ target: { name: 'password', value: 'secret' },
+ });
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Create Account' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(API.createSuperUser).toHaveBeenCalledWith(
+ expect.objectContaining({ email: '' })
+ );
+ });
+ });
+ });
+});
+
diff --git a/frontend/src/components/forms/__tests__/User.test.jsx b/frontend/src/components/forms/__tests__/User.test.jsx
new file mode 100644
index 00000000..f73c6a81
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/User.test.jsx
@@ -0,0 +1,686 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Constants ──────────────────────────────────────────────────────────────────
+vi.mock('../../../constants', () => ({
+ USER_LEVELS: { ADMIN: 1, STREAMER: 2, USER: 3 },
+ USER_LEVEL_LABELS: { 1: 'Admin', 2: 'Streamer', 3: 'User' },
+}));
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
+vi.mock('../../../store/outputProfiles', () => ({ default: vi.fn() }));
+vi.mock('../../../store/auth', () => ({ default: vi.fn() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils', () => ({ copyToClipboard: vi.fn() }));
+
+vi.mock('../../../utils/forms/UserUtils.js', () => ({
+ createUser: vi.fn(),
+ updateUser: vi.fn(),
+ generateApiKey: vi.fn(),
+ revokeApiKey: vi.fn(),
+ formValuesToPayload: vi.fn(),
+ getFormInitialValues: vi.fn(() => ({})),
+ getFormValidators: vi.fn(() => ({})),
+ userToFormValues: vi.fn(() => ({})),
+}));
+
+// ── Mantine form ───────────────────────────────────────────────────────────────
+const mockForm = {
+ getInputProps: vi.fn(() => ({})),
+ key: vi.fn((k) => k),
+ setValues: vi.fn(),
+ setFieldValue: vi.fn(),
+ reset: vi.fn(),
+ getValues: vi.fn(() => ({ user_level: '3' })),
+ onSubmit: vi.fn((fn) => (e) => { e?.preventDefault?.(); return fn(); }),
+ submitting: false,
+};
+
+vi.mock('@mantine/form', () => ({
+ useForm: vi.fn(() => mockForm),
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ Copy: () => ,
+ Key: () => ,
+ RotateCcwKey: () => ,
+ X: () => ,
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick, disabled }) => (
+
+ {children}
+
+ ),
+ Button: ({ children, onClick, disabled, loading, type, leftSection }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ Group: ({ children }) => {children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
×
+ {children}
+
+ ) : null,
+ MultiSelect: ({ label, onChange }) => (
+
+ {label}
+ onChange?.(e.target.value ? e.target.value.split(',') : [])}
+ />
+
+ ),
+ NumberInput: ({ label }) => {label}
,
+ PasswordInput: ({ label, disabled }) => (
+ {label}
+ ),
+ Select: ({ label, disabled }) => (
+ {label}
+ ),
+ Stack: ({ children }) => {children}
,
+ Switch: ({ label }) => {label}
,
+ Tabs: ({ children }) => {children}
,
+ TabsList: ({ children }) => {children}
,
+ TabsPanel: ({ children, value }) => {children}
,
+ TabsTab: ({ children, value }) => {children} ,
+ TagsInput: ({ label }) => {label}
,
+ Text: ({ children }) => {children} ,
+ TextInput: ({ label, value, disabled, rightSection }) => (
+
+ {label}
+
+ {rightSection}
+
+ ),
+ useMantineTheme: () => ({
+ colors: { red: Array(10).fill('#ff0000') },
+ }),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import useChannelsStore from '../../../store/channels';
+import useOutputProfilesStore from '../../../store/outputProfiles';
+import useAuthStore from '../../../store/auth';
+import * as UserUtils from '../../../utils/forms/UserUtils.js';
+import { copyToClipboard } from '../../../utils';
+import User from '../User';
+
+// ── Factories ──────────────────────────────────────────────────────────────────
+const makeAdminUser = (overrides = {}) => ({
+ id: 1,
+ username: 'admin',
+ user_level: 1,
+ api_key: null,
+ ...overrides,
+});
+
+const makeRegularUser = (overrides = {}) => ({
+ id: 2,
+ username: 'user1',
+ user_level: 3,
+ api_key: null,
+ ...overrides,
+});
+
+const setupMocks = ({
+ authUser = makeAdminUser(),
+ profiles = {},
+ outputProfiles = [],
+} = {}) => {
+ const mockSetUser = vi.fn();
+
+ vi.mocked(useChannelsStore).mockImplementation((sel) => sel({ profiles }));
+ vi.mocked(useOutputProfilesStore).mockImplementation((sel) =>
+ sel({ profiles: outputProfiles })
+ );
+ vi.mocked(useAuthStore).mockImplementation((sel) =>
+ sel({ user: authUser, setUser: mockSetUser })
+ );
+
+ // Reset form state
+ mockForm.getValues.mockReturnValue({ user_level: '3' });
+ mockForm.onSubmit.mockImplementation(
+ (fn) => (e) => { e?.preventDefault?.(); return fn(); }
+ );
+
+ return { mockSetUser };
+};
+
+// ──────────────────────────────────────────────────────────────────────────────
+
+describe('User', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(UserUtils.createUser).mockResolvedValue({});
+ vi.mocked(UserUtils.updateUser).mockResolvedValue({});
+ vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'new-api-key' });
+ vi.mocked(UserUtils.revokeApiKey).mockResolvedValue({ success: true });
+ vi.mocked(UserUtils.formValuesToPayload).mockReturnValue({ user_level: 3 });
+ vi.mocked(UserUtils.getFormInitialValues).mockReturnValue({});
+ vi.mocked(UserUtils.getFormValidators).mockReturnValue({});
+ vi.mocked(UserUtils.userToFormValues).mockReturnValue({});
+ });
+
+ // ── Visibility ───────────────────────────────────────────────────────────────
+
+ describe('visibility', () => {
+ it('renders nothing when isOpen is false', () => {
+ setupMocks();
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('renders the modal when isOpen is true', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('calls onClose when the modal close button is clicked', () => {
+ setupMocks();
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Tabs ─────────────────────────────────────────────────────────────────────
+
+ describe('tabs', () => {
+ it('always renders Account, EPG, and API tabs', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('tab-account')).toBeInTheDocument();
+ expect(screen.getByTestId('tab-epg')).toBeInTheDocument();
+ expect(screen.getByTestId('tab-api')).toBeInTheDocument();
+ });
+
+ it('shows Permissions tab when admin edits another user', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+ expect(screen.getByTestId('tab-permissions')).toBeInTheDocument();
+ });
+
+ it('hides Permissions tab when admin edits themselves', () => {
+ const admin = makeAdminUser();
+ setupMocks({ authUser: admin });
+ render( );
+ expect(screen.queryByTestId('tab-permissions')).not.toBeInTheDocument();
+ });
+
+ it('hides Permissions tab for non-admin user', () => {
+ setupMocks({ authUser: makeRegularUser() });
+ render(
+
+ );
+ expect(screen.queryByTestId('tab-permissions')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Admin-only fields ────────────────────────────────────────────────────────
+
+ describe('admin-only fields', () => {
+ it('shows Output Format Override for admin', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+ expect(screen.getByText('Output Format Override')).toBeInTheDocument();
+ });
+
+ it('hides Output Format Override for non-admin', () => {
+ setupMocks({ authUser: makeRegularUser() });
+ render(
+
+ );
+ expect(screen.queryByText('Output Format Override')).not.toBeInTheDocument();
+ });
+
+ it('shows Output Profile Override for admin', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+ expect(screen.getByText('Output Profile Override')).toBeInTheDocument();
+ });
+
+ it('shows Allowed IPs for admin', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+ expect(screen.getByText('Allowed IPs')).toBeInTheDocument();
+ });
+
+ it('hides Allowed IPs for non-admin', () => {
+ setupMocks({ authUser: makeRegularUser() });
+ render(
+
+ );
+ expect(screen.queryByText('Allowed IPs')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── API key generation ────────────────────────────────────────────────────────
+
+ describe('API key generation', () => {
+ it('shows "Generate API Key" when no key exists', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+ expect(screen.getByText('Generate API Key')).toBeInTheDocument();
+ });
+
+ it('shows "Regenerate API Key" when user already has an api_key', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+ expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
+ });
+
+ it('calls generateApiKey and switches button to "Regenerate API Key"', async () => {
+ setupMocks({ authUser: makeAdminUser() });
+ vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'brand-new-key' });
+
+ render(
+
+ );
+
+ expect(screen.getByText('Generate API Key')).toBeInTheDocument();
+ fireEvent.click(screen.getByText('Generate API Key'));
+
+ await waitFor(() => {
+ expect(UserUtils.generateApiKey).toHaveBeenCalledWith({ user_id: 2 });
+ expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
+ });
+ });
+
+ it('also sets the key when response contains raw_key instead of key', async () => {
+ setupMocks({ authUser: makeAdminUser() });
+ vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ raw_key: 'raw-value' });
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Generate API Key'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
+ });
+ });
+
+ it('does not call generateApiKey when canGenerateKey is false', async () => {
+ // non-admin editing someone else
+ setupMocks({ authUser: makeRegularUser({ id: 5 }) });
+ render(
+
+ );
+ // No button visible since canGenerateKey is false
+ expect(screen.queryByText('Generate API Key')).not.toBeInTheDocument();
+ expect(UserUtils.generateApiKey).not.toHaveBeenCalled();
+ });
+
+ it('does not update key when generateApiKey returns a response without key/raw_key', async () => {
+ setupMocks({ authUser: makeAdminUser() });
+ vi.mocked(UserUtils.generateApiKey).mockResolvedValue({});
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Generate API Key'));
+
+ await waitFor(() => {
+ // button text should NOT change since no key was received
+ expect(screen.getByText('Generate API Key')).toBeInTheDocument();
+ });
+ });
+
+ it('shows the generated API key in a text input', async () => {
+ setupMocks({ authUser: makeAdminUser() });
+ vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'show-me' });
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Generate API Key'));
+
+ await waitFor(() => {
+ const keyInput = screen.getByTestId('textinput-API Key');
+ expect(keyInput).toHaveValue('show-me');
+ });
+ });
+ });
+
+ // ── API key revocation ───────────────────────────────────────────────────────
+
+ describe('API key revocation', () => {
+ it('shows "Revoke API Key" when a key exists', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+ expect(screen.getByText('Revoke API Key')).toBeInTheDocument();
+ });
+
+ it('hides "Revoke API Key" when no key exists', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+ expect(screen.queryByText('Revoke API Key')).not.toBeInTheDocument();
+ });
+
+ it('calls revokeApiKey with user_id and clears the key on success', async () => {
+ setupMocks({ authUser: makeAdminUser() });
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Revoke API Key'));
+
+ await waitFor(() => {
+ expect(UserUtils.revokeApiKey).toHaveBeenCalledWith({ user_id: 2 });
+ expect(screen.queryByText('Revoke API Key')).not.toBeInTheDocument();
+ expect(screen.getByText('Generate API Key')).toBeInTheDocument();
+ });
+ });
+
+ it('updates auth store when admin revokes their own key', async () => {
+ const admin = makeAdminUser({ id: 1, api_key: 'my-key' });
+ const { mockSetUser } = setupMocks({ authUser: admin });
+
+ render( );
+ fireEvent.click(screen.getByText('Revoke API Key'));
+
+ await waitFor(() => {
+ expect(mockSetUser).toHaveBeenCalledWith(
+ expect.objectContaining({ api_key: null })
+ );
+ });
+ });
+
+ it('does not clear key when revokeApiKey returns success: false', async () => {
+ setupMocks({ authUser: makeAdminUser() });
+ vi.mocked(UserUtils.revokeApiKey).mockResolvedValue({ success: false });
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Revoke API Key'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Revoke API Key')).toBeInTheDocument();
+ });
+ });
+
+ it('does not update auth store when revoking another user\'s key', async () => {
+ const admin = makeAdminUser({ id: 1 });
+ const { mockSetUser } = setupMocks({ authUser: admin });
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Revoke API Key'));
+
+ await waitFor(() => {
+ expect(mockSetUser).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Copy to clipboard ────────────────────────────────────────────────────────
+
+ describe('copy API key to clipboard', () => {
+ it('calls copyToClipboard with the current key', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+
+ const copyButton = screen.getByTestId('icon-copy').closest('button');
+ fireEvent.click(copyButton);
+
+ expect(copyToClipboard).toHaveBeenCalledWith(
+ 'copy-me',
+ expect.objectContaining({ successTitle: 'API Key Copied!' })
+ );
+ });
+ });
+
+ // ── Form submission ──────────────────────────────────────────────────────────
+
+ describe('form submission', () => {
+ it('calls createUser when no user prop is given', async () => {
+ setupMocks({ authUser: makeAdminUser() });
+
+ render( );
+ fireEvent.click(screen.getByText('Save'));
+
+ await waitFor(() => {
+ expect(UserUtils.createUser).toHaveBeenCalled();
+ });
+ });
+
+ it('calls updateUser when editing an existing user', async () => {
+ const admin = makeAdminUser();
+ setupMocks({ authUser: admin });
+ vi.mocked(UserUtils.updateUser).mockResolvedValue({ id: 2 });
+
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Save'));
+
+ await waitFor(() => {
+ expect(UserUtils.updateUser).toHaveBeenCalledWith(
+ 2,
+ expect.any(Object),
+ true, // isAdmin
+ admin
+ );
+ });
+ });
+
+ it('updates auth store when admin saves their own profile', async () => {
+ const admin = makeAdminUser({ id: 1 });
+ const updatedAdmin = { ...admin, email: 'new@example.com' };
+ const { mockSetUser } = setupMocks({ authUser: admin });
+ vi.mocked(UserUtils.updateUser).mockResolvedValue(updatedAdmin);
+
+ render( );
+ fireEvent.click(screen.getByText('Save'));
+
+ await waitFor(() => {
+ expect(mockSetUser).toHaveBeenCalledWith(updatedAdmin);
+ });
+ });
+
+ it('resets form and calls onClose after successful submission', async () => {
+ setupMocks({ authUser: makeAdminUser() });
+ const onClose = vi.fn();
+
+ render( );
+ fireEvent.click(screen.getByText('Save'));
+
+ await waitFor(() => {
+ expect(mockForm.reset).toHaveBeenCalled();
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('removes password from payload when updating and payload.password is falsy', async () => {
+ const admin = makeAdminUser();
+ setupMocks({ authUser: admin });
+ vi.mocked(UserUtils.formValuesToPayload).mockReturnValue({
+ user_level: 3,
+ password: '',
+ });
+ vi.mocked(UserUtils.updateUser).mockResolvedValue({ id: 2 });
+
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Save'));
+
+ await waitFor(() => {
+ const payload = vi.mocked(UserUtils.updateUser).mock.calls[0][1];
+ expect(payload).not.toHaveProperty('password');
+ });
+ });
+ });
+
+ // ── useEffect ────────────────────────────────────────────────────────────────
+
+ describe('useEffect on user prop', () => {
+ it('calls userToFormValues and sets form values when user has an id', () => {
+ const user = makeRegularUser({ id: 2 });
+ vi.mocked(UserUtils.userToFormValues).mockReturnValue({ username: 'user1' });
+ setupMocks({ authUser: makeAdminUser() });
+
+ render( );
+
+ expect(UserUtils.userToFormValues).toHaveBeenCalledWith(user);
+ expect(mockForm.setValues).toHaveBeenCalledWith({ username: 'user1' });
+ });
+
+ it('resets form when no user is provided', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render( );
+ expect(mockForm.reset).toHaveBeenCalled();
+ });
+ });
+
+ // ── XC password generation ───────────────────────────────────────────────────
+
+ describe('XC password generation', () => {
+ it('calls setValues with a generated xc_password when rotate icon is clicked', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+
+ const rotateButton = screen
+ .getByTestId('icon-rotate-ccw-key')
+ .closest('button');
+ fireEvent.click(rotateButton);
+
+ expect(mockForm.setValues).toHaveBeenCalledWith(
+ expect.objectContaining({ xc_password: expect.any(String) })
+ );
+ });
+
+ it('disables the XC password rotate button for non-admin', () => {
+ setupMocks({ authUser: makeRegularUser() });
+ render(
+
+ );
+
+ const rotateButton = screen
+ .getByTestId('icon-rotate-ccw-key')
+ .closest('button');
+ expect(rotateButton).toBeDisabled();
+ });
+ });
+
+ // ── Channel profiles logic ───────────────────────────────────────────────────
+
+ describe('channel profiles logic', () => {
+ const profiles = { 1: { id: 1, name: 'HD' }, 2: { id: 2, name: 'SD' } };
+
+ it('excludes "All" (0) when other profiles are selected alongside it', () => {
+ setupMocks({ authUser: makeAdminUser(), profiles });
+ render(
+
+ );
+
+ const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
+ fireEvent.change(multiSelect, { target: { value: '1,2' } });
+
+ expect(mockForm.setFieldValue).toHaveBeenCalledWith(
+ 'channel_profiles',
+ expect.not.arrayContaining(['0'])
+ );
+ });
+
+ it('sets only ["0"] when "All" is newly selected', () => {
+ setupMocks({ authUser: makeAdminUser(), profiles });
+ render(
+
+ );
+
+ const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
+ fireEvent.change(multiSelect, { target: { value: '0' } });
+
+ expect(mockForm.setFieldValue).toHaveBeenCalledWith(
+ 'channel_profiles',
+ ['0']
+ );
+ });
+
+ it('allows multiple non-all profiles together', () => {
+ setupMocks({ authUser: makeAdminUser(), profiles });
+ render(
+
+ );
+
+ const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
+ fireEvent.change(multiSelect, { target: { value: '1,2' } });
+
+ expect(mockForm.setFieldValue).toHaveBeenCalledWith(
+ 'channel_profiles',
+ ['1', '2']
+ );
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/__tests__/UserAgent.test.jsx b/frontend/src/components/forms/__tests__/UserAgent.test.jsx
new file mode 100644
index 00000000..2292b8cd
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/UserAgent.test.jsx
@@ -0,0 +1,239 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/forms/UserAgentUtils.js', () => ({
+ addUserAgent: vi.fn(),
+ updateUserAgent: vi.fn(),
+ getResolver: vi.fn(() => undefined),
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Button: ({ children, disabled, type }) => (
+
+ {children}
+
+ ),
+ Checkbox: ({ label, checked, onChange }) => (
+
+
+ {label}
+
+ ),
+ Flex: ({ children }) => {children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
×
+ {children}
+
+ ) : null,
+ Space: () =>
,
+ TextInput: ({ label, error, ...rest }) => (
+
+ {label}
+
+ {error && {error} }
+
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import * as UserAgentUtils from '../../../utils/forms/UserAgentUtils.js';
+import UserAgent from '../UserAgent';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const makeUserAgent = (overrides = {}) => ({
+ id: 1,
+ name: 'Chrome',
+ user_agent: 'Mozilla/5.0',
+ description: 'Chrome browser',
+ is_active: true,
+ ...overrides,
+});
+
+describe('UserAgent', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(UserAgentUtils.addUserAgent).mockResolvedValue({});
+ vi.mocked(UserAgentUtils.updateUserAgent).mockResolvedValue({});
+ vi.mocked(UserAgentUtils.getResolver).mockReturnValue(undefined);
+ });
+
+ // ── Visibility ───────────────────────────────────────────────────────────────
+
+ describe('visibility', () => {
+ it('renders nothing when isOpen is false', () => {
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('renders the modal when isOpen is true', () => {
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('renders modal title "User-Agent"', () => {
+ render( );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('User-Agent');
+ });
+
+ it('calls onClose when modal close button is clicked', () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Default values ───────────────────────────────────────────────────────────
+
+ describe('default values', () => {
+ it('renders empty fields when no userAgent prop is given', () => {
+ render( );
+ expect(screen.getByTestId('input-Name')).toHaveValue('');
+ expect(screen.getByTestId('input-User-Agent')).toHaveValue('');
+ expect(screen.getByTestId('input-Description')).toHaveValue('');
+ });
+
+ it('pre-fills fields from userAgent prop', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('input-Name')).toHaveValue('Chrome');
+ expect(screen.getByTestId('input-User-Agent')).toHaveValue('Mozilla/5.0');
+ expect(screen.getByTestId('input-Description')).toHaveValue('Chrome browser');
+ });
+
+ it('checks Is Active checkbox when userAgent.is_active is true', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
+ });
+
+ it('unchecks Is Active checkbox when userAgent.is_active is false', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
+ });
+
+ it('defaults Is Active to checked when no userAgent prop given', () => {
+ render( );
+ expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
+ });
+ });
+
+ // ── Field interactions ───────────────────────────────────────────────────────
+
+ describe('field interactions', () => {
+ it('toggles Is Active checkbox', () => {
+ render(
+
+ );
+ const checkbox = screen.getByTestId('checkbox-is-active');
+ fireEvent.click(checkbox);
+ expect(checkbox).not.toBeChecked();
+ });
+ });
+
+ // ── Form submission ──────────────────────────────────────────────────────────
+
+ describe('form submission', () => {
+ it('calls addUserAgent when no userAgent id is given', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Submit'));
+ await waitFor(() => {
+ expect(UserAgentUtils.addUserAgent).toHaveBeenCalled();
+ });
+ });
+
+ it('calls updateUserAgent with the id when editing an existing userAgent', async () => {
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Submit'));
+ await waitFor(() => {
+ expect(UserAgentUtils.updateUserAgent).toHaveBeenCalledWith(
+ 1,
+ expect.objectContaining({ name: 'Chrome' })
+ );
+ });
+ });
+
+ it('does not call addUserAgent when updating', async () => {
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Submit'));
+ await waitFor(() => {
+ expect(UserAgentUtils.addUserAgent).not.toHaveBeenCalled();
+ });
+ });
+
+ it('does not call updateUserAgent when creating', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Submit'));
+ await waitFor(() => {
+ expect(UserAgentUtils.updateUserAgent).not.toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClose after successful submission', async () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText('Submit'));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('passes is_active value in the submitted payload', async () => {
+ render( );
+ const checkbox = screen.getByTestId('checkbox-is-active');
+ fireEvent.click(checkbox); // uncheck it
+ fireEvent.click(screen.getByText('Submit'));
+ await waitFor(() => {
+ expect(UserAgentUtils.addUserAgent).toHaveBeenCalledWith(
+ expect.objectContaining({ is_active: false })
+ );
+ });
+ });
+ });
+
+ // ── useEffect reset ──────────────────────────────────────────────────────────
+
+ describe('form reset on userAgent change', () => {
+ it('resets form values when userAgent prop changes', () => {
+ const { rerender } = render(
+
+ );
+ expect(screen.getByTestId('input-Name')).toHaveValue('Chrome');
+
+ rerender(
+
+ );
+ expect(screen.getByTestId('input-Name')).toHaveValue('Firefox');
+ });
+
+ it('resets to empty when userAgent prop is removed', () => {
+ const { rerender } = render(
+
+ );
+ rerender( );
+ expect(screen.getByTestId('input-Name')).toHaveValue('');
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/__tests__/VODCategoryFilter.test.jsx b/frontend/src/components/forms/__tests__/VODCategoryFilter.test.jsx
new file mode 100644
index 00000000..de87d5c0
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/VODCategoryFilter.test.jsx
@@ -0,0 +1,362 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Store mock ─────────────────────────────────────────────────────────────────
+vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() }));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ CircleCheck: () => ,
+ CircleX: () => ,
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Box: ({ children }) => {children}
,
+ Button: ({ children, onClick, disabled, color, variant }) => (
+
+ {children}
+
+ ),
+ Checkbox: ({ label, checked, onChange, disabled }) => (
+
+ onChange?.({ currentTarget: { checked: e.target.checked } })}
+ disabled={disabled}
+ />
+ {label}
+
+ ),
+ Flex: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ SimpleGrid: ({ children }) => {children}
,
+ Stack: ({ children }) => {children}
,
+ Text: ({ children }) => {children} ,
+ TextInput: ({ label, value, onChange, placeholder }) => (
+
+ ),
+ SegmentedControl: ({ value, onChange, data }) => (
+
+ {data.map((item) => (
+ onChange(item.value ?? item)}
+ data-active={value === (item.value ?? item) ? 'true' : 'false'}
+ >
+ {item.label ?? item}
+
+ ))}
+
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import useVODStore from '../../../store/useVODStore';
+import VODCategoryFilter from '../VODCategoryFilter';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const makeCategories = () => [
+ {
+ id: 1,
+ name: 'Action',
+ m3u_accounts: [{ m3u_account: 10, enabled: true }],
+ category_type: 'movies',
+ },
+ { id: 2, name: 'Comedy', m3u_accounts: [{ m3u_account: 10, enabled: false }], category_type: 'movies' },
+ { id: 3, name: 'Drama', m3u_accounts: [{ m3u_account: 10, enabled: true }], category_type: 'movies' },
+ { id: 4, name: 'News', m3u_accounts: [{ m3u_account: 10, enabled: true }], category_type: 'series' },
+];
+
+const makePlaylist = (overrides = {}) => ({ id: 10, name: 'My Playlist', ...overrides });
+
+const categoriesToDict = (arr) =>
+ arr.reduce((acc, cat) => ({ ...acc, [cat.id]: cat }), {});
+
+const setupMocks = ({ categories = makeCategories() } = {}) => {
+ const dict = Array.isArray(categories) ? categoriesToDict(categories) : categories;
+ vi.mocked(useVODStore).mockImplementation((sel) => sel({ categories: dict }));
+};
+
+const defaultProps = (overrides = {}) => {
+ return {
+ playlist: makePlaylist(),
+ categoryStates: [
+ { id: 1, name: 'Action', enabled: true },
+ { id: 2, name: 'Comedy', enabled: false },
+ { id: 3, name: 'Drama', enabled: true },
+ ],
+ setCategoryStates: vi.fn(),
+ type: 'movies',
+ autoEnableNewGroups: true,
+ setAutoEnableNewGroups: vi.fn(),
+ ...overrides,
+ };
+};
+
+// ──────────────────────────────────────────────────────────────────────────────
+
+describe('VODCategoryFilter', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupMocks();
+ });
+
+ // ── Rendering ─────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders without crashing', () => {
+ render( );
+ expect(screen.getByTestId('simple-grid')).toBeInTheDocument();
+ });
+
+ it('renders a button for each category matching the type and playlist', () => {
+ render( );
+ expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
+ });
+
+ it('does not render categories of a different type', () => {
+ render( );
+ expect(screen.queryByRole('checkbox', { name: 'News' })).not.toBeInTheDocument();
+ });
+
+ it('renders the text filter input', () => {
+ render( );
+ expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument();
+ });
+
+ it('renders the segmented status control', () => {
+ render( );
+ expect(screen.getByTestId('segmented-control')).toBeInTheDocument();
+ });
+
+ it('renders Enable All and Disable All buttons', () => {
+ render( );
+ expect(screen.getByText('Select Visible')).toBeInTheDocument();
+ expect(screen.getByText('Deselect Visible')).toBeInTheDocument();
+ });
+
+ it('renders the Auto-enable new groups checkbox', () => {
+ render( );
+ expect(screen.getByLabelText(/automatically enable new/i)).toBeInTheDocument();
+ });
+ });
+
+ // ── Text filter ───────────────────────────────────────────────────────────
+
+ describe('text filter', () => {
+ it('hides categories that do not match the filter', () => {
+ render( );
+ const input = screen.getByPlaceholderText(/filter/i);
+ fireEvent.change(input, { target: { value: 'act' } });
+ expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Comedy' })).not.toBeInTheDocument();
+ });
+
+ it('is case-insensitive', () => {
+ render( );
+ fireEvent.change(screen.getByPlaceholderText(/filter/i), { target: { value: 'COMEDY' } });
+ expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
+ });
+
+ it('shows all categories when filter is cleared', () => {
+ render( );
+ const input = screen.getByPlaceholderText(/filter/i);
+ fireEvent.change(input, { target: { value: 'act' } });
+ fireEvent.change(input, { target: { value: '' } });
+ expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Drama' })).toBeInTheDocument();
+ });
+
+ it('shows no categories when filter matches nothing', () => {
+ render( );
+ fireEvent.change(screen.getByPlaceholderText(/filter/i), {
+ target: { value: 'zzznomatch' },
+ });
+ expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Status filter ─────────────────────────────────────────────────────────
+
+ describe('status filter', () => {
+ it('shows only enabled categories when "Enabled" segment is selected', () => {
+ const props = defaultProps({
+ categoryStates: [ { id: 1, name: 'Action', enabled: true }, { id: 2, name: 'Comedy', enabled: false }, { id: 3, name: 'Drama', enabled: true } ],
+ });
+ render( );
+ fireEvent.click(screen.getByTestId('segment-enabled'));
+ expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Comedy' })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Drama' })).toBeInTheDocument();
+ });
+
+ it('shows only disabled categories when "Disabled" segment is selected', () => {
+ const props = defaultProps({
+ categoryStates: [ { id: 1, name: 'Action', enabled: true }, { id: 2, name: 'Comedy', enabled: false }, { id: 3, name: 'Drama', enabled: true } ],
+ });
+ render( );
+ fireEvent.click(screen.getByTestId('segment-disabled'));
+ expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
+ });
+
+ it('shows all categories when "All" segment is active', () => {
+ const props = defaultProps({
+ categoryStates: [ { id: 1, name: 'Action', enabled: true }, { id: 2, name: 'Comedy', enabled: false }, { id: 3, name: 'Drama', enabled: true } ],
+ });
+ render( );
+ fireEvent.click(screen.getByTestId('segment-disabled'));
+ fireEvent.click(screen.getByTestId('segment-all'));
+ expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
+ });
+ });
+
+ // ── Combined filters ──────────────────────────────────────────────────────
+
+ describe('combined text + status filters', () => {
+ it('applies both text and status filters simultaneously', () => {
+ const props = defaultProps();
+ render( );
+ fireEvent.change(screen.getByPlaceholderText(/filter/i), { target: { value: 'o' } });
+ fireEvent.click(screen.getByTestId('segment-disabled'));
+ // "Comedy" matches "o" AND is disabled; "Action" matches "o" but is enabled
+ expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Enable All / Disable All ──────────────────────────────────────────────
+
+ describe('Enable All button', () => {
+ it('calls setCategoryStates with all visible categories set to true', () => {
+ const setCategoryStates = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Select Visible'));
+ const called = setCategoryStates.mock.calls.at(-1)[0];
+ expect(called.find((s) => s.id === 1).enabled).toBe(true);
+ expect(called.find((s) => s.id === 2).enabled).toBe(true);
+ expect(called.find((s) => s.id === 3).enabled).toBe(true);
+ });
+
+ it('only enables filtered categories when a text filter is active', () => {
+ const setCategoryStates = vi.fn();
+ render(
+
+ );
+ fireEvent.change(screen.getByPlaceholderText(/filter/i), { target: { value: 'act' } });
+ fireEvent.click(screen.getByText('Select Visible'));
+ const called = setCategoryStates.mock.calls.at(-1)[0];
+ expect(called.find((s) => s.id === 1).enabled).toBe(true);
+ // Comedy and Drama were filtered out — their state should be unchanged
+ expect(called.find((s) => s.id === 2).enabled).toBe(false);
+ expect(called.find((s) => s.id === 3).enabled).toBe(false);
+ });
+ });
+
+ describe('Disable All button', () => {
+ it('calls setCategoryStates with all visible categories set to false', () => {
+ const setCategoryStates = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText('Deselect Visible'));
+ const called = setCategoryStates.mock.calls.at(-1)[0];
+ expect(called.find((s) => s.id === 1).enabled).toBe(false);
+ expect(called.find((s) => s.id === 2).enabled).toBe(false);
+ expect(called.find((s) => s.id === 3).enabled).toBe(false);
+ });
+
+ it('only disables filtered categories when a text filter is active', () => {
+ const setCategoryStates = vi.fn();
+ render(
+
+ );
+ fireEvent.change(screen.getByPlaceholderText(/filter/i), { target: { value: 'comedy' } });
+ fireEvent.click(screen.getByText('Deselect Visible'));
+ const called = setCategoryStates.mock.calls.at(-1)[0];
+ expect(called.find((s) => s.id === 2).enabled).toBe(false);
+ expect(called.find((s) => s.id === 1).enabled).toBe(true);
+ expect(called.find((s) => s.id === 3).enabled).toBe(true);
+ });
+ });
+
+ // ── Auto-enable new groups ────────────────────────────────────────────────
+
+ describe('autoEnableNewGroups checkbox', () => {
+ it('calls setAutoEnableNewGroups(true) when checked', () => {
+ const setAutoEnableNewGroups = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByLabelText(/automatically enable new/i));
+ expect(setAutoEnableNewGroups).toHaveBeenCalledWith(true);
+ });
+
+ it('calls setAutoEnableNewGroups(false) when unchecked', () => {
+ const setAutoEnableNewGroups = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByLabelText(/automatically enable new/i));
+ expect(setAutoEnableNewGroups).toHaveBeenCalledWith(false);
+ });
+ });
+
+ // ── No playlist / empty categories ───────────────────────────────────────
+
+ describe('edge cases', () => {
+ it('renders no category buttons when categories list is empty', () => {
+ setupMocks({ categories: [] });
+ render( );
+ expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
+ });
+
+ it('renders no category buttons when categoryStates is empty', () => {
+ render( );
+ expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
+ });
+
+ it('renders categories only for the matching playlist id', () => {
+ setupMocks({
+ categories: [
+ ...makeCategories(),
+ { id: 99, name: 'Foreign', m3u_accounts: [{ m3u_account: 99, enabled: true }], category_type: 'movies' },
+ ],
+ });
+ render( );
+ expect(screen.queryByRole('button', { name: 'Foreign' })).not.toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file