mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Minor formatting changes
This commit is contained in:
parent
fee8913824
commit
284ba87fe7
24 changed files with 1217 additions and 450 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue