Minor formatting changes

This commit is contained in:
Nick Sandstrom 2026-05-25 18:15:09 -07:00
parent fee8913824
commit 284ba87fe7
24 changed files with 1217 additions and 450 deletions

View file

@ -23,7 +23,7 @@ import { Copy, Key, RotateCcwKey, X } from 'lucide-react';
import { useForm } from '@mantine/form';
import useChannelsStore from '../../store/channels';
import useOutputProfilesStore from '../../store/outputProfiles';
import { USER_LEVEL_LABELS, USER_LEVELS, } from '../../constants';
import { USER_LEVEL_LABELS, USER_LEVELS } from '../../constants';
import useAuthStore from '../../store/auth';
import { copyToClipboard } from '../../utils';
import {

View file

@ -1,8 +1,12 @@
// Modal.js
import React, { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Checkbox, Flex, Modal, Space, TextInput, } from '@mantine/core';
import { addUserAgent, getResolver, updateUserAgent } from '../../utils/forms/UserAgentUtils.js';
import { Button, Checkbox, Flex, Modal, Space, TextInput } from '@mantine/core';
import {
addUserAgent,
getResolver,
updateUserAgent,
} from '../../utils/forms/UserAgentUtils.js';
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
const defaultValues = useMemo(

View file

@ -27,7 +27,9 @@ vi.mock('@mantine/core', async () => ({
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>×</button>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
@ -43,13 +45,18 @@ vi.mock('@mantine/core', async () => ({
</button>
),
Anchor: ({ children, onClick }) => (
<a data-testid="anchor" onClick={onClick}>{children}</a>
<a data-testid="anchor" onClick={onClick}>
{children}
</a>
),
}));
// Imports after mocks
import ProgramRecordingModal from '../ProgramRecordingModal.jsx';
import { deleteRecordingById, deleteSeriesAndRule } from '../../../utils/cards/RecordingCardUtils.js';
import {
deleteRecordingById,
deleteSeriesAndRule,
} from '../../../utils/cards/RecordingCardUtils.js';
import { deleteSeriesRuleByTvgId } from '../../../utils/guideUtils.js';
// Helpers
@ -114,11 +121,7 @@ describe('ProgramRecordingModal', () => {
describe('handleRemoveRecording', () => {
it('calls deleteRecordingById with the recording id', async () => {
const recording = makeRecording();
render(
<ProgramRecordingModal
{...defaultProps({ recording })}
/>
);
render(<ProgramRecordingModal {...defaultProps({ recording })} />);
fireEvent.click(screen.getByText('Remove this recording'));
await waitFor(() => {
expect(deleteRecordingById).toHaveBeenCalledWith('rec-1');
@ -129,9 +132,7 @@ describe('ProgramRecordingModal', () => {
const onClose = vi.fn();
const recording = makeRecording();
render(
<ProgramRecordingModal
{...defaultProps({ recording, onClose })}
/>
<ProgramRecordingModal {...defaultProps({ recording, onClose })} />
);
fireEvent.click(screen.getByText('Remove this recording'));
await waitFor(() => {
@ -144,9 +145,7 @@ describe('ProgramRecordingModal', () => {
const onClose = vi.fn();
const recording = makeRecording();
render(
<ProgramRecordingModal
{...defaultProps({ recording, onClose })}
/>
<ProgramRecordingModal {...defaultProps({ recording, onClose })} />
);
fireEvent.click(screen.getByText('Remove this recording'));
await waitFor(() => {
@ -202,7 +201,10 @@ describe('ProgramRecordingModal', () => {
);
fireEvent.click(screen.getByText(/Remove series rule/i));
await waitFor(() => {
expect(deleteSeriesRuleByTvgId).toHaveBeenCalledWith('tvg-3', 'Rule Show');
expect(deleteSeriesRuleByTvgId).toHaveBeenCalledWith(
'tvg-3',
'Rule Show'
);
});
});
@ -210,7 +212,10 @@ describe('ProgramRecordingModal', () => {
const onExistingRuleModeChange = vi.fn();
render(
<ProgramRecordingModal
{...defaultProps({ existingRuleMode: 'rule', onExistingRuleModeChange })}
{...defaultProps({
existingRuleMode: 'rule',
onExistingRuleModeChange,
})}
/>
);
fireEvent.click(screen.getByText(/Remove series rule/i));
@ -245,14 +250,18 @@ describe('ProgramRecordingModal', () => {
it('calls onRecordSeriesAll when "Record All" is clicked', () => {
const onRecordSeriesAll = vi.fn();
render(<ProgramRecordingModal {...defaultProps({ onRecordSeriesAll })} />);
render(
<ProgramRecordingModal {...defaultProps({ onRecordSeriesAll })} />
);
fireEvent.click(screen.getByText('Every episode'));
expect(onRecordSeriesAll).toHaveBeenCalled();
});
it('calls onRecordSeriesNew when "Record New" is clicked', () => {
const onRecordSeriesNew = vi.fn();
render(<ProgramRecordingModal {...defaultProps({ onRecordSeriesNew })} />);
render(
<ProgramRecordingModal {...defaultProps({ onRecordSeriesNew })} />
);
fireEvent.click(screen.getByText('New episodes only'));
expect(onRecordSeriesNew).toHaveBeenCalled();
});
@ -264,22 +273,32 @@ describe('ProgramRecordingModal', () => {
it('opens SeriesRuleEditorModal when "Edit Rule" is clicked', () => {
render(
<ProgramRecordingModal
{...defaultProps({ existingRuleMode: 'rule', existingRule: { id: 1 } })}
{...defaultProps({
existingRuleMode: 'rule',
existingRule: { id: 1 },
})}
/>
);
fireEvent.click(screen.getByText(/Customize rule/i));
expect(screen.getByTestId('series-rule-editor-modal')).toBeInTheDocument();
expect(
screen.getByTestId('series-rule-editor-modal')
).toBeInTheDocument();
});
it('closes SeriesRuleEditorModal when its onClose is called', () => {
render(
<ProgramRecordingModal
{...defaultProps({ existingRuleMode: 'rule', existingRule: { id: 1 } })}
{...defaultProps({
existingRuleMode: 'rule',
existingRule: { id: 1 },
})}
/>
);
fireEvent.click(screen.getByText(/Customize rule/i));
fireEvent.click(screen.getByTestId('series-rule-editor-close'));
expect(screen.queryByTestId('series-rule-editor-modal')).not.toBeInTheDocument();
expect(
screen.queryByTestId('series-rule-editor-modal')
).not.toBeInTheDocument();
});
});
});
});

View file

@ -55,11 +55,20 @@ vi.mock('@mantine/form', () => ({
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); }),
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; }),
setFieldValue: vi.fn((k, v) => {
values[k] = v;
}),
validateField: vi.fn(),
};
}),
@ -74,24 +83,37 @@ vi.mock('@mantine/core', () => ({
</div>
),
Button: ({ children, onClick, loading, type }) => (
<button type={type} onClick={onClick} data-loading={loading} disabled={loading}>
<button
type={type}
onClick={onClick}
data-loading={loading}
disabled={loading}
>
{children}
</button>
),
Group: ({ children }) => <div>{children}</div>,
Loader: ({ size, color }) => <span data-testid="loader" data-size={size} data-color={color} />,
Loader: ({ size, color }) => (
<span data-testid="loader" data-size={size} data-color={color} />
),
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>×</button>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
MultiSelect: ({ label, placeholder, ...props }) => (
<div>
<label>{label}</label>
<input data-testid={`multiselect-${label}`} placeholder={placeholder} {...props} />
<input
data-testid={`multiselect-${label}`}
placeholder={placeholder}
{...props}
/>
</div>
),
SegmentedControl: ({ value, onChange, data, disabled }) => (
@ -115,7 +137,9 @@ vi.mock('@mantine/core', () => ({
{rightSection}
<select data-testid={`select-${label}`} disabled={disabled} {...props}>
{(data ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
@ -124,7 +148,11 @@ vi.mock('@mantine/core', () => ({
TextInput: ({ label, placeholder, ...props }) => (
<div>
<label>{label}</label>
<input data-testid={`textinput-${label}`} placeholder={placeholder} {...props} />
<input
data-testid={`textinput-${label}`}
placeholder={placeholder}
{...props}
/>
</div>
),
}));
@ -136,8 +164,10 @@ vi.mock('@mantine/dates', () => ({
<label>{label}</label>
<input
data-testid={`datepicker-${label}`}
value={value ? value.toISOString?.() ?? value : ''}
onChange={(e) => onChange(e.target.value ? new Date(e.target.value) : null)}
value={value ? (value.toISOString?.() ?? value) : ''}
onChange={(e) =>
onChange(e.target.value ? new Date(e.target.value) : null)
}
/>
</div>
),
@ -175,7 +205,10 @@ const setupStoreMock = () => {
const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined);
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({ fetchRecordings: mockFetchRecordings, fetchRecurringRules: mockFetchRecurringRules })
sel({
fetchRecordings: mockFetchRecordings,
fetchRecurringRules: mockFetchRecurringRules,
})
);
return { mockFetchRecordings, mockFetchRecurringRules };
@ -223,7 +256,9 @@ describe('RecordingModal', () => {
it('renders the scheduling conflicts alert', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
expect(screen.getByTestId('alert')).toBeInTheDocument();
expect(screen.getByTestId('alert-title')).toHaveTextContent('Scheduling Conflicts');
expect(screen.getByTestId('alert-title')).toHaveTextContent(
'Scheduling Conflicts'
);
});
});
@ -232,13 +267,19 @@ describe('RecordingModal', () => {
describe('mode switching', () => {
it('defaults to "single" mode', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
expect(screen.getByTestId('mode-single')).toHaveAttribute('data-active', 'true');
expect(screen.getByTestId('mode-single')).toHaveAttribute(
'data-active',
'true'
);
});
it('switches to recurring mode when Recurring button clicked', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
expect(screen.getByTestId('mode-recurring')).toHaveAttribute('data-active', 'true');
expect(screen.getByTestId('mode-recurring')).toHaveAttribute(
'data-active',
'true'
);
});
it('shows DateTimePicker fields in single mode', () => {
@ -256,7 +297,9 @@ describe('RecordingModal', () => {
});
it('disables mode toggle when editing an existing recording', () => {
render(<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />);
render(
<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />
);
expect(screen.getByTestId('mode-single')).toBeDisabled();
expect(screen.getByTestId('mode-recurring')).toBeDisabled();
});
@ -296,7 +339,9 @@ describe('RecordingModal', () => {
});
it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => {
vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(new Error('fail'));
vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(
new Error('fail')
);
render(<RecordingModal isOpen onClose={vi.fn()} />);
await waitFor(() => {
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith(
@ -336,7 +381,10 @@ describe('RecordingModal', () => {
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Recording scheduled', color: 'green' })
expect.objectContaining({
title: 'Recording scheduled',
color: 'green',
})
);
});
});
@ -360,7 +408,9 @@ describe('RecordingModal', () => {
});
it('does not call showNotification when createRecording throws', async () => {
vi.mocked(RecordingUtils.createRecording).mockRejectedValue(new Error('fail'));
vi.mocked(RecordingUtils.createRecording).mockRejectedValue(
new Error('fail')
);
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
@ -402,7 +452,10 @@ describe('RecordingModal', () => {
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Recording updated', color: 'green' })
expect.objectContaining({
title: 'Recording updated',
color: 'green',
})
);
});
});
@ -446,7 +499,10 @@ describe('RecordingModal', () => {
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Recurring rule saved', color: 'green' })
expect.objectContaining({
title: 'Recurring rule saved',
color: 'green',
})
);
});
});
@ -462,7 +518,9 @@ describe('RecordingModal', () => {
});
it('does not show notification when createRecurringRule throws', async () => {
vi.mocked(RecordingUtils.createRecurringRule).mockRejectedValue(new Error('fail'));
vi.mocked(RecordingUtils.createRecurringRule).mockRejectedValue(
new Error('fail')
);
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
@ -478,19 +536,34 @@ describe('RecordingModal', () => {
it('calls getSingleFormDefaults with recording and channel when opening with existing recording', () => {
const recording = makeRecording();
const channel = makeChannel();
render(<RecordingModal isOpen recording={recording} channel={channel} onClose={vi.fn()} />);
expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(recording, channel);
render(
<RecordingModal
isOpen
recording={recording}
channel={channel}
onClose={vi.fn()}
/>
);
expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(
recording,
channel
);
});
it('calls getSingleFormDefaults with null when opening for new recording', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(null, null);
expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(
null,
null
);
});
it('calls getRecurringFormDefaults with channel on open', () => {
const channel = makeChannel();
render(<RecordingModal isOpen channel={channel} onClose={vi.fn()} />);
expect(RecordingUtils.getRecurringFormDefaults).toHaveBeenCalledWith(channel);
expect(RecordingUtils.getRecurringFormDefaults).toHaveBeenCalledWith(
channel
);
});
});
@ -504,4 +577,4 @@ describe('RecordingModal', () => {
expect(onClose).toHaveBeenCalled();
});
});
});
});

View file

@ -59,7 +59,9 @@ vi.mock('@mantine/core', () => ({
</button>
),
Badge: ({ children, color }) => (
<span data-testid="badge" data-color={color}>{children}</span>
<span data-testid="badge" data-color={color}>
{children}
</span>
),
Button: ({ children, onClick, disabled, loading, size, variant, color }) => (
<button
@ -74,24 +76,30 @@ vi.mock('@mantine/core', () => ({
</button>
),
Card: ({ children, onClick, style }) => (
<div data-testid="card" onClick={onClick} style={style}>{children}</div>
<div data-testid="card" onClick={onClick} style={style}>
{children}
</div>
),
Flex: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Image: ({ src, alt, fallbackSrc }) => (
<img src={src} alt={alt} data-fallback={fallbackSrc} />
),
Modal: ({ children, opened, onClose, title, size }) =>
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>×</button>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, size, c, fw, style }) => (
<span data-size={size} data-color={c} data-fw={fw} style={style}>{children}</span>
<span data-size={size} data-color={c} data-fw={fw} style={style}>
{children}
</span>
),
Textarea: ({ label, value, onChange, placeholder, ...props }) => (
<div>
@ -207,7 +215,9 @@ const setupMocks = ({ recording = makeRecording(), now = NOW } = {}) => {
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.getRecordingUrl).mockReturnValue(
'/recordings/test.ts'
);
vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('');
vi.mocked(RecordingCardUtils.getShowVideoUrl).mockReturnValue('/live/ch-1');
@ -234,10 +244,16 @@ describe('RecordingDetailsModal', () => {
beforeEach(() => {
vi.clearAllMocks();
setupMocks();
vi.mocked(RecordingDetailsModalUtils.updateRecordingMetadata).mockResolvedValue(undefined);
vi.mocked(RecordingDetailsModalUtils.refreshArtwork).mockResolvedValue(undefined);
vi.mocked(
RecordingDetailsModalUtils.updateRecordingMetadata
).mockResolvedValue(undefined);
vi.mocked(RecordingDetailsModalUtils.refreshArtwork).mockResolvedValue(
undefined
);
vi.mocked(RecordingCardUtils.runComSkip).mockResolvedValue(undefined);
vi.mocked(RecordingCardUtils.deleteRecordingById).mockResolvedValue(undefined);
vi.mocked(RecordingCardUtils.deleteRecordingById).mockResolvedValue(
undefined
);
});
// Visibility
@ -294,14 +310,19 @@ describe('RecordingDetailsModal', () => {
it('renders the poster image', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
expect(screen.getByAltText('Test Show')).toHaveAttribute('src', '/poster.jpg');
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(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
[mockEpisode]
);
vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('S01E02');
render(
@ -412,7 +433,10 @@ describe('RecordingDetailsModal', () => {
describe('Edit button', () => {
it('renders the Edit button', () => {
const futureRecording = makeRecording({ start_time: FUTURE, end_time: FUTURE });
const futureRecording = makeRecording({
start_time: FUTURE,
end_time: FUTURE,
});
setupMocks({ recording: futureRecording });
render(
<RecordingDetailsModal
@ -424,7 +448,10 @@ describe('RecordingDetailsModal', () => {
});
it('calls onEdit when Edit is clicked', () => {
const futureRecording = makeRecording({ start_time: FUTURE, end_time: FUTURE });
const futureRecording = makeRecording({
start_time: FUTURE,
end_time: FUTURE,
});
setupMocks({ recording: futureRecording });
const onEdit = vi.fn();
render(
@ -445,27 +472,37 @@ describe('RecordingDetailsModal', () => {
it('shows edit inputs when pencil icon is clicked', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
expect(screen.getByTestId('textinput-Recording title')).toBeInTheDocument();
expect(
screen.getByTestId('textinput-Recording title')
).toBeInTheDocument();
});
it('pre-fills title input with current title', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
expect(screen.getByTestId('textinput-Recording title')).toHaveValue('Test Show');
expect(screen.getByTestId('textinput-Recording title')).toHaveValue(
'Test Show'
);
});
it('pre-fills description input with current description', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
expect(screen.getByTestId('textarea-Description (optional)')).toHaveValue('A test description');
expect(screen.getByTestId('textarea-Description (optional)')).toHaveValue(
'A test description'
);
});
it('cancels editing when X icon is clicked', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
expect(screen.getByTestId('textinput-Recording title')).toBeInTheDocument();
expect(
screen.getByTestId('textinput-Recording title')
).toBeInTheDocument();
fireEvent.click(screen.getByTestId('icon-x').closest('button'));
expect(screen.queryByTestId('textinput-Recording title')).not.toBeInTheDocument();
expect(
screen.queryByTestId('textinput-Recording title')
).not.toBeInTheDocument();
});
it('calls updateRecordingMetadata with new title and description on save', async () => {
@ -753,18 +790,17 @@ describe('RecordingDetailsModal', () => {
target: { value: 'Custom' },
});
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => expect(screen.getByText('Custom')).toBeInTheDocument());
await waitFor(() =>
expect(screen.getByText('Custom')).toBeInTheDocument()
);
// Switch to a different recording
const newRecording = makeRecording({ id: 'rec-2' });
setupMocks({ recording: newRecording });
rerender(
<RecordingDetailsModal
{...defaultProps()}
recording={newRecording}
/>
<RecordingDetailsModal {...defaultProps()} recording={newRecording} />
);
expect(screen.getByText(/Test Show/i)).toBeInTheDocument();
});
});
});
});

View file

@ -59,12 +59,10 @@ vi.mock('@mantine/form', async () => {
setValuesState((prev) => ({ ...prev, [k]: v }));
}),
})),
onSubmit: vi.fn(
(handler) => (e) => {
e?.preventDefault?.();
return handler(values);
}
),
onSubmit: vi.fn((handler) => (e) => {
e?.preventDefault?.();
return handler(values);
}),
reset: vi.fn(() => setValuesState({ ...initialValues })),
setValues: vi.fn((newVals) =>
setValuesState((prev) => ({ ...prev, ...newVals }))
@ -84,7 +82,16 @@ vi.mock('@mantine/core', () => ({
{children}
</span>
),
Button: ({ children, onClick, loading, type, variant, color, size, disabled }) => (
Button: ({
children,
onClick,
loading,
type,
variant,
color,
size,
disabled,
}) => (
<button
type={type}
onClick={onClick}
@ -173,8 +180,10 @@ vi.mock('@mantine/dates', () => ({
<label>{label}</label>
<input
data-testid={`datepicker-${label}`}
value={value ? value.toISOString?.() ?? String(value) : ''}
onChange={(e) => onChange(e.target.value ? new Date(e.target.value) : null)}
value={value ? (value.toISOString?.() ?? String(value)) : ''}
onChange={(e) =>
onChange(e.target.value ? new Date(e.target.value) : null)
}
/>
</div>
),
@ -286,7 +295,9 @@ const setupMocks = ({ rule = makeRule(), occurrences = [] } = {}) => {
enabled: rule?.enabled ?? true,
});
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([makeChannel()]);
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([
makeChannel(),
]);
vi.mocked(RecurringRuleModalUtils.getFormDefaults).mockReturnValue({
channel_id: rule?.channel_id ?? '',
@ -299,10 +310,18 @@ const setupMocks = ({ rule = makeRule(), occurrences = [] } = {}) => {
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(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 };
@ -339,13 +358,17 @@ describe('RecurringRuleModal', () => {
it('uses rule name as modal title', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Morning News');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Morning News'
);
});
it('falls back to "Recurring Rule" when rule has no name', () => {
setupMocks({ rule: makeRule({ name: '' }) });
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Recurring Rule');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Recurring Rule'
);
});
it('calls onClose when modal close button is clicked', () => {
@ -366,16 +389,15 @@ describe('RecurringRuleModal', () => {
it('renders fallback message when rule does not exist', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(
screen.getByText('The recurring rule for this recording no longer exists.')
screen.getByText(
'The recurring rule for this recording no longer exists.'
)
).toBeInTheDocument();
});
it('shows Delete Recording button when sourceRecording is provided', () => {
render(
<RecurringRuleModal
{...defaultProps()}
recording={makeRecording()}
/>
<RecurringRuleModal {...defaultProps()} recording={makeRecording()} />
);
expect(screen.getByText('Delete Recording')).toBeInTheDocument();
});
@ -407,7 +429,10 @@ describe('RecurringRuleModal', () => {
fireEvent.click(screen.getByText('Delete Recording'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Recording deleted', color: 'green' })
expect.objectContaining({
title: 'Recording deleted',
color: 'green',
})
);
});
});
@ -571,10 +596,9 @@ describe('RecurringRuleModal', () => {
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.submit(screen.getByText('Save changes').closest('form'));
await waitFor(() => {
expect(RecurringRuleModalUtils.updateRecurringRule).toHaveBeenCalledWith(
'rule-1',
expect.any(Object)
);
expect(
RecurringRuleModalUtils.updateRecurringRule
).toHaveBeenCalledWith('rule-1', expect.any(Object));
});
});
@ -623,12 +647,17 @@ describe('RecurringRuleModal', () => {
it('Save changes button shows loading state while saving', async () => {
let resolve;
vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockReturnValue(
new Promise((r) => { resolve = r; })
new Promise((r) => {
resolve = r;
})
);
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.submit(screen.getByText('Save changes').closest('form'));
await waitFor(() => {
expect(screen.getByText('Save changes')).toHaveAttribute('data-loading', 'true');
expect(screen.getByText('Save changes')).toHaveAttribute(
'data-loading',
'true'
);
});
resolve();
});
@ -646,9 +675,9 @@ describe('RecurringRuleModal', () => {
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Delete rule'));
await waitFor(() => {
expect(RecurringRuleModalUtils.deleteRecurringRuleById).toHaveBeenCalledWith(
'rule-1'
);
expect(
RecurringRuleModalUtils.deleteRecurringRuleById
).toHaveBeenCalledWith('rule-1');
});
});
@ -684,9 +713,9 @@ describe('RecurringRuleModal', () => {
});
it('does not show notification when deleteRecurringRuleById throws', async () => {
vi.mocked(RecurringRuleModalUtils.deleteRecurringRuleById).mockRejectedValue(
new Error('fail')
);
vi.mocked(
RecurringRuleModalUtils.deleteRecurringRuleById
).mockRejectedValue(new Error('fail'));
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Delete rule'));
await waitFor(() => {
@ -805,7 +834,9 @@ describe('RecurringRuleModal', () => {
it('calls getUpcomingOccurrences with recordings, userNow, ruleId, toUserTime', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(RecurringRuleModalUtils.getUpcomingOccurrences).toHaveBeenCalledWith(
expect(
RecurringRuleModalUtils.getUpcomingOccurrences
).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Function),
'rule-1',
@ -838,10 +869,7 @@ describe('RecurringRuleModal', () => {
const occ = makeOccurrence();
setupMocks({ occurrences: [occ] });
render(
<RecurringRuleModal
{...defaultProps()}
onEditOccurrence={undefined}
/>
<RecurringRuleModal {...defaultProps()} onEditOccurrence={undefined} />
);
expect(() => fireEvent.click(screen.getByText('Edit'))).not.toThrow();
});
@ -888,13 +916,18 @@ describe('RecurringRuleModal', () => {
it('shows loading state on Cancel button while deleting occurrence', async () => {
let resolve;
vi.mocked(deleteRecordingById).mockReturnValue(
new Promise((r) => { resolve = r; })
new Promise((r) => {
resolve = r;
})
);
setupMocks({ occurrences: [makeOccurrence()] });
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(screen.getByText('Cancel')).toHaveAttribute('data-loading', 'true');
expect(screen.getByText('Cancel')).toHaveAttribute(
'data-loading',
'true'
);
});
resolve();
});
@ -934,4 +967,3 @@ describe('RecurringRuleModal', () => {
});
});
});

View file

@ -4,7 +4,15 @@ import ScheduleInput from '../ScheduleInput';
// Mantine core
vi.mock('@mantine/core', () => ({
TextInput: ({ label, placeholder, description, value, onChange, error, disabled }) => (
TextInput: ({
label,
placeholder,
description,
value,
onChange,
error,
disabled,
}) => (
<div>
<label>{typeof label === 'string' ? label : 'Cron Expression'}</label>
<input
@ -30,11 +38,17 @@ vi.mock('@mantine/core', () => ({
min={min}
disabled={disabled}
/>
{description && <span data-testid="interval-description">{description}</span>}
{description && (
<span data-testid="interval-description">{description}</span>
)}
</div>
),
Anchor: ({ children, onClick }) => (
<a data-testid={`anchor-${String(children).replace(/\s+/g, '-').toLowerCase()}`} onClick={onClick} href="#">
<a
data-testid={`anchor-${String(children).replace(/\s+/g, '-').toLowerCase()}`}
onClick={onClick}
href="#"
>
{children}
</a>
),
@ -44,9 +58,13 @@ vi.mock('@mantine/core', () => ({
Group: ({ children }) => <div>{children}</div>,
Popover: ({ children }) => <div>{children}</div>,
PopoverTarget: ({ children }) => <div>{children}</div>,
PopoverDropdown: ({ children }) => <div data-testid="popover-dropdown">{children}</div>,
PopoverDropdown: ({ children }) => (
<div data-testid="popover-dropdown">{children}</div>
),
ActionIcon: ({ children, onClick }) => (
<button data-testid="action-icon-info" onClick={onClick}>{children}</button>
<button data-testid="action-icon-info" onClick={onClick}>
{children}
</button>
),
}));
@ -66,7 +84,10 @@ vi.mock('../CronBuilder', () => ({
opened ? (
<div data-testid="cron-builder">
<span data-testid="cron-builder-value">{currentValue}</span>
<button data-testid="cron-builder-apply" onClick={() => onApply('0 3 * * *')}>
<button
data-testid="cron-builder-apply"
onClick={() => onApply('0 3 * * *')}
>
Apply
</button>
<button data-testid="cron-builder-close" onClick={onClose}>
@ -122,7 +143,12 @@ describe('ScheduleInput', () => {
});
it('renders custom intervalLabel', () => {
render(<ScheduleInput {...defaultIntervalProps()} intervalLabel="My Custom Label" />);
render(
<ScheduleInput
{...defaultIntervalProps()}
intervalLabel="My Custom Label"
/>
);
expect(screen.getByText('My Custom Label')).toBeInTheDocument();
});
@ -144,7 +170,9 @@ describe('ScheduleInput', () => {
it('shows "Use cron schedule" link in interval mode', () => {
render(<ScheduleInput {...defaultIntervalProps()} />);
expect(screen.getByTestId('anchor-use-cron-schedule')).toBeInTheDocument();
expect(
screen.getByTestId('anchor-use-cron-schedule')
).toBeInTheDocument();
});
it('calls onScheduleTypeChange("cron") when "Use cron schedule" is clicked', () => {
@ -156,7 +184,9 @@ describe('ScheduleInput', () => {
it('does not show cron switch link when disabled', () => {
render(<ScheduleInput {...defaultIntervalProps()} disabled />);
expect(screen.queryByTestId('anchor-use-cron-schedule')).not.toBeInTheDocument();
expect(
screen.queryByTestId('anchor-use-cron-schedule')
).not.toBeInTheDocument();
});
it('disables the NumberInput when disabled prop is true', () => {
@ -166,7 +196,10 @@ describe('ScheduleInput', () => {
it('renders custom switchToCronLabel', () => {
render(
<ScheduleInput {...defaultIntervalProps()} switchToCronLabel="Use custom cron schedule" />
<ScheduleInput
{...defaultIntervalProps()}
switchToCronLabel="Use custom cron schedule"
/>
);
expect(screen.getByText('Use custom cron schedule')).toBeInTheDocument();
});
@ -182,7 +215,9 @@ describe('ScheduleInput', () => {
</ScheduleInput>
);
expect(screen.getByTestId('custom-child')).toBeInTheDocument();
expect(screen.queryByTestId('numberinput-interval')).not.toBeInTheDocument();
expect(
screen.queryByTestId('numberinput-interval')
).not.toBeInTheDocument();
});
it('shows cron toggle link below children', () => {
@ -191,7 +226,9 @@ describe('ScheduleInput', () => {
<div>Child</div>
</ScheduleInput>
);
expect(screen.getByTestId('anchor-use-cron-schedule')).toBeInTheDocument();
expect(
screen.getByTestId('anchor-use-cron-schedule')
).toBeInTheDocument();
});
it('does not show cron link when disabled with children', () => {
@ -200,7 +237,9 @@ describe('ScheduleInput', () => {
<div>Child</div>
</ScheduleInput>
);
expect(screen.queryByTestId('anchor-use-cron-schedule')).not.toBeInTheDocument();
expect(
screen.queryByTestId('anchor-use-cron-schedule')
).not.toBeInTheDocument();
});
});
@ -219,12 +258,16 @@ describe('ScheduleInput', () => {
it('shows "Use interval schedule" link in cron mode', () => {
render(<ScheduleInput {...defaultCronProps()} />);
expect(screen.getByTestId('anchor-use-interval-schedule')).toBeInTheDocument();
expect(
screen.getByTestId('anchor-use-interval-schedule')
).toBeInTheDocument();
});
it('shows "Open Cron Builder" link', () => {
render(<ScheduleInput {...defaultCronProps()} />);
expect(screen.getByTestId('anchor-open-cron-builder')).toBeInTheDocument();
expect(
screen.getByTestId('anchor-open-cron-builder')
).toBeInTheDocument();
});
it('calls onScheduleTypeChange("interval") and onCronChange("") when switching to interval', () => {
@ -251,13 +294,20 @@ describe('ScheduleInput', () => {
it('does not show cron links when disabled', () => {
render(<ScheduleInput {...defaultCronProps()} disabled />);
expect(screen.queryByTestId('anchor-use-interval-schedule')).not.toBeInTheDocument();
expect(screen.queryByTestId('anchor-open-cron-builder')).not.toBeInTheDocument();
expect(
screen.queryByTestId('anchor-use-interval-schedule')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('anchor-open-cron-builder')
).not.toBeInTheDocument();
});
it('renders custom switchToIntervalLabel', () => {
render(
<ScheduleInput {...defaultCronProps()} switchToIntervalLabel="Use simple schedule" />
<ScheduleInput
{...defaultCronProps()}
switchToIntervalLabel="Use simple schedule"
/>
);
expect(screen.getByText('Use simple schedule')).toBeInTheDocument();
});
@ -279,7 +329,9 @@ describe('ScheduleInput', () => {
});
render(<ScheduleInput {...defaultCronProps()} cronValue="bad cron" />);
await waitFor(() => {
expect(screen.getByTestId('cron-error')).toHaveTextContent('Invalid cron expression');
expect(screen.getByTestId('cron-error')).toHaveTextContent(
'Invalid cron expression'
);
});
});
@ -325,7 +377,9 @@ describe('ScheduleInput', () => {
it('passes current cronValue to CronBuilder', () => {
render(<ScheduleInput {...defaultCronProps()} cronValue="0 4 * * *" />);
fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
expect(screen.getByTestId('cron-builder-value')).toHaveTextContent('0 4 * * *');
expect(screen.getByTestId('cron-builder-value')).toHaveTextContent(
'0 4 * * *'
);
});
it('closes CronBuilder when onClose is triggered', () => {
@ -370,4 +424,4 @@ describe('ScheduleInput', () => {
expect(screen.getByTestId('numberinput-interval')).toHaveValue(0);
});
});
});
});

View file

@ -63,9 +63,15 @@ vi.mock('../SeriesRuleEditorModal', () => ({
default: ({ opened, onClose, initialRule, onSaved }) =>
opened ? (
<div data-testid="series-rule-editor">
<span data-testid="editor-rule">{initialRule ? initialRule.title : 'new'}</span>
<button data-testid="editor-close" onClick={onClose}>Close</button>
<button data-testid="editor-save" onClick={onSaved}>Save</button>
<span data-testid="editor-rule">
{initialRule ? initialRule.title : 'new'}
</span>
<button data-testid="editor-close" onClick={onClose}>
Close
</button>
<button data-testid="editor-save" onClick={onSaved}>
Save
</button>
</div>
) : null,
}));
@ -73,7 +79,10 @@ vi.mock('../SeriesRuleEditorModal', () => ({
//
import useChannelsStore from '../../../store/channels.jsx';
import { deleteSeriesAndRule } from '../../../utils/cards/RecordingCardUtils.js';
import { evaluateSeriesRulesByTvgId, fetchRules } from '../../../utils/guideUtils.js';
import {
evaluateSeriesRulesByTvgId,
fetchRules,
} from '../../../utils/guideUtils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
const makeRule = (overrides = {}) => ({
@ -126,7 +135,9 @@ describe('SeriesRecordingModal', () => {
it('renders modal title "Series Recording Rules"', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Series Recording Rules');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Series Recording Rules'
);
});
it('calls onClose when modal close button is clicked', () => {
@ -142,17 +153,23 @@ describe('SeriesRecordingModal', () => {
describe('empty state', () => {
it('shows "No series rules configured" when rules is empty', () => {
render(<SeriesRecordingModal {...defaultProps({ rules: [] })} />);
expect(screen.getByText('No series rules configured')).toBeInTheDocument();
expect(
screen.getByText('No series rules configured')
).toBeInTheDocument();
});
it('shows "No series rules configured" when rules is null', () => {
render(<SeriesRecordingModal {...defaultProps({ rules: null })} />);
expect(screen.getByText('No series rules configured')).toBeInTheDocument();
expect(
screen.getByText('No series rules configured')
).toBeInTheDocument();
});
it('does not show "No series rules configured" when rules are present', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(screen.queryByText('No series rules configured')).not.toBeInTheDocument();
expect(
screen.queryByText('No series rules configured')
).not.toBeInTheDocument();
});
});
@ -167,7 +184,9 @@ describe('SeriesRecordingModal', () => {
it('falls back to tvg_id when rule has no title', () => {
render(
<SeriesRecordingModal
{...defaultProps({ rules: [makeRule({ title: '', tvg_id: 'tvg-fallback' })] })}
{...defaultProps({
rules: [makeRule({ title: '', tvg_id: 'tvg-fallback' })],
})}
/>
);
expect(screen.getByText('tvg-fallback')).toBeInTheDocument();
@ -214,19 +233,29 @@ describe('SeriesRecordingModal', () => {
describe('rule summary', () => {
it('shows "New episodes" for mode new', () => {
render(<SeriesRecordingModal {...defaultProps({ rules: [makeRule({ mode: 'new' })] })} />);
render(
<SeriesRecordingModal
{...defaultProps({ rules: [makeRule({ mode: 'new' })] })}
/>
);
expect(screen.getByText(/New episodes/)).toBeInTheDocument();
});
it('shows "Every episode" for mode all', () => {
render(<SeriesRecordingModal {...defaultProps({ rules: [makeRule({ mode: 'all' })] })} />);
render(
<SeriesRecordingModal
{...defaultProps({ rules: [makeRule({ mode: 'all' })] })}
/>
);
expect(screen.getByText(/Every episode/)).toBeInTheDocument();
});
it('shows exact title label in summary', () => {
render(
<SeriesRecordingModal
{...defaultProps({ rules: [makeRule({ title_mode: 'exact', title: 'Test Show' })] })}
{...defaultProps({
rules: [makeRule({ title_mode: 'exact', title: 'Test Show' })],
})}
/>
);
expect(screen.getByText(/Exact title: "Test Show"/)).toBeInTheDocument();
@ -235,7 +264,9 @@ describe('SeriesRecordingModal', () => {
it('shows contains label in summary', () => {
render(
<SeriesRecordingModal
{...defaultProps({ rules: [makeRule({ title_mode: 'contains', title: 'Test' })] })}
{...defaultProps({
rules: [makeRule({ title_mode: 'contains', title: 'Test' })],
})}
/>
);
expect(screen.getByText(/Title contains: "Test"/)).toBeInTheDocument();
@ -244,7 +275,9 @@ describe('SeriesRecordingModal', () => {
it('shows regex label in summary', () => {
render(
<SeriesRecordingModal
{...defaultProps({ rules: [makeRule({ title_mode: 'regex', title: '^Test' })] })}
{...defaultProps({
rules: [makeRule({ title_mode: 'regex', title: '^Test' })],
})}
/>
);
expect(screen.getByText(/Title regex: "\^Test"/)).toBeInTheDocument();
@ -289,7 +322,10 @@ describe('SeriesRecordingModal', () => {
fireEvent.click(screen.getByText('Evaluate Now'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Evaluated', message: 'Checked for episodes' })
expect.objectContaining({
title: 'Evaluated',
message: 'Checked for episodes',
})
);
});
});
@ -377,7 +413,9 @@ describe('SeriesRecordingModal', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add rule'));
fireEvent.click(screen.getByTestId('editor-close'));
expect(screen.queryByTestId('series-rule-editor')).not.toBeInTheDocument();
expect(
screen.queryByTestId('series-rule-editor')
).not.toBeInTheDocument();
});
});
@ -414,4 +452,4 @@ describe('SeriesRecordingModal', () => {
});
});
});
});
});

View file

@ -90,14 +90,7 @@ vi.mock('@mantine/core', () => ({
))}
</div>
),
Select: ({
label,
placeholder,
data,
value,
onChange,
description,
}) => (
Select: ({ label, placeholder, data, value, onChange, description }) => (
<div>
<label>{label}</label>
{description && (
@ -156,7 +149,10 @@ 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 {
createSeriesRule,
evaluateSeriesRulesByTvgId,
} from '../../../utils/guideUtils.js';
import {
getTvgOptions,
previewSeriesRule,
@ -216,7 +212,11 @@ const setupMocks = () => {
vi.mocked(getTvgOptions).mockReturnValue(mockTvgOptions);
vi.mocked(getChannelsSummary).mockResolvedValue(mockChannels);
vi.mocked(previewSeriesRule).mockResolvedValue({ matches: [], total: 0, epg_found: true });
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);
@ -260,7 +260,9 @@ describe('SeriesRuleEditorModal', () => {
it('shows "New Series Rule" title when initialRule is null', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent('New Series Rule');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'New Series Rule'
);
});
it('shows "Edit Series Rule" title when initialRule is provided', () => {
@ -270,7 +272,9 @@ describe('SeriesRuleEditorModal', () => {
initialRule={{ tvg_id: 'tvg-1', mode: 'all', title: 'Test' }}
/>
);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Edit Series Rule');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Edit Series Rule'
);
});
it('renders the title input', () => {
@ -325,21 +329,29 @@ describe('SeriesRuleEditorModal', () => {
};
it('pre-fills title from initialRule', () => {
render(<SeriesRuleEditorModal {...defaultProps} initialRule={initialRule} />);
render(
<SeriesRuleEditorModal {...defaultProps} initialRule={initialRule} />
);
expect(screen.getByTestId('input-title')).toHaveValue('Pre-filled Title');
});
it('pre-fills description from initialRule', () => {
render(<SeriesRuleEditorModal {...defaultProps} initialRule={initialRule} />);
expect(screen.getByTestId('textarea-description')).toHaveValue('Pre-filled description');
render(
<SeriesRuleEditorModal {...defaultProps} initialRule={initialRule} />
);
expect(screen.getByTestId('textarea-description')).toHaveValue(
'Pre-filled description'
);
});
it('uses default mode "all" when initialRule has no mode', () => {
render(<SeriesRuleEditorModal {...defaultProps} initialRule={{ title: 'X' }} />);
// "All" segmented button should be active (data-active=true)
const allBtn = screen.getAllByText('All').find(
(el) => el.closest('[data-testid="segmented-control"]')
render(
<SeriesRuleEditorModal {...defaultProps} initialRule={{ title: 'X' }} />
);
// "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();
});
@ -348,10 +360,18 @@ describe('SeriesRuleEditorModal', () => {
<SeriesRuleEditorModal {...defaultProps} initialRule={initialRule} />
);
rerender(
<SeriesRuleEditorModal {...defaultProps} opened={false} initialRule={null} />
<SeriesRuleEditorModal
{...defaultProps}
opened={false}
initialRule={null}
/>
);
rerender(
<SeriesRuleEditorModal {...defaultProps} opened={true} initialRule={null} />
<SeriesRuleEditorModal
{...defaultProps}
opened={true}
initialRule={null}
/>
);
expect(screen.getByTestId('input-title')).toHaveValue('');
expect(screen.getByTestId('textarea-description')).toHaveValue('');
@ -374,7 +394,9 @@ describe('SeriesRuleEditorModal', () => {
});
it('handles getChannelsSummary rejection gracefully', async () => {
vi.mocked(getChannelsSummary).mockRejectedValue(new Error('Network error'));
vi.mocked(getChannelsSummary).mockRejectedValue(
new Error('Network error')
);
render(<SeriesRuleEditorModal {...defaultProps} />);
// Should not throw; channel options just empty
await waitFor(() => {
@ -439,7 +461,11 @@ describe('SeriesRuleEditorModal', () => {
});
it('renders preview badge with match count', async () => {
vi.mocked(previewSeriesRule).mockResolvedValue({ matches: [{ id: 'p1', title: 'X' }], total: 5, epg_found: true });
vi.mocked(previewSeriesRule).mockResolvedValue({
matches: [{ id: 'p1', title: 'X' }],
total: 5,
epg_found: true,
});
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'X' },
@ -450,7 +476,9 @@ describe('SeriesRuleEditorModal', () => {
});
it('shows preview error alert when previewSeriesRule rejects', async () => {
vi.mocked(previewSeriesRule).mockRejectedValue(new Error('Preview failed'));
vi.mocked(previewSeriesRule).mockRejectedValue(
new Error('Preview failed')
);
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'Boom' },
@ -467,9 +495,16 @@ describe('SeriesRuleEditorModal', () => {
total: 0,
epg_found: false,
});
render(<SeriesRuleEditorModal {...defaultProps} initialRule={{ tvg_id: 'tvg-999', title: 'X' }} />);
render(
<SeriesRuleEditorModal
{...defaultProps}
initialRule={{ tvg_id: 'tvg-999', title: 'X' }}
/>
);
await waitFor(() => {
expect(screen.getByText(/No EPG channel matches this tvg_id/)).toBeInTheDocument();
expect(
screen.getByText(/No EPG channel matches this tvg_id/)
).toBeInTheDocument();
});
});
@ -485,7 +520,9 @@ describe('SeriesRuleEditorModal', () => {
target: { value: 'X' },
});
await waitFor(() => {
expect(screen.getByText(/This rule matches many programs/)).toBeInTheDocument();
expect(
screen.getByText(/This rule matches many programs/)
).toBeInTheDocument();
});
});
@ -603,7 +640,9 @@ describe('SeriesRuleEditorModal', () => {
});
it('does not crash when evaluateSeriesRulesByTvgId throws', async () => {
vi.mocked(evaluateSeriesRulesByTvgId).mockRejectedValue(new Error('eval fail'));
vi.mocked(evaluateSeriesRulesByTvgId).mockRejectedValue(
new Error('eval fail')
);
renderWithTitle();
await expect(
waitFor(() => {
@ -626,7 +665,12 @@ describe('SeriesRuleEditorModal', () => {
it('includes channel_id in payload when channelId is set', async () => {
vi.mocked(getChannelsSummary).mockResolvedValue(mockChannels);
render(<SeriesRuleEditorModal {...defaultProps} initialRule={{ channel_id: 10, title: 'Show' }} />);
render(
<SeriesRuleEditorModal
{...defaultProps}
initialRule={{ channel_id: 10, title: 'Show' }}
/>
);
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(createSeriesRule).toHaveBeenCalledWith(
@ -660,7 +704,11 @@ describe('SeriesRuleEditorModal', () => {
describe('SegmentedControl', () => {
it('changes title mode when SegmentedControl is clicked', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.click(screen.getAllByText('Contains').find((el) => el.closest('[data-testid="segmented-control"]')));
fireEvent.click(
screen
.getAllByText('Contains')
.find((el) => el.closest('[data-testid="segmented-control"]'))
);
// No error thrown; state updated without crash
});
@ -679,4 +727,4 @@ describe('SeriesRuleEditorModal', () => {
});
});
});
});
});

View file

@ -257,7 +257,9 @@ describe('Stream', () => {
describe('default values', () => {
it('pre-fills name from stream prop', () => {
render(<Stream {...defaultProps({ stream: makeStream() })} />);
expect(screen.getByTestId('input-stream-name')).toHaveValue('Test Stream');
expect(screen.getByTestId('input-stream-name')).toHaveValue(
'Test Stream'
);
});
it('pre-fills url from stream prop', () => {
@ -289,7 +291,9 @@ describe('Stream', () => {
it('renders empty group when stream has no channel_group', () => {
render(
<Stream {...defaultProps({ stream: makeStream({ channel_group: null }) })} />
<Stream
{...defaultProps({ stream: makeStream({ channel_group: null }) })}
/>
);
expect(screen.getByTestId('select-group')).toHaveValue('');
});
@ -317,7 +321,9 @@ describe('Stream', () => {
target: { value: 'http://new.stream/live' },
});
fireEvent.submit(screen.getByRole('button', { name: 'Submit' }).closest('form'));
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(StreamUtils.addStream).toHaveBeenCalledWith(

View file

@ -25,7 +25,8 @@ vi.mock('../../../utils/forms/StreamProfileUtils.js', () => ({
],
COMMAND_EXAMPLES: {
ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1',
streamlink: '{streamUrl} --http-header User-Agent={userAgent} best --stdout',
streamlink:
'{streamUrl} --http-header User-Agent={userAgent} best --stdout',
},
}));
@ -99,7 +100,9 @@ vi.mock('@mantine/core', () => ({
data-testid="checkbox-is-active"
type="checkbox"
checked={checked ?? false}
onChange={(e) => onChange({ currentTarget: { checked: e.target.checked } })}
onChange={(e) =>
onChange({ currentTarget: { checked: e.target.checked } })
}
/>
</div>
),
@ -114,7 +117,17 @@ vi.mock('@mantine/core', () => ({
{children}
</div>
) : null,
Select: ({ label, value, onChange, data, placeholder, error, clearable, disabled, description }) => (
Select: ({
label,
value,
onChange,
data,
placeholder,
error,
clearable,
disabled,
description,
}) => (
<div>
<label>{label}</label>
{description && <div>{description}</div>}
@ -135,10 +148,24 @@ vi.mock('@mantine/core', () => ({
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Textarea: ({ label, name, value, onChange, error, placeholder, description, disabled, ...rest }) => (
Textarea: ({
label,
name,
value,
onChange,
error,
placeholder,
description,
disabled,
...rest
}) => (
<div>
<label htmlFor={name}>{label}</label>
{description && <div data-testid={`desc-${label?.replace(/\s+/g, '-').toLowerCase()}`}>{description}</div>}
{description && (
<div data-testid={`desc-${label?.replace(/\s+/g, '-').toLowerCase()}`}>
{description}
</div>
)}
<textarea
id={name}
name={name}
@ -196,7 +223,9 @@ const setupMocks = () => {
sel({ userAgents: mockUserAgents })
);
vi.mocked(StreamProfileUtils.addStreamProfile).mockResolvedValue(undefined);
vi.mocked(StreamProfileUtils.updateStreamProfile).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'];
@ -234,7 +263,9 @@ describe('StreamProfile', () => {
it('renders "Stream Profile" as the modal title', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Stream Profile');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Stream Profile'
);
});
it('calls onClose when modal close button is clicked', () => {
@ -302,7 +333,9 @@ describe('StreamProfile', () => {
it('pre-fills parameters from profile prop', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('textarea-parameters')).toHaveValue('-i {streamUrl}');
expect(screen.getByTestId('textarea-parameters')).toHaveValue(
'-i {streamUrl}'
);
});
it('pre-selects command from profile prop', () => {
@ -345,7 +378,9 @@ describe('StreamProfile', () => {
describe('command selection', () => {
it('does not show Custom Command input when a built-in is selected', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.queryByTestId('input-custom-command')).not.toBeInTheDocument();
expect(
screen.queryByTestId('input-custom-command')
).not.toBeInTheDocument();
});
it('shows Custom Command input when Custom… is selected', () => {
@ -364,14 +399,20 @@ describe('StreamProfile', () => {
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: 'ffmpeg' },
});
expect(screen.queryByTestId('input-custom-command')).not.toBeInTheDocument();
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__');
vi.mocked(StreamProfileUtils.toCommandSelection).mockReturnValue(
'__custom__'
);
render(
<StreamProfile
{...defaultProps({ profile: makeProfile({ command: '/usr/bin/mycmd' }) })}
{...defaultProps({
profile: makeProfile({ command: '/usr/bin/mycmd' }),
})}
/>
);
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
@ -604,4 +645,3 @@ describe('StreamProfile', () => {
});
});
});

View file

@ -191,7 +191,9 @@ describe('SuperuserForm', () => {
fireEvent.change(screen.getByTestId('input-email'), {
target: { name: 'email', value: 'admin@example.com' },
});
expect(screen.getByTestId('input-email')).toHaveValue('admin@example.com');
expect(screen.getByTestId('input-email')).toHaveValue(
'admin@example.com'
);
});
it('initializes all fields as empty strings', () => {
@ -329,4 +331,3 @@ describe('SuperuserForm', () => {
});
});
});

View file

@ -34,7 +34,10 @@ const mockForm = {
setFieldValue: vi.fn(),
reset: vi.fn(),
getValues: vi.fn(() => ({ user_level: '3' })),
onSubmit: vi.fn((fn) => (e) => { e?.preventDefault?.(); return fn(); }),
onSubmit: vi.fn((fn) => (e) => {
e?.preventDefault?.();
return fn();
}),
submitting: false,
};
@ -73,7 +76,9 @@ vi.mock('@mantine/core', () => ({
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>×</button>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
@ -82,29 +87,60 @@ vi.mock('@mantine/core', () => ({
<label>{label}</label>
<input
data-testid={`multiselect-${label}`}
onChange={(e) => onChange?.(e.target.value ? e.target.value.split(',') : [])}
onChange={(e) =>
onChange?.(e.target.value ? e.target.value.split(',') : [])
}
/>
</div>
),
NumberInput: ({ label }) => <div><label>{label}</label><input /></div>,
NumberInput: ({ label }) => (
<div>
<label>{label}</label>
<input />
</div>
),
PasswordInput: ({ label, disabled }) => (
<div><label>{label}</label><input type="password" disabled={disabled} /></div>
<div>
<label>{label}</label>
<input type="password" disabled={disabled} />
</div>
),
Select: ({ label, disabled }) => (
<div><label>{label}</label><select disabled={disabled} /></div>
<div>
<label>{label}</label>
<select disabled={disabled} />
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Switch: ({ label }) => <div><label>{label}</label><input type="checkbox" /></div>,
Switch: ({ label }) => (
<div>
<label>{label}</label>
<input type="checkbox" />
</div>
),
Tabs: ({ children }) => <div>{children}</div>,
TabsList: ({ children }) => <div>{children}</div>,
TabsPanel: ({ children, value }) => <div data-testid={`panel-${value}`}>{children}</div>,
TabsTab: ({ children, value }) => <button data-testid={`tab-${value}`}>{children}</button>,
TagsInput: ({ label }) => <div><label>{label}</label><input /></div>,
TabsPanel: ({ children, value }) => (
<div data-testid={`panel-${value}`}>{children}</div>
),
TabsTab: ({ children, value }) => (
<button data-testid={`tab-${value}`}>{children}</button>
),
TagsInput: ({ label }) => (
<div>
<label>{label}</label>
<input />
</div>
),
Text: ({ children }) => <span>{children}</span>,
TextInput: ({ label, value, disabled, rightSection }) => (
<div>
<label>{label}</label>
<input data-testid={`textinput-${label}`} defaultValue={value} disabled={disabled} />
<input
data-testid={`textinput-${label}`}
defaultValue={value}
disabled={disabled}
/>
{rightSection}
</div>
),
@ -155,9 +191,10 @@ const setupMocks = ({
// Reset form state
mockForm.getValues.mockReturnValue({ user_level: '3' });
mockForm.onSubmit.mockImplementation(
(fn) => (e) => { e?.preventDefault?.(); return fn(); }
);
mockForm.onSubmit.mockImplementation((fn) => (e) => {
e?.preventDefault?.();
return fn();
});
return { mockSetUser };
};
@ -169,7 +206,9 @@ describe('User', () => {
vi.clearAllMocks();
vi.mocked(UserUtils.createUser).mockResolvedValue({});
vi.mocked(UserUtils.updateUser).mockResolvedValue({});
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'new-api-key' });
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({});
@ -214,9 +253,7 @@ describe('User', () => {
it('shows Permissions tab when admin edits another user', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser()} />
);
render(<User isOpen={true} onClose={vi.fn()} user={makeRegularUser()} />);
expect(screen.getByTestId('tab-permissions')).toBeInTheDocument();
});
@ -230,7 +267,11 @@ describe('User', () => {
it('hides Permissions tab for non-admin user', () => {
setupMocks({ authUser: makeRegularUser() });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 99 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 99 })}
/>
);
expect(screen.queryByTestId('tab-permissions')).not.toBeInTheDocument();
});
@ -242,7 +283,11 @@ describe('User', () => {
it('shows Output Format Override for admin', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.getByText('Output Format Override')).toBeInTheDocument();
});
@ -250,15 +295,25 @@ describe('User', () => {
it('hides Output Format Override for non-admin', () => {
setupMocks({ authUser: makeRegularUser() });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 5 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 5 })}
/>
);
expect(screen.queryByText('Output Format Override')).not.toBeInTheDocument();
expect(
screen.queryByText('Output Format Override')
).not.toBeInTheDocument();
});
it('shows Output Profile Override for admin', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.getByText('Output Profile Override')).toBeInTheDocument();
});
@ -266,7 +321,11 @@ describe('User', () => {
it('shows Allowed IPs for admin', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.getByText('Allowed IPs')).toBeInTheDocument();
});
@ -274,7 +333,11 @@ describe('User', () => {
it('hides Allowed IPs for non-admin', () => {
setupMocks({ authUser: makeRegularUser() });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 5 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 5 })}
/>
);
expect(screen.queryByText('Allowed IPs')).not.toBeInTheDocument();
});
@ -286,7 +349,11 @@ describe('User', () => {
it('shows "Generate API Key" when no key exists', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
});
@ -305,10 +372,16 @@ describe('User', () => {
it('calls generateApiKey and switches button to "Regenerate API Key"', async () => {
setupMocks({ authUser: makeAdminUser() });
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'brand-new-key' });
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({
key: 'brand-new-key',
});
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
@ -322,10 +395,16 @@ describe('User', () => {
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' });
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({
raw_key: 'raw-value',
});
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
fireEvent.click(screen.getByText('Generate API Key'));
@ -339,7 +418,11 @@ describe('User', () => {
// non-admin editing someone else
setupMocks({ authUser: makeRegularUser({ id: 5 }) });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
// No button visible since canGenerateKey is false
expect(screen.queryByText('Generate API Key')).not.toBeInTheDocument();
@ -351,7 +434,11 @@ describe('User', () => {
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({});
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
fireEvent.click(screen.getByText('Generate API Key'));
@ -367,7 +454,11 @@ describe('User', () => {
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'show-me' });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
fireEvent.click(screen.getByText('Generate API Key'));
@ -397,7 +488,11 @@ describe('User', () => {
it('hides "Revoke API Key" when no key exists', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.queryByText('Revoke API Key')).not.toBeInTheDocument();
});
@ -455,7 +550,7 @@ describe('User', () => {
});
});
it('does not update auth store when revoking another user\'s key', async () => {
it("does not update auth store when revoking another user's key", async () => {
const admin = makeAdminUser({ id: 1 });
const { mockSetUser } = setupMocks({ authUser: admin });
@ -518,7 +613,11 @@ describe('User', () => {
vi.mocked(UserUtils.updateUser).mockResolvedValue({ id: 2 });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
fireEvent.click(screen.getByText('Save'));
@ -569,7 +668,11 @@ describe('User', () => {
vi.mocked(UserUtils.updateUser).mockResolvedValue({ id: 2 });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
fireEvent.click(screen.getByText('Save'));
@ -585,7 +688,9 @@ describe('User', () => {
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' });
vi.mocked(UserUtils.userToFormValues).mockReturnValue({
username: 'user1',
});
setupMocks({ authUser: makeAdminUser() });
render(<User isOpen={true} onClose={vi.fn()} user={user} />);
@ -607,7 +712,11 @@ describe('User', () => {
it('calls setValues with a generated xc_password when rotate icon is clicked', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
const rotateButton = screen
@ -623,7 +732,11 @@ describe('User', () => {
it('disables the XC password rotate button for non-admin', () => {
setupMocks({ authUser: makeRegularUser() });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 5 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 5 })}
/>
);
const rotateButton = screen
@ -641,7 +754,11 @@ describe('User', () => {
it('excludes "All" (0) when other profiles are selected alongside it', () => {
setupMocks({ authUser: makeAdminUser(), profiles });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
@ -656,31 +773,38 @@ describe('User', () => {
it('sets only ["0"] when "All" is newly selected', () => {
setupMocks({ authUser: makeAdminUser(), profiles });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
fireEvent.change(multiSelect, { target: { value: '0' } });
expect(mockForm.setFieldValue).toHaveBeenCalledWith(
'channel_profiles',
['0']
);
expect(mockForm.setFieldValue).toHaveBeenCalledWith('channel_profiles', [
'0',
]);
});
it('allows multiple non-all profiles together', () => {
setupMocks({ authUser: makeAdminUser(), profiles });
render(
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
fireEvent.change(multiSelect, { target: { value: '1,2' } });
expect(mockForm.setFieldValue).toHaveBeenCalledWith(
'channel_profiles',
['1', '2']
);
expect(mockForm.setFieldValue).toHaveBeenCalledWith('channel_profiles', [
'1',
'2',
]);
});
});
});
});

View file

@ -31,7 +31,9 @@ vi.mock('@mantine/core', () => ({
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>×</button>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
@ -105,23 +107,37 @@ describe('UserAgent', () => {
it('pre-fills fields from userAgent prop', () => {
render(
<UserAgent isOpen={true} onClose={vi.fn()} userAgent={makeUserAgent()} />
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent()}
/>
);
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');
expect(screen.getByTestId('input-Description')).toHaveValue(
'Chrome browser'
);
});
it('checks Is Active checkbox when userAgent.is_active is true', () => {
render(
<UserAgent isOpen={true} onClose={vi.fn()} userAgent={makeUserAgent({ is_active: true })} />
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent({ is_active: true })}
/>
);
expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
});
it('unchecks Is Active checkbox when userAgent.is_active is false', () => {
render(
<UserAgent isOpen={true} onClose={vi.fn()} userAgent={makeUserAgent({ is_active: false })} />
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent({ is_active: false })}
/>
);
expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
});
@ -137,7 +153,11 @@ describe('UserAgent', () => {
describe('field interactions', () => {
it('toggles Is Active checkbox', () => {
render(
<UserAgent isOpen={true} onClose={vi.fn()} userAgent={makeUserAgent({ is_active: true })} />
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent({ is_active: true })}
/>
);
const checkbox = screen.getByTestId('checkbox-is-active');
fireEvent.click(checkbox);
@ -158,7 +178,11 @@ describe('UserAgent', () => {
it('calls updateUserAgent with the id when editing an existing userAgent', async () => {
render(
<UserAgent isOpen={true} onClose={vi.fn()} userAgent={makeUserAgent()} />
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent()}
/>
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
@ -171,7 +195,11 @@ describe('UserAgent', () => {
it('does not call addUserAgent when updating', async () => {
render(
<UserAgent isOpen={true} onClose={vi.fn()} userAgent={makeUserAgent()} />
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent()}
/>
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
@ -214,7 +242,11 @@ describe('UserAgent', () => {
describe('form reset on userAgent change', () => {
it('resets form values when userAgent prop changes', () => {
const { rerender } = render(
<UserAgent isOpen={true} onClose={vi.fn()} userAgent={makeUserAgent()} />
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent()}
/>
);
expect(screen.getByTestId('input-Name')).toHaveValue('Chrome');
@ -222,7 +254,11 @@ describe('UserAgent', () => {
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent({ id: 2, name: 'Firefox', user_agent: 'Gecko/20100101' })}
userAgent={makeUserAgent({
id: 2,
name: 'Firefox',
user_agent: 'Gecko/20100101',
})}
/>
);
expect(screen.getByTestId('input-Name')).toHaveValue('Firefox');
@ -230,10 +266,14 @@ describe('UserAgent', () => {
it('resets to empty when userAgent prop is removed', () => {
const { rerender } = render(
<UserAgent isOpen={true} onClose={vi.fn()} userAgent={makeUserAgent()} />
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent()}
/>
);
rerender(<UserAgent isOpen={true} onClose={vi.fn()} userAgent={null} />);
expect(screen.getByTestId('input-Name')).toHaveValue('');
});
});
});
});

View file

@ -7,14 +7,19 @@ vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() }));
// lucide-react
vi.mock('lucide-react', () => ({
CircleCheck: () => <svg data-testid="icon-circle-check" />,
CircleX: () => <svg data-testid="icon-circle-x" />,
CircleX: () => <svg data-testid="icon-circle-x" />,
}));
// Mantine core
vi.mock('@mantine/core', () => ({
Box: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, disabled, color, variant }) => (
<button onClick={onClick} disabled={disabled} data-color={color} data-variant={variant}>
Box: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, disabled, color, variant }) => (
<button
onClick={onClick}
disabled={disabled}
data-color={color}
data-variant={variant}
>
{children}
</button>
),
@ -24,17 +29,19 @@ vi.mock('@mantine/core', () => ({
type="checkbox"
aria-label={label}
checked={checked}
onChange={(e) => onChange?.({ currentTarget: { checked: e.target.checked } })}
onChange={(e) =>
onChange?.({ currentTarget: { checked: e.target.checked } })
}
disabled={disabled}
/>
{label}
</label>
),
Flex: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Flex: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
SimpleGrid: ({ children }) => <div data-testid="simple-grid">{children}</div>,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
TextInput: ({ label, value, onChange, placeholder }) => (
<input
aria-label={label ?? placeholder}
@ -71,18 +78,39 @@ const makeCategories = () => [
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' },
{
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 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;
const dict = Array.isArray(categories)
? categoriesToDict(categories)
: categories;
vi.mocked(useVODStore).mockImplementation((sel) => sel({ categories: dict }));
};
@ -120,13 +148,19 @@ describe('VODCategoryFilter', () => {
it('renders a button for each category matching the type and playlist', () => {
render(<VODCategoryFilter {...defaultProps()} />);
expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Action' })
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Comedy' })
).toBeInTheDocument();
});
it('does not render categories of a different type', () => {
render(<VODCategoryFilter {...defaultProps()} />);
expect(screen.queryByRole('checkbox', { name: 'News' })).not.toBeInTheDocument();
expect(
screen.queryByRole('checkbox', { name: 'News' })
).not.toBeInTheDocument();
});
it('renders the text filter input', () => {
@ -147,7 +181,9 @@ describe('VODCategoryFilter', () => {
it('renders the Auto-enable new groups checkbox', () => {
render(<VODCategoryFilter {...defaultProps()} />);
expect(screen.getByLabelText(/automatically enable new/i)).toBeInTheDocument();
expect(
screen.getByLabelText(/automatically enable new/i)
).toBeInTheDocument();
});
});
@ -158,15 +194,25 @@ describe('VODCategoryFilter', () => {
render(<VODCategoryFilter {...defaultProps()} />);
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();
expect(
screen.getByRole('button', { name: 'Action' })
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Comedy' })
).not.toBeInTheDocument();
});
it('is case-insensitive', () => {
render(<VODCategoryFilter {...defaultProps()} />);
fireEvent.change(screen.getByPlaceholderText(/filter/i), { target: { value: 'COMEDY' } });
expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
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', () => {
@ -174,8 +220,12 @@ describe('VODCategoryFilter', () => {
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: 'Action' })
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Comedy' })
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Drama' })).toBeInTheDocument();
});
@ -184,7 +234,9 @@ describe('VODCategoryFilter', () => {
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
target: { value: 'zzznomatch' },
});
expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Action' })
).not.toBeInTheDocument();
});
});
@ -193,34 +245,58 @@ describe('VODCategoryFilter', () => {
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 } ],
categoryStates: [
{ id: 1, name: 'Action', enabled: true },
{ id: 2, name: 'Comedy', enabled: false },
{ id: 3, name: 'Drama', enabled: true },
],
});
render(<VODCategoryFilter {...props} />);
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: '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 } ],
categoryStates: [
{ id: 1, name: 'Action', enabled: true },
{ id: 2, name: 'Comedy', enabled: false },
{ id: 3, name: 'Drama', enabled: true },
],
});
render(<VODCategoryFilter {...props} />);
fireEvent.click(screen.getByTestId('segment-disabled'));
expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
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 } ],
categoryStates: [
{ id: 1, name: 'Action', enabled: true },
{ id: 2, name: 'Comedy', enabled: false },
{ id: 3, name: 'Drama', enabled: true },
],
});
render(<VODCategoryFilter {...props} />);
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();
expect(
screen.getByRole('button', { name: 'Action' })
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Comedy' })
).toBeInTheDocument();
});
});
@ -230,11 +306,17 @@ describe('VODCategoryFilter', () => {
it('applies both text and status filters simultaneously', () => {
const props = defaultProps();
render(<VODCategoryFilter {...props} />);
fireEvent.change(screen.getByPlaceholderText(/filter/i), { target: { value: 'o' } });
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();
expect(
screen.getByRole('button', { name: 'Comedy' })
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Action' })
).not.toBeInTheDocument();
});
});
@ -247,7 +329,11 @@ describe('VODCategoryFilter', () => {
<VODCategoryFilter
{...defaultProps({
setCategoryStates,
categoryStates: [ { id: 1, name: 'Action', enabled: false }, { id: 2, name: 'Comedy', enabled: false }, { id: 3, name: 'Drama', enabled: false } ],
categoryStates: [
{ id: 1, name: 'Action', enabled: false },
{ id: 2, name: 'Comedy', enabled: false },
{ id: 3, name: 'Drama', enabled: false },
],
})}
/>
);
@ -264,11 +350,17 @@ describe('VODCategoryFilter', () => {
<VODCategoryFilter
{...defaultProps({
setCategoryStates,
categoryStates: [ { id: 1, name: 'Action', enabled: false }, { id: 2, name: 'Comedy', enabled: false }, { id: 3, name: 'Drama', enabled: false } ],
categoryStates: [
{ id: 1, name: 'Action', enabled: false },
{ id: 2, name: 'Comedy', enabled: false },
{ id: 3, name: 'Drama', enabled: false },
],
})}
/>
);
fireEvent.change(screen.getByPlaceholderText(/filter/i), { target: { value: 'act' } });
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);
@ -295,11 +387,17 @@ describe('VODCategoryFilter', () => {
<VODCategoryFilter
{...defaultProps({
setCategoryStates,
categoryStates: [ { id: 1, name: 'Action', enabled: true }, { id: 2, name: 'Comedy', enabled: true }, { id: 3, name: 'Drama', enabled: true } ],
categoryStates: [
{ id: 1, name: 'Action', enabled: true },
{ id: 2, name: 'Comedy', enabled: true },
{ id: 3, name: 'Drama', enabled: true },
],
})}
/>
);
fireEvent.change(screen.getByPlaceholderText(/filter/i), { target: { value: 'comedy' } });
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);
@ -315,7 +413,10 @@ describe('VODCategoryFilter', () => {
const setAutoEnableNewGroups = vi.fn();
render(
<VODCategoryFilter
{...defaultProps({ autoEnableNewGroups: false, setAutoEnableNewGroups })}
{...defaultProps({
autoEnableNewGroups: false,
setAutoEnableNewGroups,
})}
/>
);
fireEvent.click(screen.getByLabelText(/automatically enable new/i));
@ -326,7 +427,10 @@ describe('VODCategoryFilter', () => {
const setAutoEnableNewGroups = vi.fn();
render(
<VODCategoryFilter
{...defaultProps({ autoEnableNewGroups: true, setAutoEnableNewGroups })}
{...defaultProps({
autoEnableNewGroups: true,
setAutoEnableNewGroups,
})}
/>
);
fireEvent.click(screen.getByLabelText(/automatically enable new/i));
@ -340,23 +444,34 @@ describe('VODCategoryFilter', () => {
it('renders no category buttons when categories list is empty', () => {
setupMocks({ categories: [] });
render(<VODCategoryFilter {...defaultProps({ categoryStates: [] })} />);
expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Action' })
).not.toBeInTheDocument();
});
it('renders no category buttons when categoryStates is empty', () => {
render(<VODCategoryFilter {...defaultProps({ categoryStates: [] })} />);
expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
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' },
{
id: 99,
name: 'Foreign',
m3u_accounts: [{ m3u_account: 99, enabled: true }],
category_type: 'movies',
},
],
});
render(<VODCategoryFilter {...defaultProps()} />);
expect(screen.queryByRole('button', { name: 'Foreign' })).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Foreign' })
).not.toBeInTheDocument();
});
});
});
});

View file

@ -25,7 +25,7 @@ export function formatRange(start, end) {
const s = new Date(start);
const e = new Date(end);
if(isNaN(s) || isNaN(e)) throw new Error('Invalid date');
if (isNaN(s) || isNaN(e)) throw new Error('Invalid date');
const sameDay = s.toDateString() === e.toDateString();
const dateStr = s.toLocaleDateString();
@ -81,4 +81,4 @@ export const getChannelOptions = (allChannels, tvgsById, tvgId) => {
}
}
return [...matching, ...others];
};
};

View file

@ -1,6 +1,6 @@
import {NETWORK_ACCESS_OPTIONS, USER_LEVELS} from "../../constants.js";
import { NETWORK_ACCESS_OPTIONS, USER_LEVELS } from '../../constants.js';
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../networkUtils.js';
import API from "../../api.js";
import API from '../../api.js';
const isValidNetworkEntry = (entry) =>
entry.match(IPV4_CIDR_REGEX) ||
@ -123,14 +123,14 @@ export const getFormInitialValues = () => {
epg_prev_days: 0,
allowed_ips: [],
};
}
};
export const getFormValidators = (user) => {
return (values) => ({
username: !values.username
? 'Username is required'
: values.user_level == USER_LEVELS.STREAMER &&
!values.username.match(/^[a-z0-9]+$/i)
!values.username.match(/^[a-z0-9]+$/i)
? 'Streamer username must be alphanumeric'
: null,
password:
@ -141,10 +141,8 @@ export const getFormValidators = (user) => {
values.xc_password && !values.xc_password.match(/^[a-z0-9]+$/i)
? 'XC password must be alphanumeric'
: null,
allowed_ips: (values.allowed_ips || []).some(
(t) => !isValidNetworkEntry(t)
)
allowed_ips: (values.allowed_ips || []).some((t) => !isValidNetworkEntry(t))
? 'Invalid IP address or CIDR range'
: null,
});
}
};

View file

@ -11,7 +11,6 @@ vi.mock('../../../api.js', () => ({
},
}));
describe('RecordingDetailsModalUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
@ -653,7 +652,9 @@ describe('RecordingDetailsModalUtils', () => {
it('should propagate API errors', async () => {
API.getChannel.mockRejectedValue(new Error('Not found'));
await expect(RecordingDetailsModalUtils.getChannel(99)).rejects.toThrow('Not found');
await expect(RecordingDetailsModalUtils.getChannel(99)).rejects.toThrow(
'Not found'
);
});
});
@ -680,7 +681,11 @@ describe('RecordingDetailsModalUtils', () => {
API.updateRecordingMetadata.mockResolvedValue({});
const recording = { id: 5 };
await RecordingDetailsModalUtils.updateRecordingMetadata(recording, '', 'Some description');
await RecordingDetailsModalUtils.updateRecordingMetadata(
recording,
'',
'Some description'
);
expect(API.updateRecordingMetadata).toHaveBeenCalledWith(5, {
title: 'Custom Recording',
@ -692,7 +697,11 @@ describe('RecordingDetailsModalUtils', () => {
API.updateRecordingMetadata.mockResolvedValue({});
const recording = { id: 5 };
await RecordingDetailsModalUtils.updateRecordingMetadata(recording, null, 'desc');
await RecordingDetailsModalUtils.updateRecordingMetadata(
recording,
null,
'desc'
);
expect(API.updateRecordingMetadata).toHaveBeenCalledWith(5, {
title: 'Custom Recording',
@ -704,7 +713,11 @@ describe('RecordingDetailsModalUtils', () => {
API.updateRecordingMetadata.mockRejectedValue(new Error('Server error'));
await expect(
RecordingDetailsModalUtils.updateRecordingMetadata({ id: 1 }, 'Title', 'Desc')
RecordingDetailsModalUtils.updateRecordingMetadata(
{ id: 1 },
'Title',
'Desc'
)
).rejects.toThrow('Server error');
});
});
@ -723,9 +736,9 @@ describe('RecordingDetailsModalUtils', () => {
it('should propagate API errors', async () => {
API.refreshArtwork.mockRejectedValue(new Error('Artwork fetch failed'));
await expect(RecordingDetailsModalUtils.refreshArtwork(7)).rejects.toThrow(
'Artwork fetch failed'
);
await expect(
RecordingDetailsModalUtils.refreshArtwork(7)
).rejects.toThrow('Artwork fetch failed');
});
});
});

View file

@ -85,7 +85,7 @@ describe('RecordingUtils', () => {
});
});
// ─── toIsoIfDate ──────────────────────────────────────────────────────────────
// ─── toIsoIfDate ──────────────────────────────────────────────────────────────
describe('toIsoIfDate', () => {
it('returns ISO string for a valid Date', () => {
@ -107,7 +107,7 @@ describe('RecordingUtils', () => {
});
});
// ─── toDateString ─────────────────────────────────────────────────────────────
// ─── toDateString ─────────────────────────────────────────────────────────────
describe('toDateString', () => {
it('formats a Date to YYYY-MM-DD', () => {
@ -129,7 +129,7 @@ describe('RecordingUtils', () => {
});
});
// ─── createRoundedDate ────────────────────────────────────────────────────────
// ─── createRoundedDate ────────────────────────────────────────────────────────
describe('createRoundedDate', () => {
it('returns a Date', () => {
@ -141,7 +141,7 @@ describe('RecordingUtils', () => {
});
});
// ─── timeChange ───────────────────────────────────────────────────────────────
// ─── timeChange ───────────────────────────────────────────────────────────────
describe('timeChange', () => {
it('calls setter with string directly', () => {
@ -163,7 +163,7 @@ describe('RecordingUtils', () => {
});
});
// ─── API wrappers ─────────────────────────────────────────────────────────────
// ─── API wrappers ─────────────────────────────────────────────────────────────
describe('getChannelsSummary', () => {
it('calls API.getChannelsSummary', () => {
@ -197,13 +197,13 @@ describe('RecordingUtils', () => {
});
});
// ─── sortedChannelOptions ─────────────────────────────────────────────────────
// ─── sortedChannelOptions ─────────────────────────────────────────────────────
describe('sortedChannelOptions', () => {
const channels = [
{ id: 3, name: 'CNN', channel_number: 20 },
{ id: 1, name: 'ESPN', channel_number: 5 },
{ id: 2, name: 'HBO', channel_number: 10 },
{ id: 3, name: 'CNN', channel_number: 20 },
{ id: 1, name: 'ESPN', channel_number: 5 },
{ id: 2, name: 'HBO', channel_number: 10 },
];
it('sorts channels by channel_number ascending', () => {
@ -213,7 +213,9 @@ describe('RecordingUtils', () => {
});
it('maps each channel to { value, label }', () => {
const result = sortedChannelOptions([{ id: 7, name: 'FOX', channel_number: 3 }]);
const result = sortedChannelOptions([
{ id: 7, name: 'FOX', channel_number: 3 },
]);
expect(result[0]).toEqual({ value: '7', label: 'FOX' });
});
@ -269,22 +271,25 @@ describe('RecordingUtils', () => {
});
});
// ─── numberedChannelLabel ─────────────────────────────────────────────────────
// ─── numberedChannelLabel ─────────────────────────────────────────────────────
describe('numberedChannelLabel', () => {
it('returns "number - name" when both are present', () => {
expect(numberedChannelLabel({ id: 1, name: 'HBO', channel_number: 501 }))
.toBe('501 - HBO');
expect(
numberedChannelLabel({ id: 1, name: 'HBO', channel_number: 501 })
).toBe('501 - HBO');
});
it('returns name only when channel_number is falsy', () => {
expect(numberedChannelLabel({ id: 1, name: 'HBO', channel_number: null }))
.toBe('HBO');
expect(
numberedChannelLabel({ id: 1, name: 'HBO', channel_number: null })
).toBe('HBO');
});
it('falls back to "Channel id" when name is missing and number present', () => {
expect(numberedChannelLabel({ id: 5, channel_number: 10 }))
.toBe('10 - Channel 5');
expect(numberedChannelLabel({ id: 5, channel_number: 10 })).toBe(
'10 - Channel 5'
);
});
it('falls back to "Channel id" when both name and number are missing', () => {
@ -292,7 +297,7 @@ describe('RecordingUtils', () => {
});
});
// ─── getSingleFormDefaults ────────────────────────────────────────────────────
// ─── getSingleFormDefaults ────────────────────────────────────────────────────
describe('getSingleFormDefaults', () => {
it('returns defaults with no arguments', () => {
@ -320,7 +325,7 @@ describe('RecordingUtils', () => {
});
});
// ─── getRecurringFormDefaults ─────────────────────────────────────────────────
// ─── getRecurringFormDefaults ─────────────────────────────────────────────────
describe('getRecurringFormDefaults', () => {
it('returns defaults with no arguments', () => {
@ -341,7 +346,7 @@ describe('RecordingUtils', () => {
});
});
// ─── buildSinglePayload ───────────────────────────────────────────────────────
// ─── buildSinglePayload ───────────────────────────────────────────────────────
describe('buildSinglePayload', () => {
it('builds payload from values', () => {
@ -357,7 +362,7 @@ describe('RecordingUtils', () => {
});
});
// ─── buildRecurringPayload ────────────────────────────────────────────────────
// ─── buildRecurringPayload ────────────────────────────────────────────────────
describe('buildRecurringPayload', () => {
it('builds payload from values', () => {
@ -396,7 +401,7 @@ describe('RecordingUtils', () => {
});
});
// ─── singleFormValidators ─────────────────────────────────────────────────────
// ─── singleFormValidators ─────────────────────────────────────────────────────
describe('singleFormValidators', () => {
describe('end_time', () => {
@ -424,12 +429,14 @@ describe('RecordingUtils', () => {
it('returns null when end is after start', () => {
const start = new Date('2024-06-15T09:00:00Z');
const end = new Date('2024-06-15T10:00:00Z');
expect(singleFormValidators.end_time(end, { start_time: start })).toBeNull();
expect(
singleFormValidators.end_time(end, { start_time: start })
).toBeNull();
});
});
});
// ─── recurringFormValidators ──────────────────────────────────────────────────
// ─── recurringFormValidators ──────────────────────────────────────────────────
describe('recurringFormValidators', () => {
describe('days_of_week', () => {
@ -458,9 +465,11 @@ describe('RecordingUtils', () => {
describe('end_time', () => {
it('returns error when value is falsy', () => {
expect(recurringFormValidators.end_time(null, { start_time: '2024-06-15T10:00:00' })).toBe(
'Select an end time'
);
expect(
recurringFormValidators.end_time(null, {
start_time: '2024-06-15T10:00:00',
})
).toBe('Select an end time');
});
it('returns error when end equals start (diff === 0)', () => {
@ -488,15 +497,17 @@ describe('RecordingUtils', () => {
it('returns error when end_date is before start_date', () => {
const start = new Date('2024-06-15');
const end = new Date('2024-06-01');
expect(recurringFormValidators.end_date(end, { start_date: start })).toBe(
'End date cannot be before start date'
);
expect(
recurringFormValidators.end_date(end, { start_date: start })
).toBe('End date cannot be before start date');
});
it('returns null when end_date is on or after start_date', () => {
const start = new Date('2024-06-15');
const end = new Date('2024-06-15');
expect(recurringFormValidators.end_date(end, { start_date: start })).toBeNull();
expect(
recurringFormValidators.end_date(end, { start_date: start })
).toBeNull();
});
});
});

View file

@ -8,11 +8,10 @@ vi.mock('../../../api.js', () => ({
}));
vi.mock('../RecordingUtils.js', () => ({
numberedChannelLabel: vi.fn(
(item) =>
item.channel_number
? `${item.channel_number} - ${item.name || `Channel ${item.id}`}`
: item.name || `Channel ${item.id}`
numberedChannelLabel: vi.fn((item) =>
item.channel_number
? `${item.channel_number} - ${item.name || `Channel ${item.id}`}`
: item.name || `Channel ${item.id}`
),
sortedChannelOptions: vi.fn((channels) =>
(Array.isArray(channels) ? channels : []).map((c) => ({
@ -33,7 +32,10 @@ import {
getChannelOptions,
previewSeriesRule,
} from '../SeriesRuleEditorModalUtils.js';
import { sortedChannelOptions, numberedChannelLabel } from '../RecordingUtils.js';
import {
sortedChannelOptions,
numberedChannelLabel,
} from '../RecordingUtils.js';
// ─────────────────────────────────────────────────────────────────────────────
@ -112,7 +114,7 @@ describe('SeriesRuleEditorModalUtils', () => {
describe('formatRange', () => {
it('formats same-day range as "date startTime - endTime"', () => {
const start = '2024-06-01T10:00:00';
const end = '2024-06-01T11:30:00';
const end = '2024-06-01T11:30:00';
const result = formatRange(start, end);
// Must include a dash separating start and end times (not "->")
expect(result).toMatch(/-(?!>)/);
@ -121,7 +123,7 @@ describe('SeriesRuleEditorModalUtils', () => {
it('formats cross-day range with "->" separator', () => {
const start = '2024-06-01T23:00:00';
const end = '2024-06-02T01:00:00';
const end = '2024-06-02T01:00:00';
const result = formatRange(start, end);
expect(result).toMatch(/->/);
});
@ -133,7 +135,7 @@ describe('SeriesRuleEditorModalUtils', () => {
it('includes a date string component in the output', () => {
const start = '2024-06-01T10:00:00';
const end = '2024-06-01T11:00:00';
const end = '2024-06-01T11:00:00';
const result = formatRange(start, end);
// toLocaleDateString produces something non-empty
expect(result.length).toBeGreaterThan(5);
@ -141,7 +143,7 @@ describe('SeriesRuleEditorModalUtils', () => {
it('handles ISO strings with timezone offsets', () => {
const start = '2024-06-01T10:00:00Z';
const end = '2024-06-01T11:00:00Z';
const end = '2024-06-01T11:00:00Z';
// Should not throw
expect(() => formatRange(start, end)).not.toThrow();
});
@ -227,15 +229,15 @@ describe('SeriesRuleEditorModalUtils', () => {
describe('getChannelOptions', () => {
const makeChannels = () => [
{ id: 1, name: 'ESPN', channel_number: 5, epg_data_id: 'epg-1' },
{ id: 2, name: 'HBO', channel_number: 10, epg_data_id: 'epg-2' },
{ id: 3, name: 'CNN', channel_number: 20, epg_data_id: 'epg-3' },
{ id: 1, name: 'ESPN', channel_number: 5, epg_data_id: 'epg-1' },
{ id: 2, name: 'HBO', channel_number: 10, epg_data_id: 'epg-2' },
{ id: 3, name: 'CNN', channel_number: 20, epg_data_id: 'epg-3' },
];
const makeTvgsById = () => ({
'epg-1': { tvg_id: 'tvg-espn' },
'epg-2': { tvg_id: 'tvg-hbo' },
'epg-3': { tvg_id: 'tvg-cnn' },
'epg-2': { tvg_id: 'tvg-hbo' },
'epg-3': { tvg_id: 'tvg-cnn' },
});
it('calls sortedChannelOptions with allChannels and numberedChannelLabel', () => {
@ -262,9 +264,9 @@ describe('SeriesRuleEditorModalUtils', () => {
it('places matching channels before non-matching when tvgId set', () => {
// Override sortedChannelOptions to return deterministic output
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
{ value: '1', label: 'ESPN' },
{ value: '2', label: 'HBO' },
{ value: '3', label: 'CNN' },
{ value: '1', label: 'ESPN' },
{ value: '2', label: 'HBO' },
{ value: '3', label: 'CNN' },
]);
const channels = makeChannels();
@ -273,15 +275,15 @@ describe('SeriesRuleEditorModalUtils', () => {
// tvgId matches ESPN (epg-1 → tvg-espn)
const result = getChannelOptions(channels, tvgsById, 'tvg-espn');
expect(result[0].value).toBe('1'); // ESPN first (matching)
expect(result[1].value).toBe('2'); // HBO second (non-matching)
expect(result[2].value).toBe('3'); // CNN third (non-matching)
expect(result[0].value).toBe('1'); // ESPN first (matching)
expect(result[1].value).toBe('2'); // HBO second (non-matching)
expect(result[2].value).toBe('3'); // CNN third (non-matching)
});
it('returns all in sorted order when no channel matches tvgId', () => {
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
{ value: '1', label: 'ESPN' },
{ value: '2', label: 'HBO' },
{ value: '2', label: 'HBO' },
]);
const channels = makeChannels();
@ -292,7 +294,9 @@ describe('SeriesRuleEditorModalUtils', () => {
});
it('handles channel with no epg_data_id (cTvg is null)', () => {
const channels = [{ id: 9, name: 'NoEPG', channel_number: 1, epg_data_id: null }];
const channels = [
{ id: 9, name: 'NoEPG', channel_number: 1, epg_data_id: null },
];
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
{ value: '9', label: 'NoEPG' },
]);
@ -305,7 +309,14 @@ describe('SeriesRuleEditorModalUtils', () => {
});
it('handles missing epg_data_id entry in tvgsById', () => {
const channels = [{ id: 5, name: 'Unknown', channel_number: 1, epg_data_id: 'epg-missing' }];
const channels = [
{
id: 5,
name: 'Unknown',
channel_number: 1,
epg_data_id: 'epg-missing',
},
];
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
{ value: '5', label: 'Unknown' },
]);
@ -333,20 +344,20 @@ describe('SeriesRuleEditorModalUtils', () => {
it('places multiple matching channels before non-matching', () => {
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
{ value: '1', label: 'ESPN' },
{ value: '1', label: 'ESPN' },
{ value: '2', label: 'ESPN2' },
{ value: '3', label: 'CNN' },
{ value: '3', label: 'CNN' },
]);
const channels = [
{ id: 1, name: 'ESPN', channel_number: 5, epg_data_id: 'epg-1' },
{ id: 2, name: 'ESPN2', channel_number: 6, epg_data_id: 'epg-4' },
{ id: 3, name: 'CNN', channel_number: 20, epg_data_id: 'epg-3' },
{ id: 1, name: 'ESPN', channel_number: 5, epg_data_id: 'epg-1' },
{ id: 2, name: 'ESPN2', channel_number: 6, epg_data_id: 'epg-4' },
{ id: 3, name: 'CNN', channel_number: 20, epg_data_id: 'epg-3' },
];
const tvgsById = {
'epg-1': { tvg_id: 'tvg-espn' },
'epg-4': { tvg_id: 'tvg-espn' }, // both ESPN channels share tvg_id
'epg-3': { tvg_id: 'tvg-cnn' },
'epg-3': { tvg_id: 'tvg-cnn' },
};
const result = getChannelOptions(channels, tvgsById, 'tvg-espn');
@ -385,9 +396,13 @@ describe('SeriesRuleEditorModalUtils', () => {
});
it('propagates rejection from API.previewSeriesRule', async () => {
vi.mocked(API.previewSeriesRule).mockRejectedValue(new Error('Network error'));
vi.mocked(API.previewSeriesRule).mockRejectedValue(
new Error('Network error')
);
const controller = new AbortController();
await expect(previewSeriesRule({}, controller)).rejects.toThrow('Network error');
await expect(previewSeriesRule({}, controller)).rejects.toThrow(
'Network error'
);
});
it('propagates AbortError when signal is aborted', async () => {
@ -397,7 +412,9 @@ describe('SeriesRuleEditorModalUtils', () => {
const controller = new AbortController();
controller.abort();
await expect(previewSeriesRule({}, controller)).rejects.toThrow('Aborted');
await expect(previewSeriesRule({}, controller)).rejects.toThrow(
'Aborted'
);
});
it('passes through all fields of the preview key', async () => {
@ -422,4 +439,3 @@ describe('SeriesRuleEditorModalUtils', () => {
});
});
});

View file

@ -54,7 +54,9 @@ describe('StreamProfileUtils', () => {
describe('COMMAND_EXAMPLES', () => {
it('has an entry for each non-custom built-in command', () => {
const nonCustom = BUILT_IN_COMMANDS.filter((c) => c.value !== '__custom__');
const nonCustom = BUILT_IN_COMMANDS.filter(
(c) => c.value !== '__custom__'
);
for (const cmd of nonCustom) {
expect(COMMAND_EXAMPLES).toHaveProperty(cmd.value);
expect(typeof COMMAND_EXAMPLES[cmd.value]).toBe('string');
@ -194,4 +196,4 @@ describe('StreamProfileUtils', () => {
expect(API.addStreamProfile).toHaveBeenCalledWith({});
});
});
});
});

View file

@ -32,7 +32,7 @@ describe('StreamUtils', () => {
});
});
// ─── updateStream ─────────────────────────────────────────────────────────────
// ─── updateStream ─────────────────────────────────────────────────────────────
describe('updateStream', () => {
beforeEach(() => {
@ -63,7 +63,7 @@ describe('StreamUtils', () => {
});
});
// ─── addStream ────────────────────────────────────────────────────────────────
// ─── addStream ────────────────────────────────────────────────────────────────
describe('addStream', () => {
beforeEach(() => {
@ -92,4 +92,4 @@ describe('StreamUtils', () => {
expect(API.addStream).toHaveBeenCalledWith({});
});
});
});
});

View file

@ -13,7 +13,11 @@ vi.mock('@hookform/resolvers/yup', () => ({
import API from '../../../api.js';
import { yupResolver } from '@hookform/resolvers/yup';
import { getResolver, updateUserAgent, addUserAgent } from '../UserAgentUtils.js';
import {
getResolver,
updateUserAgent,
addUserAgent,
} from '../UserAgentUtils.js';
describe('UserAgentUtils', () => {
// ─── getResolver ──────────────────────────────────────────────────────────────
@ -32,7 +36,7 @@ describe('UserAgentUtils', () => {
});
});
// ─── updateUserAgent ──────────────────────────────────────────────────────────
// ─── updateUserAgent ──────────────────────────────────────────────────────────
describe('updateUserAgent', () => {
beforeEach(() => {
@ -63,7 +67,7 @@ describe('UserAgentUtils', () => {
});
});
// ─── addUserAgent ─────────────────────────────────────────────────────────────
// ─── addUserAgent ─────────────────────────────────────────────────────────────
describe('addUserAgent', () => {
beforeEach(() => {
@ -92,4 +96,4 @@ describe('UserAgentUtils', () => {
expect(API.addUserAgent).toHaveBeenCalledWith({});
});
});
});
});

View file

@ -47,7 +47,10 @@ describe('UserUtils', () => {
it('calls API.createUser with the provided values', () => {
API.createUser.mockResolvedValueOnce({ id: 1 });
createUser({ username: 'alice', password: 'pass' });
expect(API.createUser).toHaveBeenCalledWith({ username: 'alice', password: 'pass' });
expect(API.createUser).toHaveBeenCalledWith({
username: 'alice',
password: 'pass',
});
});
it('returns the promise from API.createUser', () => {
@ -57,7 +60,7 @@ describe('UserUtils', () => {
});
});
// ─── updateUser ───────────────────────────────────────────────────────────────
// ─── updateUser ───────────────────────────────────────────────────────────────
describe('updateUser', () => {
beforeEach(() => vi.clearAllMocks());
@ -65,19 +68,31 @@ describe('UserUtils', () => {
it('calls API.updateUser with selfEdit=false when isAdmin is true', () => {
API.updateUser.mockResolvedValueOnce({});
updateUser(1, { username: 'bob' }, true, { id: 1 });
expect(API.updateUser).toHaveBeenCalledWith(1, { username: 'bob' }, false);
expect(API.updateUser).toHaveBeenCalledWith(
1,
{ username: 'bob' },
false
);
});
it('calls API.updateUser with selfEdit=true when not admin and userId matches authUser', () => {
API.updateUser.mockResolvedValueOnce({});
updateUser(5, { username: 'carol' }, false, { id: 5 });
expect(API.updateUser).toHaveBeenCalledWith(5, { username: 'carol' }, true);
expect(API.updateUser).toHaveBeenCalledWith(
5,
{ username: 'carol' },
true
);
});
it('calls API.updateUser with selfEdit=false when not admin and userId does not match authUser', () => {
API.updateUser.mockResolvedValueOnce({});
updateUser(5, { username: 'dave' }, false, { id: 99 });
expect(API.updateUser).toHaveBeenCalledWith(5, { username: 'dave' }, false);
expect(API.updateUser).toHaveBeenCalledWith(
5,
{ username: 'dave' },
false
);
});
it('returns the promise from API.updateUser', () => {
@ -87,7 +102,7 @@ describe('UserUtils', () => {
});
});
// ─── generateApiKey ───────────────────────────────────────────────────────────
// ─── generateApiKey ───────────────────────────────────────────────────────────
describe('generateApiKey', () => {
beforeEach(() => vi.clearAllMocks());
@ -105,7 +120,7 @@ describe('UserUtils', () => {
});
});
// ─── revokeApiKey ─────────────────────────────────────────────────────────────
// ─── revokeApiKey ─────────────────────────────────────────────────────────────
describe('revokeApiKey', () => {
beforeEach(() => vi.clearAllMocks());
@ -123,7 +138,7 @@ describe('UserUtils', () => {
});
});
// ─── userToFormValues ─────────────────────────────────────────────────────────
// ─── userToFormValues ─────────────────────────────────────────────────────────
describe('userToFormValues', () => {
const makeUser = (overrides = {}) => ({
@ -149,7 +164,9 @@ describe('UserUtils', () => {
});
it('defaults first_name and last_name to empty string when absent', () => {
const result = userToFormValues(makeUser({ first_name: null, last_name: null }));
const result = userToFormValues(
makeUser({ first_name: null, last_name: null })
);
expect(result.first_name).toBe('');
expect(result.last_name).toBe('');
});
@ -160,7 +177,9 @@ describe('UserUtils', () => {
});
it('uses channel_profiles when non-empty', () => {
const result = userToFormValues(makeUser({ channel_profiles: ['3', '4'] }));
const result = userToFormValues(
makeUser({ channel_profiles: ['3', '4'] })
);
expect(result.channel_profiles).toEqual(['3', '4']);
});
@ -217,7 +236,7 @@ describe('UserUtils', () => {
});
});
// ─── formValuesToPayload ──────────────────────────────────────────────────────
// ─── formValuesToPayload ──────────────────────────────────────────────────────
describe('formValuesToPayload', () => {
const makeValues = (overrides = {}) => ({
@ -253,7 +272,10 @@ describe('UserUtils', () => {
});
it('sets output_profile to null when empty string', () => {
const result = formValuesToPayload(makeValues({ output_profile: '' }), null);
const result = formValuesToPayload(
makeValues({ output_profile: '' }),
null
);
expect(result.custom_properties.output_profile).toBeNull();
});
@ -272,11 +294,20 @@ describe('UserUtils', () => {
});
it('populates allowed_networks for all NETWORK_KEYS when allowed_ips present', () => {
const result = formValuesToPayload(makeValues({ allowed_ips: ['192.168.1.0/24', '10.0.0.1/32'] }), null);
const result = formValuesToPayload(
makeValues({ allowed_ips: ['192.168.1.0/24', '10.0.0.1/32'] }),
null
);
expect(result.allowed_ips).toBeUndefined();
expect(result.custom_properties.allowed_networks.m3u).toBe('192.168.1.0/24,10.0.0.1/32');
expect(result.custom_properties.allowed_networks.xc).toBe('192.168.1.0/24,10.0.0.1/32');
expect(result.custom_properties.allowed_networks.mpegts).toBe('192.168.1.0/24,10.0.0.1/32');
expect(result.custom_properties.allowed_networks.m3u).toBe(
'192.168.1.0/24,10.0.0.1/32'
);
expect(result.custom_properties.allowed_networks.xc).toBe(
'192.168.1.0/24,10.0.0.1/32'
);
expect(result.custom_properties.allowed_networks.mpegts).toBe(
'192.168.1.0/24,10.0.0.1/32'
);
});
it('sets empty allowed_networks when allowed_ips is empty', () => {
@ -285,17 +316,25 @@ describe('UserUtils', () => {
});
it('sets channel_profiles to empty array when it includes "0"', () => {
const result = formValuesToPayload(makeValues({ channel_profiles: ['0'] }), null);
const result = formValuesToPayload(
makeValues({ channel_profiles: ['0'] }),
null
);
expect(result.channel_profiles).toEqual([]);
});
it('preserves channel_profiles when it does not include "0"', () => {
const result = formValuesToPayload(makeValues({ channel_profiles: ['1', '2'] }), null);
const result = formValuesToPayload(
makeValues({ channel_profiles: ['1', '2'] }),
null
);
expect(result.channel_profiles).toEqual(['1', '2']);
});
it('merges existing custom_properties from existingUser', () => {
const existingUser = { custom_properties: { some_other_prop: 'keep_me' } };
const existingUser = {
custom_properties: { some_other_prop: 'keep_me' },
};
const result = formValuesToPayload(makeValues(), existingUser);
expect(result.custom_properties.some_other_prop).toBe('keep_me');
});
@ -305,7 +344,7 @@ describe('UserUtils', () => {
});
});
// ─── getFormInitialValues ─────────────────────────────────────────────────────
// ─── getFormInitialValues ─────────────────────────────────────────────────────
describe('getFormInitialValues', () => {
it('returns the expected default structure', () => {
@ -336,97 +375,151 @@ describe('UserUtils', () => {
});
});
// ─── getFormValidators ────────────────────────────────────────────────────────
// ─── getFormValidators ────────────────────────────────────────────────────────
describe('getFormValidators', () => {
const validate = (user, values) => getFormValidators(user)(values);
describe('username', () => {
it('returns error when username is empty', () => {
const result = validate(null, { ...getFormInitialValues(), username: '' });
const result = validate(null, {
...getFormInitialValues(),
username: '',
});
expect(result.username).toBe('Username is required');
});
it('returns null for a valid non-streamer username', () => {
const result = validate(null, { ...getFormInitialValues(), username: 'alice', user_level: '0' });
const result = validate(null, {
...getFormInitialValues(),
username: 'alice',
user_level: '0',
});
expect(result.username).toBeNull();
});
it('returns null for a valid alphanumeric streamer username', () => {
const result = validate(null, { ...getFormInitialValues(), username: 'alice123', user_level: '2' });
const result = validate(null, {
...getFormInitialValues(),
username: 'alice123',
user_level: '2',
});
expect(result.username).toBeNull();
});
it('returns error for streamer username with non-alphanumeric characters', () => {
const result = validate(null, { ...getFormInitialValues(), username: 'alice_123', user_level: '2' });
const result = validate(null, {
...getFormInitialValues(),
username: 'alice_123',
user_level: '2',
});
expect(result.username).toBe('Streamer username must be alphanumeric');
});
});
describe('password', () => {
it('returns error when creating a non-streamer user without a password', () => {
const result = validate(null, { ...getFormInitialValues(), user_level: '0', password: '' });
const result = validate(null, {
...getFormInitialValues(),
user_level: '0',
password: '',
});
expect(result.password).toBe('Password is required');
});
it('returns null when creating a streamer user without a password', () => {
const result = validate(null, { ...getFormInitialValues(), user_level: '2', password: '' });
const result = validate(null, {
...getFormInitialValues(),
user_level: '2',
password: '',
});
expect(result.password).toBeNull();
});
it('returns null when editing an existing user without a password', () => {
const result = validate({ id: 1 }, { ...getFormInitialValues(), user_level: '0', password: '' });
const result = validate(
{ id: 1 },
{ ...getFormInitialValues(), user_level: '0', password: '' }
);
expect(result.password).toBeNull();
});
it('returns null when password is provided for new user', () => {
const result = validate(null, { ...getFormInitialValues(), user_level: '0', password: 'secret' });
const result = validate(null, {
...getFormInitialValues(),
user_level: '0',
password: 'secret',
});
expect(result.password).toBeNull();
});
});
describe('xc_password', () => {
it('returns null when xc_password is empty', () => {
const result = validate(null, { ...getFormInitialValues(), xc_password: '' });
const result = validate(null, {
...getFormInitialValues(),
xc_password: '',
});
expect(result.xc_password).toBeNull();
});
it('returns null for a valid alphanumeric xc_password', () => {
const result = validate(null, { ...getFormInitialValues(), xc_password: 'abc123' });
const result = validate(null, {
...getFormInitialValues(),
xc_password: 'abc123',
});
expect(result.xc_password).toBeNull();
});
it('returns error for xc_password with non-alphanumeric characters', () => {
const result = validate(null, { ...getFormInitialValues(), xc_password: 'abc!@#' });
const result = validate(null, {
...getFormInitialValues(),
xc_password: 'abc!@#',
});
expect(result.xc_password).toBe('XC password must be alphanumeric');
});
});
describe('allowed_ips', () => {
it('returns null for an empty allowed_ips array', () => {
const result = validate(null, { ...getFormInitialValues(), allowed_ips: [] });
const result = validate(null, {
...getFormInitialValues(),
allowed_ips: [],
});
expect(result.allowed_ips).toBeNull();
});
it('returns null for a valid IPv4 CIDR', () => {
const result = validate(null, { ...getFormInitialValues(), allowed_ips: ['192.168.1.0/24'] });
const result = validate(null, {
...getFormInitialValues(),
allowed_ips: ['192.168.1.0/24'],
});
expect(result.allowed_ips).toBeNull();
});
it('returns null for a bare IPv4 address (auto-appends /32)', () => {
const result = validate(null, { ...getFormInitialValues(), allowed_ips: ['10.0.0.1'] });
const result = validate(null, {
...getFormInitialValues(),
allowed_ips: ['10.0.0.1'],
});
expect(result.allowed_ips).toBeNull();
});
it('returns error for an invalid IP entry', () => {
const result = validate(null, { ...getFormInitialValues(), allowed_ips: ['not-an-ip'] });
const result = validate(null, {
...getFormInitialValues(),
allowed_ips: ['not-an-ip'],
});
expect(result.allowed_ips).toBe('Invalid IP address or CIDR range');
});
it('returns error when any entry in the list is invalid', () => {
const result = validate(null, { ...getFormInitialValues(), allowed_ips: ['192.168.1.0/24', 'bad-entry'] });
const result = validate(null, {
...getFormInitialValues(),
allowed_ips: ['192.168.1.0/24', 'bad-entry'],
});
expect(result.allowed_ips).toBe('Invalid IP address or CIDR range');
});
});
});
});
});