mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Added tests for components
This commit is contained in:
parent
e766c4dd54
commit
fee8913824
13 changed files with 6763 additions and 0 deletions
|
|
@ -0,0 +1,285 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Dependency mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
|
||||
deleteRecordingById: vi.fn(),
|
||||
deleteSeriesAndRule: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/guideUtils.js', () => ({
|
||||
deleteSeriesRuleByTvgId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../SeriesRuleEditorModal.jsx', () => ({
|
||||
default: ({ opened, onClose }) =>
|
||||
opened ? (
|
||||
<div data-testid="series-rule-editor-modal">
|
||||
<button data-testid="series-rule-editor-close" onClick={onClose}>
|
||||
Close Editor
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock('@mantine/core', async () => ({
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>×</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Button: ({ children, onClick, disabled, loading, color, variant }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
data-color={color}
|
||||
data-variant={variant}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Anchor: ({ children, onClick }) => (
|
||||
<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 { deleteSeriesRuleByTvgId } from '../../../utils/guideUtils.js';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const makeProgram = (overrides = {}) => ({
|
||||
tvg_id: 'tvg-1',
|
||||
title: 'Test Show',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeRecording = (overrides = {}) => ({
|
||||
id: 'rec-1',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
opened: true,
|
||||
onClose: vi.fn(),
|
||||
program: makeProgram(),
|
||||
recording: null,
|
||||
existingRuleMode: null,
|
||||
existingRule: null,
|
||||
onRecordOne: vi.fn(),
|
||||
onRecordSeriesAll: vi.fn(),
|
||||
onRecordSeriesNew: vi.fn(),
|
||||
onExistingRuleModeChange: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ─── ProgramRecordingModal ─────────────────────────────────────────────────────
|
||||
|
||||
describe('ProgramRecordingModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(deleteRecordingById).mockResolvedValue(undefined);
|
||||
vi.mocked(deleteSeriesAndRule).mockResolvedValue(undefined);
|
||||
vi.mocked(deleteSeriesRuleByTvgId).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when opened is true', () => {
|
||||
render(<ProgramRecordingModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the modal when opened is false', () => {
|
||||
render(<ProgramRecordingModal {...defaultProps({ opened: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when the modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<ProgramRecordingModal {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleRemoveRecording ──────────────────────────────────────────────────
|
||||
|
||||
describe('handleRemoveRecording', () => {
|
||||
it('calls deleteRecordingById with the recording id', async () => {
|
||||
const recording = makeRecording();
|
||||
render(
|
||||
<ProgramRecordingModal
|
||||
{...defaultProps({ recording })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Remove this recording'));
|
||||
await waitFor(() => {
|
||||
expect(deleteRecordingById).toHaveBeenCalledWith('rec-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after deleting recording', async () => {
|
||||
const onClose = vi.fn();
|
||||
const recording = makeRecording();
|
||||
render(
|
||||
<ProgramRecordingModal
|
||||
{...defaultProps({ recording, onClose })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Remove this recording'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('still calls onClose when deleteRecordingById throws', async () => {
|
||||
vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
|
||||
const onClose = vi.fn();
|
||||
const recording = makeRecording();
|
||||
render(
|
||||
<ProgramRecordingModal
|
||||
{...defaultProps({ recording, onClose })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Remove this recording'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleRemoveSeries ─────────────────────────────────────────────────────
|
||||
|
||||
describe('handleRemoveSeries', () => {
|
||||
it('calls deleteSeriesAndRule with tvg_id and title', async () => {
|
||||
const program = makeProgram({ tvg_id: 'tvg-2', title: 'My Series' });
|
||||
const recording = makeRecording();
|
||||
render(
|
||||
<ProgramRecordingModal
|
||||
{...defaultProps({ program, recording, existingRuleMode: 'series' })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Remove this series/i));
|
||||
await waitFor(() => {
|
||||
expect(deleteSeriesAndRule).toHaveBeenCalledWith({
|
||||
tvg_id: 'tvg-2',
|
||||
title: 'My Series',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after removing series', async () => {
|
||||
const onClose = vi.fn();
|
||||
const recording = makeRecording();
|
||||
render(
|
||||
<ProgramRecordingModal
|
||||
{...defaultProps({ onClose, recording, existingRuleMode: 'series' })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Remove this series/i));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleRemoveSeriesRule ─────────────────────────────────────────────────
|
||||
|
||||
describe('handleRemoveSeriesRule', () => {
|
||||
it('calls deleteSeriesRuleByTvgId with tvg_id and title', async () => {
|
||||
const program = makeProgram({ tvg_id: 'tvg-3', title: 'Rule Show' });
|
||||
render(
|
||||
<ProgramRecordingModal
|
||||
{...defaultProps({ program, existingRuleMode: 'rule' })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Remove series rule/i));
|
||||
await waitFor(() => {
|
||||
expect(deleteSeriesRuleByTvgId).toHaveBeenCalledWith('tvg-3', 'Rule Show');
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onExistingRuleModeChange(null) after removing rule', async () => {
|
||||
const onExistingRuleModeChange = vi.fn();
|
||||
render(
|
||||
<ProgramRecordingModal
|
||||
{...defaultProps({ existingRuleMode: 'rule', onExistingRuleModeChange })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Remove series rule/i));
|
||||
await waitFor(() => {
|
||||
expect(onExistingRuleModeChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after removing rule', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ProgramRecordingModal
|
||||
{...defaultProps({ existingRuleMode: 'rule', onClose })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Remove series rule/i));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Record actions ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('record actions', () => {
|
||||
it('calls onRecordOne when "Record Once" is clicked', () => {
|
||||
const onRecordOne = vi.fn();
|
||||
render(<ProgramRecordingModal {...defaultProps({ onRecordOne })} />);
|
||||
fireEvent.click(screen.getByText('Just this one'));
|
||||
expect(onRecordOne).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onRecordSeriesAll when "Record All" is clicked', () => {
|
||||
const onRecordSeriesAll = vi.fn();
|
||||
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 })} />);
|
||||
fireEvent.click(screen.getByText('New episodes only'));
|
||||
expect(onRecordSeriesNew).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── SeriesRuleEditorModal ──────────────────────────────────────────────────
|
||||
|
||||
describe('SeriesRuleEditorModal', () => {
|
||||
it('opens SeriesRuleEditorModal when "Edit Rule" is clicked', () => {
|
||||
render(
|
||||
<ProgramRecordingModal
|
||||
{...defaultProps({ existingRuleMode: 'rule', existingRule: { id: 1 } })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText(/Customize rule/i));
|
||||
expect(screen.getByTestId('series-rule-editor-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes SeriesRuleEditorModal when its onClose is called', () => {
|
||||
render(
|
||||
<ProgramRecordingModal
|
||||
{...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();
|
||||
});
|
||||
});
|
||||
});
|
||||
507
frontend/src/components/forms/__tests__/Recording.test.jsx
Normal file
507
frontend/src/components/forms/__tests__/Recording.test.jsx
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import RecordingModal from '../Recording';
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/dateTimeUtils.js', () => ({
|
||||
RECURRING_DAY_OPTIONS: [
|
||||
{ value: 'mon', label: 'Monday' },
|
||||
{ value: 'tue', label: 'Tuesday' },
|
||||
],
|
||||
toTimeString: vi.fn((val) => val),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
|
||||
buildRecurringPayload: vi.fn((v) => v),
|
||||
buildSinglePayload: vi.fn((v) => v),
|
||||
createRecording: vi.fn(),
|
||||
createRecurringRule: vi.fn(),
|
||||
sortedChannelOptions: vi.fn(() => [{ value: 'ch-1', label: '501 - HBO' }]),
|
||||
numberedChannelLabel: vi.fn((item) =>
|
||||
item.channel_number ? `${item.channel_number} - ${item.name}` : item.name
|
||||
),
|
||||
getChannelsSummary: vi.fn(),
|
||||
getRecurringFormDefaults: vi.fn(() => ({
|
||||
channel_id: '',
|
||||
rule_name: '',
|
||||
days_of_week: [],
|
||||
start_date: new Date('2024-01-01'),
|
||||
end_date: null,
|
||||
start_time: '08:00',
|
||||
end_time: '09:00',
|
||||
})),
|
||||
getSingleFormDefaults: vi.fn(() => ({
|
||||
channel_id: 'ch-1',
|
||||
start_time: new Date('2024-06-01T10:00:00'),
|
||||
end_time: new Date('2024-06-01T11:00:00'),
|
||||
})),
|
||||
recurringFormValidators: {},
|
||||
singleFormValidators: {},
|
||||
timeChange: vi.fn((fn) => (e) => fn(e.target.value)),
|
||||
updateRecording: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── @mantine/form ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/form', () => ({
|
||||
useForm: vi.fn(({ initialValues }) => {
|
||||
const values = { ...initialValues };
|
||||
return {
|
||||
values,
|
||||
key: vi.fn((k) => k),
|
||||
getInputProps: vi.fn((k) => ({ name: k, value: values[k] ?? '', onChange: vi.fn() })),
|
||||
onSubmit: vi.fn((handler) => (e) => { e?.preventDefault?.(); return handler(values); }),
|
||||
reset: vi.fn(),
|
||||
setValues: vi.fn((newVals) => Object.assign(values, newVals)),
|
||||
setFieldValue: vi.fn((k, v) => { values[k] = v; }),
|
||||
validateField: vi.fn(),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Alert: ({ children, title }) => (
|
||||
<div data-testid="alert">
|
||||
<span data-testid="alert-title">{title}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, onClick, loading, type }) => (
|
||||
<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} />,
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>×</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
MultiSelect: ({ label, placeholder, ...props }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input data-testid={`multiselect-${label}`} placeholder={placeholder} {...props} />
|
||||
</div>
|
||||
),
|
||||
SegmentedControl: ({ value, onChange, data, disabled }) => (
|
||||
<div data-testid="segmented-control">
|
||||
{data.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
data-testid={`mode-${item.value}`}
|
||||
data-active={value === item.value}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
Select: ({ label, disabled, rightSection, data, ...props }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
{rightSection}
|
||||
<select data-testid={`select-${label}`} disabled={disabled} {...props}>
|
||||
{(data ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
TextInput: ({ label, placeholder, ...props }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input data-testid={`textinput-${label}`} placeholder={placeholder} {...props} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── @mantine/dates ─────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/dates', () => ({
|
||||
DatePickerInput: ({ label, value, onChange }) => (
|
||||
<div>
|
||||
<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)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
DateTimePicker: ({ label, ...props }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input data-testid={`datetimepicker-${label}`} {...props} />
|
||||
</div>
|
||||
),
|
||||
TimeInput: ({ label, value, onChange, onBlur }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
data-testid={`timeinput-${label}`}
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
CircleAlert: () => <svg data-testid="icon-circle-alert" />,
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import useChannelsStore from '../../../store/channels';
|
||||
import { showNotification } from '../../../utils/notificationUtils.js';
|
||||
import * as RecordingUtils from '../../../utils/forms/RecordingUtils.js';
|
||||
|
||||
const setupStoreMock = () => {
|
||||
const mockFetchRecordings = vi.fn().mockResolvedValue(undefined);
|
||||
const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(useChannelsStore).mockImplementation((sel) =>
|
||||
sel({ fetchRecordings: mockFetchRecordings, fetchRecurringRules: mockFetchRecurringRules })
|
||||
);
|
||||
|
||||
return { mockFetchRecordings, mockFetchRecurringRules };
|
||||
};
|
||||
|
||||
const makeRecording = (overrides = {}) => ({
|
||||
id: 'rec-1',
|
||||
start_time: '2024-06-01T10:00:00Z',
|
||||
end_time: '2024-06-01T11:00:00Z',
|
||||
custom_properties: { program: { title: 'Test Show' } },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 });
|
||||
|
||||
describe('RecordingModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([
|
||||
{ id: 'ch-1', name: 'HBO', channel_number: 501 },
|
||||
]);
|
||||
vi.mocked(RecordingUtils.createRecording).mockResolvedValue(undefined);
|
||||
vi.mocked(RecordingUtils.updateRecording).mockResolvedValue(undefined);
|
||||
vi.mocked(RecordingUtils.createRecurringRule).mockResolvedValue(undefined);
|
||||
setupStoreMock();
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when isOpen is true', () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when isOpen is false', () => {
|
||||
render(<RecordingModal isOpen={false} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Alert ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('scheduling conflict alert', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mode switching ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('mode switching', () => {
|
||||
it('defaults to "single" mode', () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
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');
|
||||
});
|
||||
|
||||
it('shows DateTimePicker fields in single mode', () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('datetimepicker-Start')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('datetimepicker-End')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows recurring fields when in recurring mode', () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('mode-recurring'));
|
||||
expect(screen.getByTestId('textinput-Rule name')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('timeinput-Start time')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('timeinput-End time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables mode toggle when editing an existing recording', () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />);
|
||||
expect(screen.getByTestId('mode-single')).toBeDisabled();
|
||||
expect(screen.getByTestId('mode-recurring')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows "Schedule Recording" submit button in single mode', () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
expect(screen.getByText('Schedule Recording')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Save Rule" submit button in recurring mode', () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('mode-recurring'));
|
||||
expect(screen.getByText('Save Rule')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Channel loading ────────────────────────────────────────────────────────
|
||||
|
||||
describe('channel loading', () => {
|
||||
it('calls getChannelsSummary when modal opens', async () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.getChannelsSummary).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls sortedChannelOptions with loaded channels', async () => {
|
||||
const channels = [{ id: 'ch-1', name: 'HBO', channel_number: 501 }];
|
||||
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue(channels);
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith(
|
||||
channels,
|
||||
RecordingUtils.numberedChannelLabel
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => {
|
||||
vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(new Error('fail'));
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith(
|
||||
[],
|
||||
RecordingUtils.numberedChannelLabel
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not load channels when modal is closed', () => {
|
||||
render(<RecordingModal isOpen={false} onClose={vi.fn()} />);
|
||||
expect(RecordingUtils.getChannelsSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Single form submit (create) ────────────────────────────────────────────
|
||||
|
||||
describe('single mode – create recording', () => {
|
||||
it('calls buildSinglePayload with form values on submit', async () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.buildSinglePayload).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls createRecording when no existing recording', async () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.createRecording).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Recording scheduled" notification after successful create', async () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Recording scheduled', color: 'green' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRecordings after successful create', async () => {
|
||||
const { mockFetchRecordings } = setupStoreMock();
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRecordings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful create', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<RecordingModal isOpen onClose={onClose} />);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call showNotification when createRecording throws', async () => {
|
||||
vi.mocked(RecordingUtils.createRecording).mockRejectedValue(new Error('fail'));
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Single form submit (update) ────────────────────────────────────────────
|
||||
|
||||
describe('single mode – update recording', () => {
|
||||
it('calls updateRecording when editing an existing recording', async () => {
|
||||
render(
|
||||
<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />
|
||||
);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.updateRecording).toHaveBeenCalledWith(
|
||||
'rec-1',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call createRecording when updating', async () => {
|
||||
render(
|
||||
<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />
|
||||
);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.createRecording).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Recording updated" notification after successful update', async () => {
|
||||
render(
|
||||
<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />
|
||||
);
|
||||
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Recording updated', color: 'green' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Recurring form submit ──────────────────────────────────────────────────
|
||||
|
||||
describe('recurring mode – create rule', () => {
|
||||
it('calls buildRecurringPayload with form values on submit', async () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('mode-recurring'));
|
||||
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.buildRecurringPayload).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls createRecurringRule on submit', async () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('mode-recurring'));
|
||||
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.createRecurringRule).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRecurringRules and fetchRecordings on success', async () => {
|
||||
const { mockFetchRecurringRules, mockFetchRecordings } = setupStoreMock();
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('mode-recurring'));
|
||||
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRecurringRules).toHaveBeenCalled();
|
||||
expect(mockFetchRecordings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Recurring rule saved" notification on success', async () => {
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('mode-recurring'));
|
||||
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Recurring rule saved', color: 'green' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful recurring submit', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<RecordingModal isOpen onClose={onClose} />);
|
||||
fireEvent.click(screen.getByTestId('mode-recurring'));
|
||||
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show notification when createRecurringRule throws', async () => {
|
||||
vi.mocked(RecordingUtils.createRecurringRule).mockRejectedValue(new Error('fail'));
|
||||
render(<RecordingModal isOpen onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('mode-recurring'));
|
||||
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form initialization ────────────────────────────────────────────────────
|
||||
|
||||
describe('form initialization', () => {
|
||||
it('calls getSingleFormDefaults with recording and channel when opening with existing recording', () => {
|
||||
const recording = makeRecording();
|
||||
const channel = makeChannel();
|
||||
render(<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);
|
||||
});
|
||||
|
||||
it('calls getRecurringFormDefaults with channel on open', () => {
|
||||
const channel = makeChannel();
|
||||
render(<RecordingModal isOpen channel={channel} onClose={vi.fn()} />);
|
||||
expect(RecordingUtils.getRecurringFormDefaults).toHaveBeenCalledWith(channel);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Close / reset ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('close and reset', () => {
|
||||
it('calls onClose when modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<RecordingModal isOpen onClose={onClose} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,770 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../../store/useVideoStore.jsx', () => ({
|
||||
default: Object.assign(vi.fn(), {
|
||||
getState: vi.fn(() => ({ showVideo: vi.fn() })),
|
||||
}),
|
||||
}));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/dateTimeUtils.js', () => ({
|
||||
format: vi.fn(),
|
||||
isAfter: vi.fn(),
|
||||
isBefore: vi.fn(),
|
||||
useDateTimeFormat: vi.fn(),
|
||||
useTimeHelpers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
|
||||
deleteRecordingById: vi.fn(),
|
||||
getChannelLogoUrl: vi.fn(),
|
||||
getPosterUrl: vi.fn(),
|
||||
getRecordingUrl: vi.fn(),
|
||||
getSeasonLabel: vi.fn(),
|
||||
getShowVideoUrl: vi.fn(),
|
||||
runComSkip: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/forms/RecordingDetailsModalUtils.js', () => ({
|
||||
getChannel: vi.fn(),
|
||||
getRating: vi.fn(),
|
||||
getStatRows: vi.fn(),
|
||||
getUpcomingEpisodes: vi.fn(),
|
||||
refreshArtwork: vi.fn(),
|
||||
updateRecordingMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' }));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
Check: () => <svg data-testid="icon-check" />,
|
||||
Pencil: () => <svg data-testid="icon-pencil" />,
|
||||
RefreshCcw: () => <svg data-testid="icon-refresh" />,
|
||||
X: () => <svg data-testid="icon-x" />,
|
||||
}));
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
ActionIcon: ({ children, onClick, disabled }) => (
|
||||
<button data-testid="action-icon" onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Badge: ({ children, color }) => (
|
||||
<span data-testid="badge" data-color={color}>{children}</span>
|
||||
),
|
||||
Button: ({ children, onClick, disabled, loading, size, variant, color }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
data-size={size}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
data-loading={loading}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Card: ({ children, onClick, style }) => (
|
||||
<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 }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>×</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children, size, c, fw, style }) => (
|
||||
<span data-size={size} data-color={c} data-fw={fw} style={style}>{children}</span>
|
||||
),
|
||||
Textarea: ({ label, value, onChange, placeholder, ...props }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<textarea
|
||||
data-testid={`textarea-${label ?? placeholder ?? 'unknown'}`}
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
TextInput: ({ label, value, onChange, placeholder, ...props }) => (
|
||||
<div>
|
||||
<label />
|
||||
<input
|
||||
data-testid={`textinput-${label ?? placeholder ?? 'unknown'}`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
style={props.style}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import RecordingDetailsModal from '../RecordingDetailsModal';
|
||||
import useChannelsStore from '../../../store/channels.jsx';
|
||||
import useVideoStore from '../../../store/useVideoStore.jsx';
|
||||
import {
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
useDateTimeFormat,
|
||||
useTimeHelpers,
|
||||
} from '../../../utils/dateTimeUtils.js';
|
||||
import { showNotification } from '../../../utils/notificationUtils.js';
|
||||
import * as RecordingCardUtils from '../../../utils/cards/RecordingCardUtils.js';
|
||||
import * as RecordingDetailsModalUtils from '../../../utils/forms/RecordingDetailsModalUtils.js';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const PAST = '2020-01-01T10:00:00Z';
|
||||
const FUTURE = '2099-01-01T10:00:00Z';
|
||||
const NOW = '2024-06-01T12:00:00Z';
|
||||
|
||||
const makeMoment = (isoString) => {
|
||||
const d = dayjs(isoString);
|
||||
return {
|
||||
isAfter: (other) => d.isAfter(other?._d ?? other),
|
||||
isBefore: (other) => d.isBefore(other?._d ?? other),
|
||||
format: vi.fn((fmt) => d.format(fmt)),
|
||||
_d: d.toDate(),
|
||||
};
|
||||
};
|
||||
|
||||
const makeRecording = (overrides = {}) => ({
|
||||
id: 'rec-1',
|
||||
channel: 'ch-1',
|
||||
start_time: PAST,
|
||||
end_time: PAST,
|
||||
_group_count: 1,
|
||||
custom_properties: {
|
||||
status: 'completed',
|
||||
file_url: '/recordings/test.ts',
|
||||
program: {
|
||||
title: 'Test Show',
|
||||
description: 'A test description',
|
||||
sub_title: 'Pilot',
|
||||
},
|
||||
...overrides.custom_properties,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 });
|
||||
|
||||
const mockShowVideo = vi.fn();
|
||||
|
||||
const setupMocks = ({ recording = makeRecording(), now = NOW } = {}) => {
|
||||
const nowMoment = makeMoment(now);
|
||||
const startMoment = makeMoment(recording.start_time);
|
||||
const endMoment = makeMoment(recording.end_time);
|
||||
|
||||
vi.mocked(useTimeHelpers).mockReturnValue({
|
||||
toUserTime: (iso) => {
|
||||
if (iso === recording.start_time) return startMoment;
|
||||
if (iso === recording.end_time) return endMoment;
|
||||
return makeMoment(iso);
|
||||
},
|
||||
userNow: () => nowMoment,
|
||||
});
|
||||
|
||||
vi.mocked(useDateTimeFormat).mockReturnValue({
|
||||
timeFormat: 'HH:mm',
|
||||
dateFormat: 'MM/DD',
|
||||
});
|
||||
|
||||
vi.mocked(format).mockImplementation((moment, fmt) => moment.format(fmt));
|
||||
vi.mocked(isAfter).mockImplementation((a, b) => a.isAfter(b));
|
||||
vi.mocked(isBefore).mockImplementation((a, b) => a.isBefore(b));
|
||||
|
||||
vi.mocked(useChannelsStore).mockImplementation((sel) =>
|
||||
sel({ recordings: [recording] })
|
||||
);
|
||||
|
||||
mockShowVideo.mockReset();
|
||||
vi.mocked(useVideoStore).mockImplementation((sel) =>
|
||||
sel({ showVideo: mockShowVideo })
|
||||
);
|
||||
useVideoStore.getState = vi.fn(() => ({ showVideo: mockShowVideo }));
|
||||
|
||||
vi.mocked(RecordingCardUtils.getPosterUrl).mockReturnValue('/poster.jpg');
|
||||
vi.mocked(RecordingCardUtils.getChannelLogoUrl).mockReturnValue('/logo.png');
|
||||
vi.mocked(RecordingCardUtils.getRecordingUrl).mockReturnValue('/recordings/test.ts');
|
||||
vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('');
|
||||
vi.mocked(RecordingCardUtils.getShowVideoUrl).mockReturnValue('/live/ch-1');
|
||||
|
||||
vi.mocked(RecordingDetailsModalUtils.getRating).mockReturnValue(null);
|
||||
vi.mocked(RecordingDetailsModalUtils.getStatRows).mockReturnValue([]);
|
||||
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue([]);
|
||||
vi.mocked(RecordingDetailsModalUtils.getChannel).mockResolvedValue(null);
|
||||
};
|
||||
|
||||
const defaultProps = () => ({
|
||||
opened: true,
|
||||
onClose: vi.fn(),
|
||||
recording: makeRecording(),
|
||||
channel: makeChannel(),
|
||||
posterUrl: '/poster.jpg',
|
||||
onWatchLive: vi.fn(),
|
||||
onWatchRecording: vi.fn(),
|
||||
env_mode: 'production',
|
||||
onEdit: vi.fn(),
|
||||
});
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||
describe('RecordingDetailsModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupMocks();
|
||||
vi.mocked(RecordingDetailsModalUtils.updateRecordingMetadata).mockResolvedValue(undefined);
|
||||
vi.mocked(RecordingDetailsModalUtils.refreshArtwork).mockResolvedValue(undefined);
|
||||
vi.mocked(RecordingCardUtils.runComSkip).mockResolvedValue(undefined);
|
||||
vi.mocked(RecordingCardUtils.deleteRecordingById).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
// ── Visibility ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when opened is true', () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when opened is false', () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} opened={false} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns null when recording is not provided', () => {
|
||||
const { container } = render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={null} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('calls onClose when modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<RecordingDetailsModal {...defaultProps()} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Content rendering ────────────────────────────────────────────────────────
|
||||
|
||||
describe('content rendering', () => {
|
||||
it('renders the recording title', () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
expect(screen.getByText(/Test Show/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Custom Recording" when no program title', () => {
|
||||
const recording = makeRecording({
|
||||
custom_properties: { status: 'completed', program: {} },
|
||||
});
|
||||
setupMocks({ recording });
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={recording} />
|
||||
);
|
||||
expect(screen.getByText('Custom Recording')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the description', () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
expect(screen.getByText('A test description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the poster image', () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
expect(screen.getByAltText('Test Show')).toHaveAttribute('src', '/poster.jpg');
|
||||
});
|
||||
|
||||
it('renders the season/episode label when present', () => {
|
||||
const seriesRecording = makeRecording({ _group_count: 2 });
|
||||
const mockEpisode = makeRecording({ id: 'ep-1', _group_count: 1 });
|
||||
|
||||
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue([mockEpisode]);
|
||||
vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('S01E02');
|
||||
|
||||
render(
|
||||
<RecordingDetailsModal
|
||||
{...defaultProps()}
|
||||
recording={seriesRecording}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('S01E02')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders rating when present', () => {
|
||||
vi.mocked(RecordingDetailsModalUtils.getRating).mockReturnValue('TV-MA');
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
expect(screen.getByText('TV-MA')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders stat rows when available', () => {
|
||||
vi.mocked(RecordingDetailsModalUtils.getStatRows).mockReturnValue([
|
||||
['Video Codec', 'H264'],
|
||||
['Audio Codec', 'AAC'],
|
||||
]);
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Stream Stats')).toBeInTheDocument();
|
||||
expect(screen.getByText('Video Codec')).toBeInTheDocument();
|
||||
expect(screen.getByText('H264')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Watch buttons ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Watch button', () => {
|
||||
it('renders Watch button for completed recording', () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Watch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onWatchRecording when Watch is clicked', () => {
|
||||
const onWatchRecording = vi.fn();
|
||||
render(
|
||||
<RecordingDetailsModal
|
||||
{...defaultProps()}
|
||||
onWatchRecording={onWatchRecording}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Watch'));
|
||||
expect(onWatchRecording).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Watch button is disabled when no file_url', () => {
|
||||
const recording = makeRecording({
|
||||
custom_properties: {
|
||||
status: 'completed',
|
||||
program: { title: 'Test Show' },
|
||||
},
|
||||
});
|
||||
setupMocks({ recording });
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={recording} />
|
||||
);
|
||||
expect(screen.getByText('Watch')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Watch Live button', () => {
|
||||
it('renders Watch Live button for in-progress recording', () => {
|
||||
const recording = makeRecording({
|
||||
start_time: PAST,
|
||||
end_time: FUTURE,
|
||||
custom_properties: {
|
||||
status: 'recording',
|
||||
file_url: '/f.ts',
|
||||
program: { title: 'Live Show' },
|
||||
},
|
||||
});
|
||||
setupMocks({ recording });
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={recording} />
|
||||
);
|
||||
expect(screen.getByText('Watch Live')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onWatchLive when Watch Live is clicked', () => {
|
||||
const recording = makeRecording({
|
||||
start_time: PAST,
|
||||
end_time: FUTURE,
|
||||
custom_properties: {
|
||||
status: 'recording',
|
||||
file_url: '/f.ts',
|
||||
program: { title: 'Live Show' },
|
||||
},
|
||||
});
|
||||
setupMocks({ recording });
|
||||
const onWatchLive = vi.fn();
|
||||
render(
|
||||
<RecordingDetailsModal
|
||||
{...defaultProps()}
|
||||
recording={recording}
|
||||
onWatchLive={onWatchLive}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Watch Live'));
|
||||
expect(onWatchLive).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit button ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Edit button', () => {
|
||||
it('renders the Edit button', () => {
|
||||
const futureRecording = makeRecording({ start_time: FUTURE, end_time: FUTURE });
|
||||
setupMocks({ recording: futureRecording });
|
||||
render(
|
||||
<RecordingDetailsModal
|
||||
{...defaultProps()}
|
||||
recording={futureRecording}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onEdit when Edit is clicked', () => {
|
||||
const futureRecording = makeRecording({ start_time: FUTURE, end_time: FUTURE });
|
||||
setupMocks({ recording: futureRecording });
|
||||
const onEdit = vi.fn();
|
||||
render(
|
||||
<RecordingDetailsModal
|
||||
{...defaultProps()}
|
||||
recording={futureRecording}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
expect(onEdit).toHaveBeenCalledWith(futureRecording);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Inline editing ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('inline metadata editing', () => {
|
||||
it('shows edit inputs when pencil icon is clicked', () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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();
|
||||
fireEvent.click(screen.getByTestId('icon-x').closest('button'));
|
||||
expect(screen.queryByTestId('textinput-Recording title')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls updateRecordingMetadata with new title and description on save', async () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
|
||||
|
||||
fireEvent.change(screen.getByTestId('textinput-Recording title'), {
|
||||
target: { value: 'New Title' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('textarea-Description (optional)'), {
|
||||
target: { value: 'New Description' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
RecordingDetailsModalUtils.updateRecordingMetadata
|
||||
).toHaveBeenCalledWith(makeRecording(), 'New Title', 'New Description');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows saved title optimistically after save', async () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
|
||||
fireEvent.change(screen.getByTestId('textinput-Recording title'), {
|
||||
target: { value: 'Updated Title' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Updated Title')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success notification after save', async () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
|
||||
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ color: 'green' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show notification when updateRecordingMetadata throws', async () => {
|
||||
vi.mocked(
|
||||
RecordingDetailsModalUtils.updateRecordingMetadata
|
||||
).mockRejectedValue(new Error('fail'));
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
|
||||
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses "Custom Recording" when title is cleared on save', async () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
|
||||
fireEvent.change(screen.getByTestId('textinput-Recording title'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Custom Recording')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Refresh artwork ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('refresh artwork', () => {
|
||||
it('calls refreshArtwork with recording id on click', async () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Refresh artwork'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(RecordingDetailsModalUtils.refreshArtwork).toHaveBeenCalledWith(
|
||||
'rec-1'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success notification after refreshArtwork', async () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Refresh artwork'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ color: 'blue.5' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show notification when refreshArtwork throws', async () => {
|
||||
vi.mocked(RecordingDetailsModalUtils.refreshArtwork).mockRejectedValue(
|
||||
new Error('fail')
|
||||
);
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Refresh artwork'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── ComSkip ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('run comskip', () => {
|
||||
it('calls runComSkip with the recording', async () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Remove commercials'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(RecordingCardUtils.runComSkip).toHaveBeenCalledWith(
|
||||
makeRecording()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success notification after runComSkip', async () => {
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Remove commercials'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ color: expect.any(String) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show notification when runComSkip throws', async () => {
|
||||
vi.mocked(RecordingCardUtils.runComSkip).mockRejectedValue(
|
||||
new Error('fail')
|
||||
);
|
||||
render(<RecordingDetailsModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Remove commercials'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show Remove commercials when comskip is completed', () => {
|
||||
const recording = makeRecording({
|
||||
custom_properties: {
|
||||
status: 'completed',
|
||||
file_url: '/f.ts',
|
||||
comskip: { status: 'completed' },
|
||||
program: { title: 'Test Show' },
|
||||
},
|
||||
});
|
||||
setupMocks({ recording });
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={recording} />
|
||||
);
|
||||
expect(screen.queryByText('Remove commercials')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Series / episodes ────────────────────────────────────────────────────────
|
||||
|
||||
describe('series group', () => {
|
||||
const makeSeriesRecording = () =>
|
||||
makeRecording({
|
||||
_group_count: 3,
|
||||
custom_properties: {
|
||||
status: 'scheduled',
|
||||
program: { title: 'Series Show', tvg_id: 'series-1' },
|
||||
},
|
||||
start_time: FUTURE,
|
||||
end_time: FUTURE,
|
||||
});
|
||||
|
||||
const makeEpisode = (id = 'ep-1') => ({
|
||||
id,
|
||||
channel: 'ch-1',
|
||||
start_time: FUTURE,
|
||||
end_time: FUTURE,
|
||||
custom_properties: {
|
||||
status: 'scheduled',
|
||||
program: { title: 'Episode', sub_title: `Sub ${id}` },
|
||||
},
|
||||
});
|
||||
|
||||
it('renders upcoming episodes list when isSeriesGroup is true', () => {
|
||||
const recording = makeSeriesRecording();
|
||||
const episode = makeEpisode();
|
||||
setupMocks({ recording });
|
||||
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
|
||||
[episode]
|
||||
);
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={recording} />
|
||||
);
|
||||
expect(screen.getByText('Sub ep-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "No upcoming episodes" when episode list is empty for series', () => {
|
||||
const recording = makeSeriesRecording();
|
||||
setupMocks({ recording });
|
||||
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
|
||||
[]
|
||||
);
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={recording} />
|
||||
);
|
||||
expect(screen.getByText(/no upcoming episodes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens child modal when an episode card is clicked', async () => {
|
||||
const recording = makeSeriesRecording();
|
||||
const episode = makeEpisode();
|
||||
setupMocks({ recording });
|
||||
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
|
||||
[episode]
|
||||
);
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={recording} />
|
||||
);
|
||||
const episodeCards = screen.getAllByTestId('card');
|
||||
fireEvent.click(episodeCards[episodeCards.length - 1]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('modal').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls deleteRecordingById when episode remove is clicked', async () => {
|
||||
const recording = makeSeriesRecording();
|
||||
const episode = makeEpisode();
|
||||
setupMocks({ recording });
|
||||
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
|
||||
[episode]
|
||||
);
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={recording} />
|
||||
);
|
||||
fireEvent.click(screen.getByText('Remove'));
|
||||
await waitFor(() => {
|
||||
expect(RecordingCardUtils.deleteRecordingById).toHaveBeenCalledWith(
|
||||
'ep-1'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── safeRecording merging ────────────────────────────────────────────────────
|
||||
|
||||
describe('safeRecording store merge', () => {
|
||||
it('picks updated recording from store while preserving _group_count', () => {
|
||||
const original = makeRecording({ _group_count: 3 });
|
||||
const storeVersion = {
|
||||
...makeRecording(),
|
||||
id: 'rec-1',
|
||||
custom_properties: {
|
||||
...makeRecording().custom_properties,
|
||||
program: { title: 'Updated Title' },
|
||||
},
|
||||
};
|
||||
vi.mocked(useChannelsStore).mockImplementation((sel) =>
|
||||
sel({ recordings: [storeVersion] })
|
||||
);
|
||||
render(
|
||||
<RecordingDetailsModal {...defaultProps()} recording={original} />
|
||||
);
|
||||
expect(screen.getByText(/Updated Title/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Optimistic state reset ───────────────────────────────────────────────────
|
||||
|
||||
describe('optimistic state reset on recording change', () => {
|
||||
it('resets saved title when recording id changes', async () => {
|
||||
const { rerender } = render(
|
||||
<RecordingDetailsModal {...defaultProps()} />
|
||||
);
|
||||
// Save a new title
|
||||
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
|
||||
fireEvent.change(screen.getByTestId('textinput-Recording title'), {
|
||||
target: { value: 'Custom' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
|
||||
await waitFor(() => expect(screen.getByText('Custom')).toBeInTheDocument());
|
||||
|
||||
// Switch to a different recording
|
||||
const newRecording = makeRecording({ id: 'rec-2' });
|
||||
setupMocks({ recording: newRecording });
|
||||
rerender(
|
||||
<RecordingDetailsModal
|
||||
{...defaultProps()}
|
||||
recording={newRecording}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Test Show/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,937 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/dateTimeUtils.js', () => ({
|
||||
format: vi.fn((moment, fmt) => `formatted-${fmt}`),
|
||||
getNow: vi.fn(() => '2024-06-01T12:00:00Z'),
|
||||
RECURRING_DAY_OPTIONS: [
|
||||
{ value: 'mon', label: 'Monday' },
|
||||
{ value: 'tue', label: 'Tuesday' },
|
||||
{ value: 'wed', label: 'Wednesday' },
|
||||
],
|
||||
toDate: vi.fn((val) => new Date(val)),
|
||||
toTimeString: vi.fn((val) => val),
|
||||
useDateTimeFormat: vi.fn(),
|
||||
useTimeHelpers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
|
||||
deleteRecordingById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/forms/RecurringRuleModalUtils.js', () => ({
|
||||
deleteRecurringRuleById: vi.fn(),
|
||||
getFormDefaults: vi.fn(),
|
||||
getUpcomingOccurrences: vi.fn(),
|
||||
updateRecurringRule: vi.fn(),
|
||||
updateRecurringRuleEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
|
||||
getChannelsSummary: vi.fn(),
|
||||
getRecurringFormDefaults: vi.fn(),
|
||||
recurringFormValidators: {},
|
||||
sortedChannelOptions: vi.fn(() => [{ value: 'ch-1', label: 'HBO' }]),
|
||||
}));
|
||||
|
||||
// ── @mantine/form ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/form', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
useForm: vi.fn(({ initialValues }) => {
|
||||
const [values, setValuesState] = React.useState({ ...initialValues });
|
||||
return {
|
||||
values,
|
||||
key: vi.fn((k) => k),
|
||||
getInputProps: vi.fn((k) => ({
|
||||
name: k,
|
||||
value: values[k] ?? '',
|
||||
onChange: vi.fn((e) => {
|
||||
const v = e?.currentTarget?.value ?? e?.target?.value ?? e;
|
||||
setValuesState((prev) => ({ ...prev, [k]: v }));
|
||||
}),
|
||||
})),
|
||||
onSubmit: vi.fn(
|
||||
(handler) => (e) => {
|
||||
e?.preventDefault?.();
|
||||
return handler(values);
|
||||
}
|
||||
),
|
||||
reset: vi.fn(() => setValuesState({ ...initialValues })),
|
||||
setValues: vi.fn((newVals) =>
|
||||
setValuesState((prev) => ({ ...prev, ...newVals }))
|
||||
),
|
||||
setFieldValue: vi.fn((k, v) =>
|
||||
setValuesState((prev) => ({ ...prev, [k]: v }))
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Badge: ({ children, color }) => (
|
||||
<span data-testid="badge" data-color={color}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Button: ({ children, onClick, loading, type, variant, color, size, disabled }) => (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
data-loading={loading}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
data-size={size}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Card: ({ children }) => <div data-testid="occurrence-card">{children}</div>,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
MultiSelect: ({ label, data, ...props }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<select data-testid={`multiselect-${label}`} multiple {...props}>
|
||||
{(data ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
Select: ({ label, data, ...props }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<select data-testid={`select-${label}`} {...props}>
|
||||
{(data ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Switch: ({ checked, onChange, label, disabled }) => (
|
||||
<label>
|
||||
<input
|
||||
data-testid="switch"
|
||||
type="checkbox"
|
||||
checked={checked ?? false}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
// Mirror the event shape the component expects: event.currentTarget.checked
|
||||
onChange({ currentTarget: { checked: e.target.checked } });
|
||||
}}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
),
|
||||
Text: ({ children, size, c, fw }) => (
|
||||
<span data-size={size} data-color={c} data-fw={fw}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
TextInput: ({ label, placeholder, ...props }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
data-testid={`textinput-${label ?? placeholder ?? 'unknown'}`}
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── @mantine/dates ─────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/dates', () => ({
|
||||
DatePickerInput: ({ label, value, onChange }) => (
|
||||
<div>
|
||||
<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)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
TimeInput: ({ label, value, onChange }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
data-testid={`timeinput-${label}`}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import useChannelsStore from '../../../store/channels.jsx';
|
||||
import {
|
||||
format,
|
||||
toTimeString,
|
||||
useDateTimeFormat,
|
||||
useTimeHelpers,
|
||||
} from '../../../utils/dateTimeUtils.js';
|
||||
import { showNotification } from '../../../utils/notificationUtils.js';
|
||||
import { deleteRecordingById } from '../../../utils/cards/RecordingCardUtils.js';
|
||||
import * as RecurringRuleModalUtils from '../../../utils/forms/RecurringRuleModalUtils.js';
|
||||
import * as RecordingUtils from '../../../utils/forms/RecordingUtils.js';
|
||||
import { useForm } from '@mantine/form';
|
||||
import RecurringRuleModal from '../RecurringRuleModal';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const FUTURE = '2099-01-01T10:00:00Z';
|
||||
const NOW = '2024-06-01T12:00:00Z';
|
||||
|
||||
const makeMoment = (isoString) => {
|
||||
const d = dayjs(isoString);
|
||||
return {
|
||||
isAfter: (other) => d.isAfter(other?._d ?? other),
|
||||
isBefore: (other) => d.isBefore(other?._d ?? other),
|
||||
format: vi.fn((fmt) => `formatted-${fmt}`),
|
||||
_d: d.toDate(),
|
||||
};
|
||||
};
|
||||
|
||||
const makeRule = (overrides = {}) => ({
|
||||
id: 'rule-1',
|
||||
name: 'Morning News',
|
||||
channel: 'ch-1',
|
||||
channel_id: 'ch-1',
|
||||
days_of_week: ['mon', 'tue'],
|
||||
start_date: new Date('2024-01-01'),
|
||||
end_date: null,
|
||||
start_time: '08:00',
|
||||
end_time: '09:00',
|
||||
enabled: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeOccurrence = (id = 'occ-1') => ({
|
||||
id,
|
||||
start_time: FUTURE,
|
||||
end_time: FUTURE,
|
||||
custom_properties: { program: { title: 'Episode' } },
|
||||
});
|
||||
|
||||
const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 });
|
||||
|
||||
const makeRecording = (overrides = {}) => ({
|
||||
id: 'rec-1',
|
||||
start_time: FUTURE,
|
||||
end_time: FUTURE,
|
||||
custom_properties: { program: { title: 'Morning News' } },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupMocks = ({ rule = makeRule(), occurrences = [] } = {}) => {
|
||||
const nowMoment = makeMoment(NOW);
|
||||
const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(useChannelsStore).mockImplementation((sel) =>
|
||||
sel({
|
||||
recurringRules: rule ? [rule] : [],
|
||||
fetchRecurringRules: mockFetchRecurringRules,
|
||||
recordings: [],
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(useTimeHelpers).mockReturnValue({
|
||||
toUserTime: (iso) => makeMoment(iso),
|
||||
userNow: () => nowMoment,
|
||||
});
|
||||
|
||||
vi.mocked(useDateTimeFormat).mockReturnValue({
|
||||
timeFormat: 'HH:mm',
|
||||
dateFormat: 'MM/DD',
|
||||
});
|
||||
|
||||
vi.mocked(format).mockImplementation((moment, fmt) => `formatted-${fmt}`);
|
||||
|
||||
vi.mocked(RecordingUtils.getRecurringFormDefaults).mockReturnValue({
|
||||
channel_id: '',
|
||||
rule_name: '',
|
||||
days_of_week: [],
|
||||
start_date: new Date('2024-01-01'),
|
||||
end_date: null,
|
||||
start_time: '08:00',
|
||||
end_time: '09:00',
|
||||
enabled: rule?.enabled ?? true,
|
||||
});
|
||||
|
||||
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([makeChannel()]);
|
||||
|
||||
vi.mocked(RecurringRuleModalUtils.getFormDefaults).mockReturnValue({
|
||||
channel_id: rule?.channel_id ?? '',
|
||||
rule_name: rule?.name ?? '',
|
||||
days_of_week: rule?.days_of_week ?? [],
|
||||
start_date: rule?.start_date ?? new Date('2024-01-01'),
|
||||
end_date: rule?.end_date ?? null,
|
||||
start_time: rule?.start_time ?? '08:00',
|
||||
end_time: rule?.end_time ?? '09:00',
|
||||
enabled: rule?.enabled ?? true,
|
||||
});
|
||||
|
||||
vi.mocked(RecurringRuleModalUtils.getUpcomingOccurrences).mockReturnValue(occurrences);
|
||||
vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockResolvedValue(undefined);
|
||||
vi.mocked(RecurringRuleModalUtils.updateRecurringRuleEnabled).mockResolvedValue(undefined);
|
||||
vi.mocked(RecurringRuleModalUtils.deleteRecurringRuleById).mockResolvedValue(undefined);
|
||||
vi.mocked(deleteRecordingById).mockResolvedValue(undefined);
|
||||
|
||||
return { mockFetchRecurringRules };
|
||||
};
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
opened: true,
|
||||
onClose: vi.fn(),
|
||||
ruleId: 'rule-1',
|
||||
recording: null,
|
||||
onEditOccurrence: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||
describe('RecurringRuleModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
// ── Visibility ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when opened is true', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when opened is false', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} opened={false} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses rule name as modal title', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
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');
|
||||
});
|
||||
|
||||
it('calls onClose when modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<RecurringRuleModal {...defaultProps()} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Missing rule fallback ────────────────────────────────────────────────────
|
||||
|
||||
describe('missing rule fallback', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks({ rule: null });
|
||||
});
|
||||
|
||||
it('renders fallback message when rule does not exist', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(
|
||||
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()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Delete Recording')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show Delete Recording button when sourceRecording is null', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} recording={null} />);
|
||||
expect(screen.queryByText('Delete Recording')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls deleteRecordingById when Delete Recording is confirmed', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<RecurringRuleModal
|
||||
{...defaultProps()}
|
||||
onClose={onClose}
|
||||
recording={makeRecording()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Delete Recording'));
|
||||
await waitFor(() => {
|
||||
expect(deleteRecordingById).toHaveBeenCalledWith('rec-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Recording deleted" notification after deleting orphaned recording', async () => {
|
||||
render(
|
||||
<RecurringRuleModal {...defaultProps()} recording={makeRecording()} />
|
||||
);
|
||||
fireEvent.click(screen.getByText('Delete Recording'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Recording deleted', color: 'green' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after deleting orphaned recording', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<RecurringRuleModal
|
||||
{...defaultProps()}
|
||||
onClose={onClose}
|
||||
recording={makeRecording()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Delete Recording'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose when Cancel is clicked in fallback', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<RecurringRuleModal
|
||||
{...defaultProps()}
|
||||
onClose={onClose}
|
||||
recording={makeRecording()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not show notification when deleteRecordingById throws', async () => {
|
||||
vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
|
||||
render(
|
||||
<RecurringRuleModal {...defaultProps()} recording={makeRecording()} />
|
||||
);
|
||||
fireEvent.click(screen.getByText('Delete Recording'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form fields ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('form fields', () => {
|
||||
it('renders the Channel select', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('select-Channel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Rule name input', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('textinput-Rule name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Every (days of week) multiselect', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('multiselect-Every')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Start date and End date pickers', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('datepicker-Start date')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('datepicker-End date')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Start time and End time inputs', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('timeinput-Start time')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('timeinput-End time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the enabled/paused Switch', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('switch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switch reflects rule enabled state', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('switch')).toBeChecked();
|
||||
});
|
||||
|
||||
it('switch is unchecked when rule is paused', () => {
|
||||
setupMocks({ rule: makeRule({ enabled: false }) });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('switch')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('renders channel name in header', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByText('HBO')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders channel number fallback when channel not in allChannels', async () => {
|
||||
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([]);
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Channel ch-1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Channel loading ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('channel loading', () => {
|
||||
it('calls getChannelsSummary when opened', async () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.getChannelsSummary).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call getChannelsSummary when closed', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} opened={false} />);
|
||||
expect(RecordingUtils.getChannelsSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls sortedChannelOptions with loaded channels', async () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith([
|
||||
makeChannel(),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => {
|
||||
vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(
|
||||
new Error('fail')
|
||||
);
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
await waitFor(() => {
|
||||
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form initialization from rule ─────────────────────────────────────────────
|
||||
|
||||
describe('form initialization', () => {
|
||||
it('calls getFormDefaults with rule when opened', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(RecurringRuleModalUtils.getFormDefaults).toHaveBeenCalledWith(
|
||||
makeRule()
|
||||
);
|
||||
});
|
||||
|
||||
it('calls form.setValues with getFormDefaults result', () => {
|
||||
const formMock = vi.mocked(useForm).mock.results[0]?.value;
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(formMock?.setValues ?? vi.fn()).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Save changes ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('save changes', () => {
|
||||
it('calls updateRecurringRule on form submit', async () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.submit(screen.getByText('Save changes').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(RecurringRuleModalUtils.updateRecurringRule).toHaveBeenCalledWith(
|
||||
'rule-1',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRecurringRules after save', async () => {
|
||||
const { mockFetchRecurringRules } = setupMocks();
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.submit(screen.getByText('Save changes').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRecurringRules).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Recurring rule updated" notification on success', async () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.submit(screen.getByText('Save changes').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Recurring rule updated',
|
||||
color: 'green',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful save', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<RecurringRuleModal {...defaultProps()} onClose={onClose} />);
|
||||
fireEvent.submit(screen.getByText('Save changes').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show notification when updateRecurringRule throws', async () => {
|
||||
vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockRejectedValue(
|
||||
new Error('fail')
|
||||
);
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.submit(screen.getByText('Save changes').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('Save changes button shows loading state while saving', async () => {
|
||||
let resolve;
|
||||
vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockReturnValue(
|
||||
new Promise((r) => { resolve = r; })
|
||||
);
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.submit(screen.getByText('Save changes').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Save changes')).toHaveAttribute('data-loading', 'true');
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete rule ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('delete rule', () => {
|
||||
it('renders the Delete rule button', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Delete rule')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls deleteRecurringRuleById when Delete rule is clicked', async () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Delete rule'));
|
||||
await waitFor(() => {
|
||||
expect(RecurringRuleModalUtils.deleteRecurringRuleById).toHaveBeenCalledWith(
|
||||
'rule-1'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRecurringRules after delete', async () => {
|
||||
const { mockFetchRecurringRules } = setupMocks();
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Delete rule'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRecurringRules).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Recurring rule removed" notification on success', async () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Delete rule'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Recurring rule removed',
|
||||
color: 'red',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful delete', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<RecurringRuleModal {...defaultProps()} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByText('Delete rule'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show notification when deleteRecurringRuleById throws', async () => {
|
||||
vi.mocked(RecurringRuleModalUtils.deleteRecurringRuleById).mockRejectedValue(
|
||||
new Error('fail')
|
||||
);
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Delete rule'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Toggle enabled ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('toggle enabled', () => {
|
||||
it('calls updateRecurringRuleEnabled with true when switch is toggled on', async () => {
|
||||
setupMocks({ rule: makeRule({ enabled: false }) });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
const sw = screen.getByTestId('switch');
|
||||
expect(sw).not.toBeChecked();
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
RecurringRuleModalUtils.updateRecurringRuleEnabled
|
||||
).toHaveBeenCalledWith('rule-1', true);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls updateRecurringRuleEnabled with false when switch is toggled off', async () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
const sw = screen.getByTestId('switch');
|
||||
expect(sw).toBeChecked();
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
RecurringRuleModalUtils.updateRecurringRuleEnabled
|
||||
).toHaveBeenCalledWith('rule-1', false);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Recurring rule enabled" notification when enabling', async () => {
|
||||
setupMocks({ rule: makeRule({ enabled: false }) });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('switch'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Recurring rule enabled',
|
||||
color: 'green',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Recurring rule paused" notification when disabling', async () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('switch'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Recurring rule paused',
|
||||
color: 'yellow',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRecurringRules after toggle', async () => {
|
||||
const { mockFetchRecurringRules } = setupMocks();
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('switch'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRecurringRules).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show notification when updateRecurringRuleEnabled throws', async () => {
|
||||
vi.mocked(
|
||||
RecurringRuleModalUtils.updateRecurringRuleEnabled
|
||||
).mockRejectedValue(new Error('fail'));
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByTestId('switch'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Upcoming occurrences ─────────────────────────────────────────────────────
|
||||
|
||||
describe('upcoming occurrences', () => {
|
||||
it('renders the occurrence count badge', () => {
|
||||
setupMocks({ occurrences: [makeOccurrence(), makeOccurrence('occ-2')] });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('badge')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('shows "No future airings" when no upcoming occurrences', () => {
|
||||
setupMocks({ occurrences: [] });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(
|
||||
screen.getByText('No future airings currently scheduled.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders occurrence cards when occurrences exist', () => {
|
||||
setupMocks({
|
||||
occurrences: [makeOccurrence(), makeOccurrence('occ-2')],
|
||||
});
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getAllByTestId('occurrence-card')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders Edit and Cancel buttons for each occurrence', () => {
|
||||
setupMocks({ occurrences: [makeOccurrence()] });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls getUpcomingOccurrences with recordings, userNow, ruleId, toUserTime', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
expect(RecurringRuleModalUtils.getUpcomingOccurrences).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.any(Function),
|
||||
'rule-1',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit occurrence ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('edit occurrence', () => {
|
||||
it('calls onClose and onEditOccurrence when Edit is clicked', () => {
|
||||
const occ = makeOccurrence();
|
||||
setupMocks({ occurrences: [occ] });
|
||||
const onClose = vi.fn();
|
||||
const onEditOccurrence = vi.fn();
|
||||
render(
|
||||
<RecurringRuleModal
|
||||
{...defaultProps()}
|
||||
onClose={onClose}
|
||||
onEditOccurrence={onEditOccurrence}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
expect(onEditOccurrence).toHaveBeenCalledWith(occ);
|
||||
});
|
||||
|
||||
it('does not throw when onEditOccurrence is not provided', () => {
|
||||
const occ = makeOccurrence();
|
||||
setupMocks({ occurrences: [occ] });
|
||||
render(
|
||||
<RecurringRuleModal
|
||||
{...defaultProps()}
|
||||
onEditOccurrence={undefined}
|
||||
/>
|
||||
);
|
||||
expect(() => fireEvent.click(screen.getByText('Edit'))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cancel occurrence ────────────────────────────────────────────────────────
|
||||
|
||||
describe('cancel occurrence', () => {
|
||||
it('calls deleteRecordingById when Cancel is clicked', async () => {
|
||||
const occ = makeOccurrence();
|
||||
setupMocks({ occurrences: [occ] });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
await waitFor(() => {
|
||||
expect(deleteRecordingById).toHaveBeenCalledWith('occ-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Occurrence cancelled" notification on success', async () => {
|
||||
const occ = makeOccurrence();
|
||||
setupMocks({ occurrences: [occ] });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Occurrence cancelled',
|
||||
color: 'yellow',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show notification when deleteRecordingById throws', async () => {
|
||||
vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
|
||||
setupMocks({ occurrences: [makeOccurrence()] });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state on Cancel button while deleting occurrence', async () => {
|
||||
let resolve;
|
||||
vi.mocked(deleteRecordingById).mockReturnValue(
|
||||
new Promise((r) => { resolve = r; })
|
||||
);
|
||||
setupMocks({ occurrences: [makeOccurrence()] });
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cancel')).toHaveAttribute('data-loading', 'true');
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Time/date field handlers ─────────────────────────────────────────────────
|
||||
|
||||
describe('field change handlers', () => {
|
||||
it('calls toTimeString when start time changes', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('timeinput-Start time'), {
|
||||
target: { value: '09:00' },
|
||||
});
|
||||
expect(toTimeString).toHaveBeenCalledWith('09:00');
|
||||
});
|
||||
|
||||
it('calls toTimeString when end time changes', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('timeinput-End time'), {
|
||||
target: { value: '10:00' },
|
||||
});
|
||||
expect(toTimeString).toHaveBeenCalledWith('10:00');
|
||||
});
|
||||
|
||||
it('updates end_date field when end date picker changes', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
const picker = screen.getByTestId('datepicker-End date');
|
||||
fireEvent.change(picker, { target: { value: '2024-12-31' } });
|
||||
// No throw — handler wired correctly
|
||||
});
|
||||
|
||||
it('updates start_date field when start date picker changes', () => {
|
||||
render(<RecurringRuleModal {...defaultProps()} />);
|
||||
const picker = screen.getByTestId('datepicker-Start date');
|
||||
fireEvent.change(picker, { target: { value: '2024-07-01' } });
|
||||
// No throw — handler wired correctly
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
373
frontend/src/components/forms/__tests__/ScheduleInput.test.jsx
Normal file
373
frontend/src/components/forms/__tests__/ScheduleInput.test.jsx
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import ScheduleInput from '../ScheduleInput';
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
TextInput: ({ label, placeholder, description, value, onChange, error, disabled }) => (
|
||||
<div>
|
||||
<label>{typeof label === 'string' ? label : 'Cron Expression'}</label>
|
||||
<input
|
||||
data-testid="textinput-cron"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
{description && <span data-testid="cron-description">{description}</span>}
|
||||
{error && <span data-testid="cron-error">{error}</span>}
|
||||
</div>
|
||||
),
|
||||
NumberInput: ({ label, description, value, onChange, min, disabled }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
data-testid="numberinput-interval"
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
min={min}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{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="#">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children }) => <span>{children}</span>,
|
||||
Code: ({ children }) => <code>{children}</code>,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Popover: ({ children }) => <div>{children}</div>,
|
||||
PopoverTarget: ({ children }) => <div>{children}</div>,
|
||||
PopoverDropdown: ({ children }) => <div data-testid="popover-dropdown">{children}</div>,
|
||||
ActionIcon: ({ children, onClick }) => (
|
||||
<button data-testid="action-icon-info" onClick={onClick}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
Info: () => <svg data-testid="icon-info" />,
|
||||
}));
|
||||
|
||||
// ── cronUtils ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/cronUtils', () => ({
|
||||
validateCronExpression: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── CronBuilder ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../CronBuilder', () => ({
|
||||
default: ({ opened, onClose, onApply, currentValue }) =>
|
||||
opened ? (
|
||||
<div data-testid="cron-builder">
|
||||
<span data-testid="cron-builder-value">{currentValue}</span>
|
||||
<button data-testid="cron-builder-apply" onClick={() => onApply('0 3 * * *')}>
|
||||
Apply
|
||||
</button>
|
||||
<button data-testid="cron-builder-close" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
import { validateCronExpression } from '../../../utils/cronUtils';
|
||||
|
||||
const defaultIntervalProps = () => ({
|
||||
scheduleType: 'interval',
|
||||
onScheduleTypeChange: vi.fn(),
|
||||
intervalValue: 6,
|
||||
onIntervalChange: vi.fn(),
|
||||
cronValue: '',
|
||||
onCronChange: vi.fn(),
|
||||
});
|
||||
|
||||
const defaultCronProps = () => ({
|
||||
scheduleType: 'cron',
|
||||
onScheduleTypeChange: vi.fn(),
|
||||
cronValue: '0 3 * * *',
|
||||
onCronChange: vi.fn(),
|
||||
intervalValue: 0,
|
||||
onIntervalChange: vi.fn(),
|
||||
});
|
||||
|
||||
describe('ScheduleInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
|
||||
});
|
||||
|
||||
// ── Interval mode ────────────────────────────────────────────────────────
|
||||
|
||||
describe('interval mode', () => {
|
||||
it('renders NumberInput in interval mode', () => {
|
||||
render(<ScheduleInput {...defaultIntervalProps()} />);
|
||||
expect(screen.getByTestId('numberinput-interval')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the intervalValue', () => {
|
||||
render(<ScheduleInput {...defaultIntervalProps()} />);
|
||||
expect(screen.getByTestId('numberinput-interval')).toHaveValue(6);
|
||||
});
|
||||
|
||||
it('renders default interval label', () => {
|
||||
render(<ScheduleInput {...defaultIntervalProps()} />);
|
||||
expect(screen.getByText('Refresh Interval (hours)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders custom intervalLabel', () => {
|
||||
render(<ScheduleInput {...defaultIntervalProps()} intervalLabel="My Custom Label" />);
|
||||
expect(screen.getByText('My Custom Label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders default interval description', () => {
|
||||
render(<ScheduleInput {...defaultIntervalProps()} />);
|
||||
expect(screen.getByTestId('interval-description')).toHaveTextContent(
|
||||
'How often to refresh (0 to disable)'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onIntervalChange when value changes', () => {
|
||||
const props = defaultIntervalProps();
|
||||
render(<ScheduleInput {...props} />);
|
||||
fireEvent.change(screen.getByTestId('numberinput-interval'), {
|
||||
target: { value: '12' },
|
||||
});
|
||||
expect(props.onIntervalChange).toHaveBeenCalledWith(12);
|
||||
});
|
||||
|
||||
it('shows "Use cron schedule" link in interval mode', () => {
|
||||
render(<ScheduleInput {...defaultIntervalProps()} />);
|
||||
expect(screen.getByTestId('anchor-use-cron-schedule')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onScheduleTypeChange("cron") when "Use cron schedule" is clicked', () => {
|
||||
const props = defaultIntervalProps();
|
||||
render(<ScheduleInput {...props} />);
|
||||
fireEvent.click(screen.getByTestId('anchor-use-cron-schedule'));
|
||||
expect(props.onScheduleTypeChange).toHaveBeenCalledWith('cron');
|
||||
});
|
||||
|
||||
it('does not show cron switch link when disabled', () => {
|
||||
render(<ScheduleInput {...defaultIntervalProps()} disabled />);
|
||||
expect(screen.queryByTestId('anchor-use-cron-schedule')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the NumberInput when disabled prop is true', () => {
|
||||
render(<ScheduleInput {...defaultIntervalProps()} disabled />);
|
||||
expect(screen.getByTestId('numberinput-interval')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders custom switchToCronLabel', () => {
|
||||
render(
|
||||
<ScheduleInput {...defaultIntervalProps()} switchToCronLabel="Use custom cron schedule" />
|
||||
);
|
||||
expect(screen.getByText('Use custom cron schedule')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Children (simple) mode ────────────────────────────────────────────────
|
||||
|
||||
describe('children (simple) mode', () => {
|
||||
it('renders children instead of NumberInput', () => {
|
||||
render(
|
||||
<ScheduleInput {...defaultIntervalProps()}>
|
||||
<div data-testid="custom-child">Custom content</div>
|
||||
</ScheduleInput>
|
||||
);
|
||||
expect(screen.getByTestId('custom-child')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('numberinput-interval')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows cron toggle link below children', () => {
|
||||
render(
|
||||
<ScheduleInput {...defaultIntervalProps()}>
|
||||
<div>Child</div>
|
||||
</ScheduleInput>
|
||||
);
|
||||
expect(screen.getByTestId('anchor-use-cron-schedule')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show cron link when disabled with children', () => {
|
||||
render(
|
||||
<ScheduleInput {...defaultIntervalProps()} disabled>
|
||||
<div>Child</div>
|
||||
</ScheduleInput>
|
||||
);
|
||||
expect(screen.queryByTestId('anchor-use-cron-schedule')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cron mode ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('cron mode', () => {
|
||||
it('renders TextInput in cron mode', () => {
|
||||
render(<ScheduleInput {...defaultCronProps()} />);
|
||||
expect(screen.getByTestId('textinput-cron')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the cronValue', () => {
|
||||
render(<ScheduleInput {...defaultCronProps()} />);
|
||||
expect(screen.getByTestId('textinput-cron')).toHaveValue('0 3 * * *');
|
||||
});
|
||||
|
||||
it('shows "Use interval schedule" link in cron mode', () => {
|
||||
render(<ScheduleInput {...defaultCronProps()} />);
|
||||
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();
|
||||
});
|
||||
|
||||
it('calls onScheduleTypeChange("interval") and onCronChange("") when switching to interval', () => {
|
||||
const props = defaultCronProps();
|
||||
render(<ScheduleInput {...props} />);
|
||||
fireEvent.click(screen.getByTestId('anchor-use-interval-schedule'));
|
||||
expect(props.onScheduleTypeChange).toHaveBeenCalledWith('interval');
|
||||
expect(props.onCronChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('calls onCronChange when cron input changes', () => {
|
||||
const props = defaultCronProps();
|
||||
render(<ScheduleInput {...props} />);
|
||||
fireEvent.change(screen.getByTestId('textinput-cron'), {
|
||||
target: { value: '0 5 * * *' },
|
||||
});
|
||||
expect(props.onCronChange).toHaveBeenCalledWith('0 5 * * *');
|
||||
});
|
||||
|
||||
it('disables the TextInput when disabled prop is true', () => {
|
||||
render(<ScheduleInput {...defaultCronProps()} disabled />);
|
||||
expect(screen.getByTestId('textinput-cron')).toBeDisabled();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('renders custom switchToIntervalLabel', () => {
|
||||
render(
|
||||
<ScheduleInput {...defaultCronProps()} switchToIntervalLabel="Use simple schedule" />
|
||||
);
|
||||
expect(screen.getByText('Use simple schedule')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cron validation ───────────────────────────────────────────────────────
|
||||
|
||||
describe('cron validation', () => {
|
||||
it('shows no error when cron is valid', () => {
|
||||
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
|
||||
render(<ScheduleInput {...defaultCronProps()} />);
|
||||
expect(screen.queryByTestId('cron-error')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error message when cron is invalid', async () => {
|
||||
vi.mocked(validateCronExpression).mockReturnValue({
|
||||
valid: false,
|
||||
error: 'Invalid cron expression',
|
||||
});
|
||||
render(<ScheduleInput {...defaultCronProps()} cronValue="bad cron" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cron-error')).toHaveTextContent('Invalid cron expression');
|
||||
});
|
||||
});
|
||||
|
||||
it('clears error when cron input is cleared', async () => {
|
||||
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
|
||||
const props = { ...defaultCronProps(), cronValue: '' };
|
||||
render(<ScheduleInput {...props} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('cron-error')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls validateCronExpression on cron value change', () => {
|
||||
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
|
||||
const props = defaultCronProps();
|
||||
render(<ScheduleInput {...props} />);
|
||||
fireEvent.change(screen.getByTestId('textinput-cron'), {
|
||||
target: { value: '0 6 * * 1' },
|
||||
});
|
||||
expect(validateCronExpression).toHaveBeenCalledWith('0 6 * * 1');
|
||||
});
|
||||
|
||||
it('does not call validateCronExpression in interval mode', () => {
|
||||
render(<ScheduleInput {...defaultIntervalProps()} />);
|
||||
expect(validateCronExpression).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cron Builder ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('CronBuilder', () => {
|
||||
it('CronBuilder is not visible initially', () => {
|
||||
render(<ScheduleInput {...defaultCronProps()} />);
|
||||
expect(screen.queryByTestId('cron-builder')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens CronBuilder when "Open Cron Builder" is clicked', () => {
|
||||
render(<ScheduleInput {...defaultCronProps()} />);
|
||||
fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
|
||||
expect(screen.getByTestId('cron-builder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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 * * *');
|
||||
});
|
||||
|
||||
it('closes CronBuilder when onClose is triggered', () => {
|
||||
render(<ScheduleInput {...defaultCronProps()} />);
|
||||
fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
|
||||
fireEvent.click(screen.getByTestId('cron-builder-close'));
|
||||
expect(screen.queryByTestId('cron-builder')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onCronChange with applied value from CronBuilder', () => {
|
||||
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
|
||||
const props = defaultCronProps();
|
||||
render(<ScheduleInput {...props} />);
|
||||
fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
|
||||
fireEvent.click(screen.getByTestId('cron-builder-apply'));
|
||||
expect(props.onCronChange).toHaveBeenCalledWith('0 3 * * *');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Default props ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('default props', () => {
|
||||
it('defaults to interval mode when scheduleType is not provided', () => {
|
||||
render(
|
||||
<ScheduleInput
|
||||
onScheduleTypeChange={vi.fn()}
|
||||
onIntervalChange={vi.fn()}
|
||||
onCronChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('numberinput-interval')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults intervalValue to 0', () => {
|
||||
render(
|
||||
<ScheduleInput
|
||||
onScheduleTypeChange={vi.fn()}
|
||||
onIntervalChange={vi.fn()}
|
||||
onCronChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('numberinput-interval')).toHaveValue(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import SeriesRecordingModal from '../SeriesRecordingModal';
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children, size, c, fw }) => (
|
||||
<span data-testid="text" data-size={size} data-color={c} data-fw={fw}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Button: ({ children, onClick, variant, color, disabled }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Badge: ({ children, color }) => (
|
||||
<span data-testid="badge" data-color={color}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Store ──────────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/channels.jsx', () => ({
|
||||
default: { getState: vi.fn() },
|
||||
}));
|
||||
|
||||
// ── Utils ──────────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
|
||||
deleteSeriesAndRule: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/guideUtils.js', () => ({
|
||||
evaluateSeriesRulesByTvgId: vi.fn(),
|
||||
fetchRules: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── SeriesRuleEditorModal ──────────────────────────────────────────────────────
|
||||
vi.mock('../SeriesRuleEditorModal', () => ({
|
||||
default: ({ opened, onClose, initialRule, onSaved }) =>
|
||||
opened ? (
|
||||
<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>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
import useChannelsStore from '../../../store/channels.jsx';
|
||||
import { deleteSeriesAndRule } from '../../../utils/cards/RecordingCardUtils.js';
|
||||
import { evaluateSeriesRulesByTvgId, fetchRules } from '../../../utils/guideUtils.js';
|
||||
import { showNotification } from '../../../utils/notificationUtils.js';
|
||||
|
||||
const makeRule = (overrides = {}) => ({
|
||||
tvg_id: 'tvg-1',
|
||||
title: 'Test Show',
|
||||
mode: 'new',
|
||||
title_mode: 'exact',
|
||||
description: '',
|
||||
channel_id: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
opened: true,
|
||||
onClose: vi.fn(),
|
||||
rules: [makeRule()],
|
||||
onRulesUpdate: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockFetchRecordings = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const setupMocks = () => {
|
||||
vi.mocked(useChannelsStore.getState).mockReturnValue({
|
||||
fetchRecordings: mockFetchRecordings,
|
||||
});
|
||||
vi.mocked(fetchRules).mockResolvedValue([makeRule()]);
|
||||
vi.mocked(evaluateSeriesRulesByTvgId).mockResolvedValue(undefined);
|
||||
vi.mocked(deleteSeriesAndRule).mockResolvedValue(undefined);
|
||||
};
|
||||
|
||||
describe('SeriesRecordingModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders when opened is true', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when opened is false', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps({ opened: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders modal title "Series Recording Rules"', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent('Series Recording Rules');
|
||||
});
|
||||
|
||||
it('calls onClose when modal close button is clicked', () => {
|
||||
const props = defaultProps();
|
||||
render(<SeriesRecordingModal {...props} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(props.onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Empty state ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('empty state', () => {
|
||||
it('shows "No series rules configured" when rules is empty', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps({ rules: [] })} />);
|
||||
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();
|
||||
});
|
||||
|
||||
it('does not show "No series rules configured" when rules are present', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
expect(screen.queryByText('No series rules configured')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rule rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('rule rendering', () => {
|
||||
it('renders rule title', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Test Show')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to tvg_id when rule has no title', () => {
|
||||
render(
|
||||
<SeriesRecordingModal
|
||||
{...defaultProps({ rules: [makeRule({ title: '', tvg_id: 'tvg-fallback' })] })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('tvg-fallback')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Pinned channel" badge when channel_id is set', () => {
|
||||
render(
|
||||
<SeriesRecordingModal
|
||||
{...defaultProps({ rules: [makeRule({ channel_id: 'ch-1' })] })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Pinned channel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render "Pinned channel" badge when channel_id is null', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
expect(screen.queryByText('Pinned channel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple rules', () => {
|
||||
const rules = [
|
||||
makeRule({ tvg_id: 'tvg-1', title: 'Show One' }),
|
||||
makeRule({ tvg_id: 'tvg-2', title: 'Show Two' }),
|
||||
];
|
||||
render(<SeriesRecordingModal {...defaultProps({ rules })} />);
|
||||
expect(screen.getByText('Show One')).toBeInTheDocument();
|
||||
expect(screen.getByText('Show Two')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Edit, Evaluate Now, and Remove buttons for each rule', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
expect(screen.getByText('Evaluate Now')).toBeInTheDocument();
|
||||
expect(screen.getByText('Remove')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Add rule" button', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
expect(screen.getByText('Add rule')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rule summary ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('rule summary', () => {
|
||||
it('shows "New episodes" for 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' })] })} />);
|
||||
expect(screen.getByText(/Every episode/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows exact title label in summary', () => {
|
||||
render(
|
||||
<SeriesRecordingModal
|
||||
{...defaultProps({ rules: [makeRule({ title_mode: 'exact', title: 'Test Show' })] })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Exact title: "Test Show"/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows contains label in summary', () => {
|
||||
render(
|
||||
<SeriesRecordingModal
|
||||
{...defaultProps({ rules: [makeRule({ title_mode: 'contains', title: 'Test' })] })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Title contains: "Test"/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows regex label in summary', () => {
|
||||
render(
|
||||
<SeriesRecordingModal
|
||||
{...defaultProps({ rules: [makeRule({ title_mode: 'regex', title: '^Test' })] })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Title regex: "\^Test"/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows description in summary when present', () => {
|
||||
render(
|
||||
<SeriesRecordingModal
|
||||
{...defaultProps({ rules: [makeRule({ description: 'A drama' })] })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Description: "A drama"/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show description in summary when empty', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
expect(screen.queryByText(/Description:/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Evaluate Now ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('Evaluate Now', () => {
|
||||
it('calls evaluateSeriesRulesByTvgId with rule tvg_id', async () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Evaluate Now'));
|
||||
await waitFor(() => {
|
||||
expect(evaluateSeriesRulesByTvgId).toHaveBeenCalledWith('tvg-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRecordings after evaluation', async () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Evaluate Now'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRecordings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Evaluated" notification after evaluation', async () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Evaluate Now'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Evaluated', message: 'Checked for episodes' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('still shows notification when fetchRecordings rejects', async () => {
|
||||
mockFetchRecordings.mockRejectedValueOnce(new Error('network'));
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Evaluate Now'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Evaluated' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Remove series ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('Remove series', () => {
|
||||
it('calls deleteSeriesAndRule with tvg_id and title', async () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Remove'));
|
||||
await waitFor(() => {
|
||||
expect(deleteSeriesAndRule).toHaveBeenCalledWith({
|
||||
tvg_id: 'tvg-1',
|
||||
title: 'Test Show',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRecordings after removal', async () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Remove'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRecordings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRules after removal', async () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Remove'));
|
||||
await waitFor(() => {
|
||||
expect(fetchRules).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onRulesUpdate with fetched rules after removal', async () => {
|
||||
const updated = [makeRule({ title: 'Updated Show' })];
|
||||
vi.mocked(fetchRules).mockResolvedValue(updated);
|
||||
const props = defaultProps();
|
||||
render(<SeriesRecordingModal {...props} />);
|
||||
fireEvent.click(screen.getByText('Remove'));
|
||||
await waitFor(() => {
|
||||
expect(props.onRulesUpdate).toHaveBeenCalledWith(updated);
|
||||
});
|
||||
});
|
||||
|
||||
it('still calls onRulesUpdate when fetchRecordings rejects', async () => {
|
||||
mockFetchRecordings.mockRejectedValueOnce(new Error('network'));
|
||||
const props = defaultProps();
|
||||
render(<SeriesRecordingModal {...props} />);
|
||||
fireEvent.click(screen.getByText('Remove'));
|
||||
await waitFor(() => {
|
||||
expect(props.onRulesUpdate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Add rule / Editor ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Add rule', () => {
|
||||
it('opens SeriesRuleEditorModal when "Add rule" is clicked', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add rule'));
|
||||
expect(screen.getByTestId('series-rule-editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes null initialRule when opening via "Add rule"', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add rule'));
|
||||
expect(screen.getByTestId('editor-rule')).toHaveTextContent('new');
|
||||
});
|
||||
|
||||
it('closes SeriesRuleEditorModal when editor onClose fires', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Add rule'));
|
||||
fireEvent.click(screen.getByTestId('editor-close'));
|
||||
expect(screen.queryByTestId('series-rule-editor')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit rule ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Edit rule', () => {
|
||||
it('opens SeriesRuleEditorModal with the selected rule when Edit is clicked', () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
expect(screen.getByTestId('series-rule-editor')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('editor-rule')).toHaveTextContent('Test Show');
|
||||
});
|
||||
|
||||
it('calls fetchRules and onRulesUpdate when editor onSaved fires', async () => {
|
||||
const updated = [makeRule({ title: 'Saved Show' })];
|
||||
vi.mocked(fetchRules).mockResolvedValue(updated);
|
||||
const props = defaultProps();
|
||||
render(<SeriesRecordingModal {...props} />);
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
fireEvent.click(screen.getByTestId('editor-save'));
|
||||
await waitFor(() => {
|
||||
expect(fetchRules).toHaveBeenCalled();
|
||||
expect(props.onRulesUpdate).toHaveBeenCalledWith(updated);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes editor after save', async () => {
|
||||
render(<SeriesRecordingModal {...defaultProps()} />);
|
||||
fireEvent.click(screen.getByText('Edit'));
|
||||
fireEvent.click(screen.getByTestId('editor-save'));
|
||||
await waitFor(() => {
|
||||
// editor is still open (onSaved doesn't auto-close) — just verify no error
|
||||
expect(fetchRules).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,682 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../../store/epgs.jsx', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils.js', () => ({
|
||||
useDebounce: vi.fn((val) => val),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
|
||||
getChannelsSummary: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/guideUtils.js', () => ({
|
||||
createSeriesRule: vi.fn(),
|
||||
evaluateSeriesRulesByTvgId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/forms/SeriesRuleEditorModalUtils.js', () => ({
|
||||
TITLE_MODES: [
|
||||
{ label: 'Exact', value: 'exact' },
|
||||
{ label: 'Contains', value: 'contains' },
|
||||
],
|
||||
DESCRIPTION_MODES: [
|
||||
{ label: 'Contains', value: 'contains' },
|
||||
{ label: 'Exact', value: 'exact' },
|
||||
],
|
||||
EPISODE_MODES: [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'New only', value: 'new' },
|
||||
],
|
||||
formatRange: vi.fn((start, end) => `${start} - ${end}`),
|
||||
getChannelOptions: vi.fn(() => [{ value: '10', label: '501 - HBO' }]),
|
||||
getTvgOptions: vi.fn(),
|
||||
previewSeriesRule: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Alert: ({ children, color }) => (
|
||||
<div data-testid="alert" data-color={color}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Badge: ({ children, color }) => (
|
||||
<span data-testid="badge" data-color={color}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Button: ({ children, onClick, disabled, loading, variant }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
data-loading={loading}
|
||||
data-variant={variant}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Divider: () => <hr />,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
ScrollAreaAutosize: ({ children }) => <div>{children}</div>,
|
||||
SegmentedControl: ({ data, value, onChange, size }) => (
|
||||
<div data-testid="segmented-control" data-size={size}>
|
||||
{data.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
data-active={value === item.value}
|
||||
onClick={() => onChange(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
Select: ({
|
||||
label,
|
||||
placeholder,
|
||||
data,
|
||||
value,
|
||||
onChange,
|
||||
description,
|
||||
}) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
{description && (
|
||||
<span data-testid="select-description">{description}</span>
|
||||
)}
|
||||
<select
|
||||
data-testid={`select-${label?.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{(data || []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children, size, c, fw }) => (
|
||||
<span data-size={size} data-color={c} data-fw={fw}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Textarea: ({ label, placeholder, value, onChange, description }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
{description && <span>{description}</span>}
|
||||
<textarea
|
||||
data-testid="textarea-description"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
TextInput: ({ label, placeholder, value, onChange, description }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
{description && <span>{description}</span>}
|
||||
<input
|
||||
data-testid="input-title"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────
|
||||
import SeriesRuleEditorModal from '../SeriesRuleEditorModal';
|
||||
import useChannelsStore from '../../../store/channels.jsx';
|
||||
import useEPGsStore from '../../../store/epgs.jsx';
|
||||
import { useDebounce } from '../../../utils.js';
|
||||
import { showNotification } from '../../../utils/notificationUtils.js';
|
||||
import { getChannelsSummary } from '../../../utils/forms/RecordingUtils.js';
|
||||
import { createSeriesRule, evaluateSeriesRulesByTvgId } from '../../../utils/guideUtils.js';
|
||||
import {
|
||||
getTvgOptions,
|
||||
previewSeriesRule,
|
||||
formatRange,
|
||||
} from '../../../utils/forms/SeriesRuleEditorModalUtils.js';
|
||||
|
||||
// ── Shared test data ───────────────────────────────────────────────────────
|
||||
const mockTvgs = [
|
||||
{ id: 1, tvg_id: 'tvg-1', name: 'Channel One' },
|
||||
{ id: 2, tvg_id: 'tvg-2', name: 'Channel Two' },
|
||||
];
|
||||
|
||||
const mockTvgsById = {
|
||||
1: { tvg_id: 'tvg-1', name: 'Channel One' },
|
||||
2: { tvg_id: 'tvg-2', name: 'Channel Two' },
|
||||
};
|
||||
|
||||
const mockChannels = [
|
||||
{ id: 10, name: 'HBO', channel_number: 501, epg_data_id: 1 },
|
||||
{ id: 20, name: 'CNN', channel_number: 102, epg_data_id: 2 },
|
||||
];
|
||||
|
||||
const mockTvgOptions = [
|
||||
{ value: 'tvg-1', label: 'Channel One (tvg-1)' },
|
||||
{ value: 'tvg-2', label: 'Channel Two (tvg-2)' },
|
||||
];
|
||||
|
||||
const mockPreviewResponse = {
|
||||
matches: [
|
||||
{
|
||||
id: 'p1',
|
||||
title: 'Match Show',
|
||||
sub_title: 'Episode 1',
|
||||
description: 'A great show',
|
||||
start_time: '2024-01-01T10:00:00Z',
|
||||
end_time: '2024-01-01T11:00:00Z',
|
||||
tvg_id: 'tvg-1',
|
||||
is_new: true,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
epg_found: true,
|
||||
};
|
||||
|
||||
// ── Setup helper ───────────────────────────────────────────────────────────
|
||||
const setupMocks = () => {
|
||||
const mockFetchRecordings = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(useEPGsStore).mockImplementation((selector) =>
|
||||
selector({ tvgs: mockTvgs, tvgsById: mockTvgsById })
|
||||
);
|
||||
|
||||
// Mock getState for the imperative call in handleSave
|
||||
useChannelsStore.getState = vi.fn().mockReturnValue({
|
||||
fetchRecordings: mockFetchRecordings,
|
||||
});
|
||||
|
||||
vi.mocked(getTvgOptions).mockReturnValue(mockTvgOptions);
|
||||
vi.mocked(getChannelsSummary).mockResolvedValue(mockChannels);
|
||||
vi.mocked(previewSeriesRule).mockResolvedValue({ matches: [], total: 0, epg_found: true });
|
||||
vi.mocked(createSeriesRule).mockResolvedValue({ id: 'rule-1' });
|
||||
vi.mocked(evaluateSeriesRulesByTvgId).mockResolvedValue(undefined);
|
||||
vi.mocked(useDebounce).mockImplementation((val) => val);
|
||||
vi.mocked(formatRange).mockImplementation((s, e) => `${s} - ${e}`);
|
||||
|
||||
return { mockFetchRecordings };
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
describe('SeriesRuleEditorModal', () => {
|
||||
let defaultProps;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupMocks();
|
||||
|
||||
defaultProps = {
|
||||
opened: true,
|
||||
onClose: vi.fn(),
|
||||
initialRule: null,
|
||||
onSaved: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the modal when opened is true', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the modal when opened is false', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} opened={false} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "New Series Rule" title when initialRule is null', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent('New Series Rule');
|
||||
});
|
||||
|
||||
it('shows "Edit Series Rule" title when initialRule is provided', () => {
|
||||
render(
|
||||
<SeriesRuleEditorModal
|
||||
{...defaultProps}
|
||||
initialRule={{ tvg_id: 'tvg-1', mode: 'all', title: 'Test' }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent('Edit Series Rule');
|
||||
});
|
||||
|
||||
it('renders the title input', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
expect(screen.getByTestId('input-title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the description textarea', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
expect(screen.getByTestId('textarea-description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Cancel and Save rule buttons', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
expect(screen.getByText('Save rule')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Save rule button is disabled when no title or description', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
expect(screen.getByText('Save rule')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('Save rule button is enabled when title is provided', async () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'My Show' },
|
||||
});
|
||||
expect(screen.getByText('Save rule')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('Save rule button is enabled when description is provided', async () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('textarea-description'), {
|
||||
target: { value: 'some description' },
|
||||
});
|
||||
expect(screen.getByText('Save rule')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── State hydration from initialRule ─────────────────────────────────────
|
||||
|
||||
describe('state hydration', () => {
|
||||
const initialRule = {
|
||||
tvg_id: 'tvg-1',
|
||||
mode: 'new',
|
||||
title: 'Pre-filled Title',
|
||||
title_mode: 'contains',
|
||||
description: 'Pre-filled description',
|
||||
description_mode: 'exact',
|
||||
channel_id: 10,
|
||||
};
|
||||
|
||||
it('pre-fills title from initialRule', () => {
|
||||
render(<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');
|
||||
});
|
||||
|
||||
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"]')
|
||||
);
|
||||
expect(allBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('resets fields when modal is reopened without initialRule', async () => {
|
||||
const { rerender } = render(
|
||||
<SeriesRuleEditorModal {...defaultProps} initialRule={initialRule} />
|
||||
);
|
||||
rerender(
|
||||
<SeriesRuleEditorModal {...defaultProps} opened={false} initialRule={null} />
|
||||
);
|
||||
rerender(
|
||||
<SeriesRuleEditorModal {...defaultProps} opened={true} initialRule={null} />
|
||||
);
|
||||
expect(screen.getByTestId('input-title')).toHaveValue('');
|
||||
expect(screen.getByTestId('textarea-description')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Channel loading ───────────────────────────────────────────────────────
|
||||
|
||||
describe('channel loading', () => {
|
||||
it('calls getChannelsSummary when modal opens', async () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(getChannelsSummary).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call getChannelsSummary when modal is closed', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} opened={false} />);
|
||||
expect(getChannelsSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles getChannelsSummary rejection gracefully', async () => {
|
||||
vi.mocked(getChannelsSummary).mockRejectedValue(new Error('Network error'));
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
// Should not throw; channel options just empty
|
||||
await waitFor(() => {
|
||||
expect(getChannelsSummary).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── TVG options ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('EPG channel options', () => {
|
||||
it('calls getTvgOptions with tvgs from store', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
expect(getTvgOptions).toHaveBeenCalledWith(mockTvgs);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Preview ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('preview', () => {
|
||||
it('does not call previewSeriesRule when title, description, and tvg_id are all empty', async () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
await waitFor(() => expect(getChannelsSummary).toHaveBeenCalled());
|
||||
expect(previewSeriesRule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls previewSeriesRule when title is entered', async () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'My Show' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(previewSeriesRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'My Show' }),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls previewSeriesRule when description is entered', async () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('textarea-description'), {
|
||||
target: { value: 'some words' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(previewSeriesRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ description: 'some words' }),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders preview match results', async () => {
|
||||
vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'Match Show' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Match Show/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders preview badge with match count', async () => {
|
||||
vi.mocked(previewSeriesRule).mockResolvedValue({ matches: [{ id: 'p1', title: 'X' }], total: 5, epg_found: true });
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'X' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('badge')).toHaveTextContent('1 of 5');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows preview error alert when previewSeriesRule rejects', async () => {
|
||||
vi.mocked(previewSeriesRule).mockRejectedValue(new Error('Preview failed'));
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'Boom' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('alert')).toHaveTextContent('Preview failed');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "No EPG channel matches" warning when epg_found is false and tvg_id is set', async () => {
|
||||
vi.mocked(previewSeriesRule).mockResolvedValue({
|
||||
matches: [],
|
||||
total: 0,
|
||||
epg_found: false,
|
||||
});
|
||||
render(<SeriesRuleEditorModal {...defaultProps} initialRule={{ tvg_id: 'tvg-999', title: 'X' }} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No EPG channel matches this tvg_id/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows warn alert when preview.warn is true', async () => {
|
||||
vi.mocked(previewSeriesRule).mockResolvedValue({
|
||||
matches: Array(50).fill({ id: 'x', title: 'X' }),
|
||||
total: 50,
|
||||
epg_found: true,
|
||||
warn: true,
|
||||
});
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'X' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/This rule matches many programs/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "No matching upcoming programs" when matches empty and filter set', async () => {
|
||||
vi.mocked(previewSeriesRule).mockResolvedValue({
|
||||
matches: [],
|
||||
total: 0,
|
||||
epg_found: true,
|
||||
});
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'Unknown Show' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/No matching upcoming programs/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders sub_title in preview match', async () => {
|
||||
vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'Match Show' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Episode 1/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders "(NEW)" marker for new episodes in preview', async () => {
|
||||
vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'Match Show' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/(NEW)/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleSave ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('handleSave', () => {
|
||||
const renderWithTitle = () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'My Show' },
|
||||
});
|
||||
};
|
||||
|
||||
it('calls createSeriesRule with correct payload', async () => {
|
||||
renderWithTitle();
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
expect(createSeriesRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'My Show', mode: 'all' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls evaluateSeriesRulesByTvgId after createSeriesRule', async () => {
|
||||
renderWithTitle();
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
expect(evaluateSeriesRulesByTvgId).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls fetchRecordings after save', async () => {
|
||||
const { mockFetchRecordings } = setupMocks();
|
||||
renderWithTitle();
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRecordings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls showNotification on success', async () => {
|
||||
renderWithTitle();
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Series rule saved' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onSaved callback on success', async () => {
|
||||
renderWithTitle();
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onSaved).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after save', async () => {
|
||||
renderWithTitle();
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call showNotification when createSeriesRule throws', async () => {
|
||||
vi.mocked(createSeriesRule).mockRejectedValue(new Error('Server error'));
|
||||
renderWithTitle();
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
expect(createSeriesRule).toHaveBeenCalled();
|
||||
});
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not crash when evaluateSeriesRulesByTvgId throws', async () => {
|
||||
vi.mocked(evaluateSeriesRulesByTvgId).mockRejectedValue(new Error('eval fail'));
|
||||
renderWithTitle();
|
||||
await expect(
|
||||
waitFor(() => {
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('does not crash when fetchRecordings throws', async () => {
|
||||
useChannelsStore.getState = vi.fn().mockReturnValue({
|
||||
fetchRecordings: vi.fn().mockRejectedValue(new Error('fetch fail')),
|
||||
});
|
||||
renderWithTitle();
|
||||
await expect(
|
||||
waitFor(() => {
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('includes channel_id in payload when channelId is set', async () => {
|
||||
vi.mocked(getChannelsSummary).mockResolvedValue(mockChannels);
|
||||
render(<SeriesRuleEditorModal {...defaultProps} initialRule={{ channel_id: 10, title: 'Show' }} />);
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
expect(createSeriesRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channel_id: 10 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('omits channel_id from payload when no channel selected', async () => {
|
||||
renderWithTitle();
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
await waitFor(() => {
|
||||
const call = vi.mocked(createSeriesRule).mock.calls[0][0];
|
||||
expect(call).not.toHaveProperty('channel_id');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cancel button ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Cancel button', () => {
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── SegmentedControl interactions ─────────────────────────────────────────
|
||||
|
||||
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"]')));
|
||||
// No error thrown; state updated without crash
|
||||
});
|
||||
|
||||
it('changes episode mode to "New only"', () => {
|
||||
render(<SeriesRuleEditorModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('New only'));
|
||||
// Payload mode becomes 'new'; verify via save
|
||||
fireEvent.change(screen.getByTestId('input-title'), {
|
||||
target: { value: 'Test' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Save rule'));
|
||||
waitFor(() => {
|
||||
expect(createSeriesRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: 'new' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
566
frontend/src/components/forms/__tests__/Stream.test.jsx
Normal file
566
frontend/src/components/forms/__tests__/Stream.test.jsx
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Module-level form state (survives re-renders, accessible in beforeEach) ───
|
||||
const __form = { values: {}, resetSpy: null };
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/streamProfiles.jsx', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/forms/StreamUtils.js', () => ({
|
||||
addStream: vi.fn(),
|
||||
getResolver: vi.fn(() => undefined),
|
||||
updateStream: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── react-hook-form ────────────────────────────────────────────────────────────
|
||||
vi.mock('react-hook-form', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
useForm: vi.fn(({ defaultValues } = {}) => {
|
||||
const [formValues, setFormValues] = React.useState(() => {
|
||||
const vals = defaultValues || {};
|
||||
Object.assign(__form.values, vals);
|
||||
return vals;
|
||||
});
|
||||
|
||||
const updateField = (name, value) => {
|
||||
__form.values[name] = value;
|
||||
setFormValues((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const register = (name) => ({
|
||||
name,
|
||||
value: __form.values[name] ?? '',
|
||||
onChange: (e) => updateField(name, e.target.value),
|
||||
onBlur: () => {},
|
||||
});
|
||||
|
||||
const setValue = (name, value) => updateField(name, value);
|
||||
|
||||
const watch = (name) => formValues[name];
|
||||
|
||||
const handleSubmit = (onSubmit) => (e) => {
|
||||
e?.preventDefault?.();
|
||||
return onSubmit({ ...__form.values });
|
||||
};
|
||||
|
||||
// Keep reset stable across re-renders so useEffect([defaultValues, reset])
|
||||
// in Stream.jsx does not fire on every render and cause an infinite loop.
|
||||
const resetImpl = React.useCallback((newValues) => {
|
||||
const vals = newValues || defaultValues || {};
|
||||
Object.assign(__form.values, vals);
|
||||
setFormValues({ ...vals });
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const resetRef = React.useRef(null);
|
||||
if (!resetRef.current) {
|
||||
resetRef.current = vi.fn((...args) => resetImpl(...args));
|
||||
__form.resetSpy = resetRef.current;
|
||||
}
|
||||
const reset = resetRef.current;
|
||||
|
||||
return {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors: {}, isSubmitting: false },
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Button: ({ children, type, disabled }) => (
|
||||
<button type={type} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
Select: ({ label, value, onChange, data, placeholder, error }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<select
|
||||
data-testid={`select-${label?.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
>
|
||||
<option value="">{placeholder ?? `-- ${label} --`}</option>
|
||||
{(data ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <span data-testid={`error-${label}`}>{error}</span>}
|
||||
</div>
|
||||
),
|
||||
TextInput: ({ label, name, value, onChange, error, ...rest }) => (
|
||||
<div>
|
||||
<label htmlFor={name}>{label}</label>
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
data-testid={`input-${label?.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
{error && <span data-testid={`error-${label}`}>{error}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import Stream from '../Stream';
|
||||
import useStreamProfilesStore from '../../../store/streamProfiles.jsx';
|
||||
import useChannelsStore from '../../../store/channels.jsx';
|
||||
import * as StreamUtils from '../../../utils/forms/StreamUtils.js';
|
||||
|
||||
// ── Shared test data ───────────────────────────────────────────────────────────
|
||||
const mockProfiles = [
|
||||
{ id: 1, name: 'Default' },
|
||||
{ id: 2, name: 'HD Profile' },
|
||||
];
|
||||
|
||||
const mockChannelGroups = {
|
||||
10: { id: 10, name: 'Sports' },
|
||||
20: { id: 20, name: 'News' },
|
||||
};
|
||||
|
||||
const makeStream = (overrides = {}) => ({
|
||||
id: 'stream-1',
|
||||
name: 'Test Stream',
|
||||
url: 'http://example.com/stream',
|
||||
channel_group: 10,
|
||||
stream_profile_id: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupMocks = () => {
|
||||
vi.mocked(useStreamProfilesStore).mockImplementation((sel) =>
|
||||
sel({ profiles: mockProfiles })
|
||||
);
|
||||
vi.mocked(useChannelsStore).mockImplementation((sel) =>
|
||||
sel({ channelGroups: mockChannelGroups })
|
||||
);
|
||||
vi.mocked(StreamUtils.addStream).mockResolvedValue(undefined);
|
||||
vi.mocked(StreamUtils.updateStream).mockResolvedValue(undefined);
|
||||
};
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
stream: null,
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Stream', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset module-level form state before each test
|
||||
__form.values = {};
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
// ── Visibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when isOpen is true', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when isOpen is false', () => {
|
||||
render(<Stream {...defaultProps({ isOpen: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Stream" as the modal title', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent('Stream');
|
||||
});
|
||||
|
||||
it('calls onClose when modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Stream {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form fields ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('form fields', () => {
|
||||
it('renders Stream Name input', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-stream-name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Stream URL input', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-stream-url')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Group select', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByTestId('select-group')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Stream Profile select', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByTestId('select-stream-profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Submit button', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByText('Submit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates Group select with channel groups', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByText('Sports')).toBeInTheDocument();
|
||||
expect(screen.getByText('News')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates Stream Profile select with profiles', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByText('Default')).toBeInTheDocument();
|
||||
expect(screen.getByText('HD Profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Optional" placeholder for Stream Profile', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByText('Optional')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Default values ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('default values', () => {
|
||||
it('pre-fills name from stream prop', () => {
|
||||
render(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
expect(screen.getByTestId('input-stream-name')).toHaveValue('Test Stream');
|
||||
});
|
||||
|
||||
it('pre-fills url from stream prop', () => {
|
||||
render(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
expect(screen.getByTestId('input-stream-url')).toHaveValue(
|
||||
'http://example.com/stream'
|
||||
);
|
||||
});
|
||||
|
||||
it('pre-selects group from stream prop', () => {
|
||||
render(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
expect(screen.getByTestId('select-group')).toHaveValue('10');
|
||||
});
|
||||
|
||||
it('pre-selects stream profile from stream prop', () => {
|
||||
render(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
expect(screen.getByTestId('select-stream-profile')).toHaveValue('1');
|
||||
});
|
||||
|
||||
it('renders empty name when no stream provided', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-stream-name')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('renders empty url when no stream provided', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-stream-url')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('renders empty group when stream has no channel_group', () => {
|
||||
render(
|
||||
<Stream {...defaultProps({ stream: makeStream({ channel_group: null }) })} />
|
||||
);
|
||||
expect(screen.getByTestId('select-group')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('renders empty profile when stream has no stream_profile_id', () => {
|
||||
render(
|
||||
<Stream
|
||||
{...defaultProps({ stream: makeStream({ stream_profile_id: null }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('select-stream-profile')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Create stream ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('create stream (no existing stream)', () => {
|
||||
it('calls addStream on submit when no stream id', async () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-stream-name'), {
|
||||
target: { value: 'New Stream' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-stream-url'), {
|
||||
target: { value: 'http://new.stream/live' },
|
||||
});
|
||||
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Submit' }).closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamUtils.addStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'New Stream',
|
||||
url: 'http://new.stream/live',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call updateStream when creating', async () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-stream-name'), {
|
||||
target: { value: 'New Stream' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-stream-url'), {
|
||||
target: { value: 'http://new.stream' },
|
||||
});
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamUtils.updateStream).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful create', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Stream {...defaultProps({ onClose })} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-stream-name'), {
|
||||
target: { value: 'New Stream' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-stream-url'), {
|
||||
target: { value: 'http://new.stream' },
|
||||
});
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('converts channel_group string to integer in payload', async () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-stream-name'), {
|
||||
target: { value: 'S' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-stream-url'), {
|
||||
target: { value: 'http://x.com' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('select-group'), {
|
||||
target: { value: '10' },
|
||||
});
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamUtils.addStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channel_group: 10 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets channel_group to null when no group selected', async () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-stream-name'), {
|
||||
target: { value: 'S' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-stream-url'), {
|
||||
target: { value: 'http://x.com' },
|
||||
});
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const call = vi.mocked(StreamUtils.addStream).mock.calls[0][0];
|
||||
expect(call.channel_group).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('converts stream_profile_id string to integer in payload', async () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-stream-name'), {
|
||||
target: { value: 'S' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-stream-url'), {
|
||||
target: { value: 'http://x.com' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('select-stream-profile'), {
|
||||
target: { value: '2' },
|
||||
});
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamUtils.addStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stream_profile_id: 2 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets stream_profile_id to null when no profile selected', async () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-stream-name'), {
|
||||
target: { value: 'S' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-stream-url'), {
|
||||
target: { value: 'http://x.com' },
|
||||
});
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const call = vi.mocked(StreamUtils.addStream).mock.calls[0][0];
|
||||
expect(call.stream_profile_id).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Update stream ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('update stream (existing stream)', () => {
|
||||
it('calls updateStream with the stream id on submit', async () => {
|
||||
render(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamUtils.updateStream).toHaveBeenCalledWith(
|
||||
'stream-1',
|
||||
expect.objectContaining({
|
||||
name: 'Test Stream',
|
||||
url: 'http://example.com/stream',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call addStream when updating', async () => {
|
||||
render(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamUtils.addStream).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful update', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Stream {...defaultProps({ stream: makeStream(), onClose })} />);
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('converts channel_group back to integer for update payload', async () => {
|
||||
render(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamUtils.updateStream).toHaveBeenCalledWith(
|
||||
'stream-1',
|
||||
expect.objectContaining({ channel_group: 10 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('converts stream_profile_id back to integer for update payload', async () => {
|
||||
render(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Submit' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamUtils.updateStream).toHaveBeenCalledWith(
|
||||
'stream-1',
|
||||
expect.objectContaining({ stream_profile_id: 1 })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── getResolver ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getResolver', () => {
|
||||
it('calls getResolver on mount', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(StreamUtils.getResolver).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form reset on stream change ────────────────────────────────────────────
|
||||
|
||||
describe('form reset', () => {
|
||||
it('resets the form when stream prop changes', () => {
|
||||
const { rerender } = render(<Stream {...defaultProps()} />);
|
||||
|
||||
rerender(<Stream {...defaultProps({ stream: makeStream() })} />);
|
||||
|
||||
expect(__form.resetSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Submit button state ────────────────────────────────────────────────────
|
||||
|
||||
describe('submit button', () => {
|
||||
it('Submit button is not disabled by default', () => {
|
||||
render(<Stream {...defaultProps()} />);
|
||||
expect(screen.getByText('Submit')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
607
frontend/src/components/forms/__tests__/StreamProfile.test.jsx
Normal file
607
frontend/src/components/forms/__tests__/StreamProfile.test.jsx
Normal file
|
|
@ -0,0 +1,607 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Module-level form state ────────────────────────────────────────────────────
|
||||
const __form = { values: {}, resetSpy: null };
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/userAgents', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/forms/StreamProfileUtils.js', () => ({
|
||||
addStreamProfile: vi.fn(),
|
||||
updateStreamProfile: vi.fn(),
|
||||
getResolver: vi.fn(() => undefined),
|
||||
toCommandSelection: vi.fn((cmd) => {
|
||||
const builtins = ['ffmpeg', 'streamlink', 'cvlc', 'yt-dlp'];
|
||||
return builtins.includes(cmd) ? cmd : '__custom__';
|
||||
}),
|
||||
BUILT_IN_COMMANDS: [
|
||||
{ value: 'ffmpeg', label: 'FFmpeg' },
|
||||
{ value: 'streamlink', label: 'Streamlink' },
|
||||
{ value: 'cvlc', label: 'VLC' },
|
||||
{ value: 'yt-dlp', label: 'yt-dlp' },
|
||||
{ value: '__custom__', label: 'Custom…' },
|
||||
],
|
||||
COMMAND_EXAMPLES: {
|
||||
ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1',
|
||||
streamlink: '{streamUrl} --http-header User-Agent={userAgent} best --stdout',
|
||||
},
|
||||
}));
|
||||
|
||||
// ── react-hook-form ────────────────────────────────────────────────────────────
|
||||
vi.mock('react-hook-form', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
useForm: vi.fn(({ defaultValues } = {}) => {
|
||||
const [formValues, setFormValues] = React.useState(() => {
|
||||
const vals = defaultValues || {};
|
||||
Object.assign(__form.values, vals);
|
||||
return vals;
|
||||
});
|
||||
|
||||
const updateField = (name, value) => {
|
||||
__form.values[name] = value;
|
||||
setFormValues((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const register = (name) => ({
|
||||
name,
|
||||
value: __form.values[name] ?? '',
|
||||
onChange: (e) => updateField(name, e.target.value),
|
||||
onBlur: () => {},
|
||||
});
|
||||
|
||||
const setValue = (name, value) => updateField(name, value);
|
||||
const watch = (name) => formValues[name];
|
||||
|
||||
const handleSubmit = (onSubmit) => (e) => {
|
||||
e?.preventDefault?.();
|
||||
return onSubmit({ ...__form.values });
|
||||
};
|
||||
|
||||
const resetImpl = React.useCallback((newValues) => {
|
||||
const vals = newValues || defaultValues || {};
|
||||
Object.assign(__form.values, vals);
|
||||
setFormValues({ ...vals });
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const resetRef = React.useRef(null);
|
||||
if (!resetRef.current) {
|
||||
resetRef.current = vi.fn((...args) => resetImpl(...args));
|
||||
__form.resetSpy = resetRef.current;
|
||||
}
|
||||
|
||||
return {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors: {}, isSubmitting: false },
|
||||
reset: resetRef.current,
|
||||
setValue,
|
||||
watch,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Button: ({ children, type, disabled }) => (
|
||||
<button type={type} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Checkbox: ({ label, checked, onChange }) => (
|
||||
<div>
|
||||
<label htmlFor="checkbox-is-active">{label}</label>
|
||||
<input
|
||||
id="checkbox-is-active"
|
||||
data-testid="checkbox-is-active"
|
||||
type="checkbox"
|
||||
checked={checked ?? false}
|
||||
onChange={(e) => onChange({ currentTarget: { checked: e.target.checked } })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
Select: ({ label, value, onChange, data, placeholder, error, clearable, disabled, description }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
{description && <div>{description}</div>}
|
||||
<select
|
||||
data-testid={`select-${label?.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
value={value ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value || (clearable ? null : ''))}
|
||||
>
|
||||
<option value="">{placeholder ?? `-- ${label} --`}</option>
|
||||
{(data ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <span data-testid={`error-${label}`}>{error}</span>}
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
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>}
|
||||
<textarea
|
||||
id={name}
|
||||
name={name}
|
||||
data-testid={`textarea-${label?.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
value={value ?? ''}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
{error && <span data-testid={`error-${label}`}>{error}</span>}
|
||||
</div>
|
||||
),
|
||||
TextInput: ({ label, name, value, onChange, error, disabled, ...rest }) => (
|
||||
<div>
|
||||
<label htmlFor={name}>{label}</label>
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
data-testid={`input-${label?.replace(/\s+/g, '-').toLowerCase()}`}
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
/>
|
||||
{error && <span data-testid={`error-${label}`}>{error}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import StreamProfile from '../StreamProfile';
|
||||
import useUserAgentsStore from '../../../store/userAgents';
|
||||
import * as StreamProfileUtils from '../../../utils/forms/StreamProfileUtils.js';
|
||||
|
||||
// ── Shared test data ───────────────────────────────────────────────────────────
|
||||
const mockUserAgents = [
|
||||
{ id: 1, name: 'Chrome' },
|
||||
{ id: 2, name: 'Firefox' },
|
||||
];
|
||||
|
||||
const makeProfile = (overrides = {}) => ({
|
||||
id: 'sp-1',
|
||||
name: 'My Profile',
|
||||
command: 'ffmpeg',
|
||||
parameters: '-i {streamUrl}',
|
||||
is_active: true,
|
||||
user_agent: '1',
|
||||
locked: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupMocks = () => {
|
||||
vi.mocked(useUserAgentsStore).mockImplementation((sel) =>
|
||||
sel({ userAgents: mockUserAgents })
|
||||
);
|
||||
vi.mocked(StreamProfileUtils.addStreamProfile).mockResolvedValue(undefined);
|
||||
vi.mocked(StreamProfileUtils.updateStreamProfile).mockResolvedValue(undefined);
|
||||
vi.mocked(StreamProfileUtils.getResolver).mockReturnValue(undefined);
|
||||
vi.mocked(StreamProfileUtils.toCommandSelection).mockImplementation((cmd) => {
|
||||
const builtins = ['ffmpeg', 'streamlink', 'cvlc', 'yt-dlp'];
|
||||
return builtins.includes(cmd) ? cmd : '__custom__';
|
||||
});
|
||||
};
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
profile: null,
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('StreamProfile', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__form.values = {};
|
||||
__form.resetSpy = null;
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
// ── Visibility ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders the modal when isOpen is true', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the modal when isOpen is false', () => {
|
||||
render(<StreamProfile {...defaultProps({ isOpen: false })} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Stream Profile" as the modal title', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent('Stream Profile');
|
||||
});
|
||||
|
||||
it('calls onClose when modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<StreamProfile {...defaultProps({ onClose })} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form fields ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('form fields', () => {
|
||||
it('renders Name input', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Command select', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('select-command')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Parameters textarea', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('textarea-parameters')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders User-Agent select', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('select-user-agent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Is Active checkbox', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('checkbox-is-active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Save button', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates Command select with built-in options', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByText('FFmpeg')).toBeInTheDocument();
|
||||
expect(screen.getByText('Streamlink')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Custom…').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('populates User-Agent select with user agents', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByText('Chrome')).toBeInTheDocument();
|
||||
expect(screen.getByText('Firefox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Default values ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('default values', () => {
|
||||
it('pre-fills name from profile prop', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('input-name')).toHaveValue('My Profile');
|
||||
});
|
||||
|
||||
it('pre-fills parameters from profile prop', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('textarea-parameters')).toHaveValue('-i {streamUrl}');
|
||||
});
|
||||
|
||||
it('pre-selects command from profile prop', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('select-command')).toHaveValue('ffmpeg');
|
||||
});
|
||||
|
||||
it('pre-selects user-agent from profile prop', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('select-user-agent')).toHaveValue('1');
|
||||
});
|
||||
|
||||
it('checkbox is checked when is_active is true', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
|
||||
});
|
||||
|
||||
it('checkbox is unchecked when is_active is false', () => {
|
||||
render(
|
||||
<StreamProfile
|
||||
{...defaultProps({ profile: makeProfile({ is_active: false }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('defaults name to empty string when no profile', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-name')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('defaults parameters to empty string when no profile', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('textarea-parameters')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Command selection ───────────────────────────────────────────────────────
|
||||
|
||||
describe('command selection', () => {
|
||||
it('does not show Custom Command input when a built-in is selected', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.queryByTestId('input-custom-command')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Custom Command input when Custom… is selected', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: '__custom__' },
|
||||
});
|
||||
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Custom Command input when switching from custom back to built-in', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: '__custom__' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: 'ffmpeg' },
|
||||
});
|
||||
expect(screen.queryByTestId('input-custom-command')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Custom Command input when profile has a custom command', () => {
|
||||
vi.mocked(StreamProfileUtils.toCommandSelection).mockReturnValue('__custom__');
|
||||
render(
|
||||
<StreamProfile
|
||||
{...defaultProps({ profile: makeProfile({ command: '/usr/bin/mycmd' }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets command form value when built-in is selected', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: 'streamlink' },
|
||||
});
|
||||
expect(__form.values.command).toBe('streamlink');
|
||||
});
|
||||
|
||||
it('clears command form value when Custom… is selected', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: '__custom__' },
|
||||
});
|
||||
expect(__form.values.command).toBe('');
|
||||
});
|
||||
|
||||
it('shows parameters example hint for ffmpeg', () => {
|
||||
const { container } = render(
|
||||
<StreamProfile {...defaultProps({ profile: makeProfile() })} />
|
||||
);
|
||||
expect(container.textContent).toMatch(/-user_agent \{userAgent\}/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Is Active checkbox ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Is Active checkbox', () => {
|
||||
it('toggles is_active to false when unchecked', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
fireEvent.click(screen.getByTestId('checkbox-is-active'));
|
||||
expect(__form.values.is_active).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles is_active to true when checked', () => {
|
||||
render(
|
||||
<StreamProfile
|
||||
{...defaultProps({ profile: makeProfile({ is_active: false }) })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('checkbox-is-active'));
|
||||
expect(__form.values.is_active).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── User-Agent select ───────────────────────────────────────────────────────
|
||||
|
||||
describe('User-Agent select', () => {
|
||||
it('updates user_agent form value when changed', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByTestId('select-user-agent'), {
|
||||
target: { value: '2' },
|
||||
});
|
||||
expect(__form.values.user_agent).toBe('2');
|
||||
});
|
||||
|
||||
it('sets user_agent to empty string when cleared', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
fireEvent.change(screen.getByTestId('select-user-agent'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
expect(__form.values.user_agent).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Locked profile ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('locked profile', () => {
|
||||
it('disables Name input when profile is locked', () => {
|
||||
render(
|
||||
<StreamProfile
|
||||
{...defaultProps({ profile: makeProfile({ locked: true }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('input-name')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Command select when profile is locked', () => {
|
||||
render(
|
||||
<StreamProfile
|
||||
{...defaultProps({ profile: makeProfile({ locked: true }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('select-command')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Parameters textarea when profile is locked', () => {
|
||||
render(
|
||||
<StreamProfile
|
||||
{...defaultProps({ profile: makeProfile({ locked: true }) })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('textarea-parameters')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not disable inputs when profile is not locked', () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
expect(screen.getByTestId('input-name')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not disable inputs when no profile is provided', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(screen.getByTestId('input-name')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Create profile ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('create profile (no existing profile)', () => {
|
||||
it('calls addStreamProfile on submit when no profile id', async () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-name'), {
|
||||
target: { value: 'New Profile' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('select-command'), {
|
||||
target: { value: 'ffmpeg' },
|
||||
});
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamProfileUtils.addStreamProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'New Profile' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call updateStreamProfile when creating', async () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-name'), {
|
||||
target: { value: 'New Profile' },
|
||||
});
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamProfileUtils.updateStreamProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful create', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<StreamProfile {...defaultProps({ onClose })} />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-name'), {
|
||||
target: { value: 'New Profile' },
|
||||
});
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Update profile ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('update profile (existing profile)', () => {
|
||||
it('calls updateStreamProfile with the profile id on submit', async () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamProfileUtils.updateStreamProfile).toHaveBeenCalledWith(
|
||||
'sp-1',
|
||||
expect.objectContaining({ name: 'My Profile' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call addStreamProfile when updating', async () => {
|
||||
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(StreamProfileUtils.addStreamProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful update', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<StreamProfile {...defaultProps({ profile: makeProfile(), onClose })} />
|
||||
);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form reset ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('form reset', () => {
|
||||
it('resets the form when profile prop changes', () => {
|
||||
const { rerender } = render(<StreamProfile {...defaultProps()} />);
|
||||
|
||||
rerender(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
|
||||
|
||||
expect(__form.resetSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls reset after successful submit', async () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(__form.resetSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── getResolver ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getResolver', () => {
|
||||
it('calls getResolver on mount', () => {
|
||||
render(<StreamProfile {...defaultProps()} />);
|
||||
expect(StreamProfileUtils.getResolver).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
332
frontend/src/components/forms/__tests__/SuperuserForm.test.jsx
Normal file
332
frontend/src/components/forms/__tests__/SuperuserForm.test.jsx
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Asset mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../assets/logo.png', () => ({ default: 'logo.png' }));
|
||||
|
||||
// ── API mock ───────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../api', () => ({
|
||||
default: {
|
||||
createSuperUser: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/auth', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../../store/settings', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── @mantine/core ──────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Button: ({ children, type, fullWidth }) => (
|
||||
<button type={type} data-fullwidth={fullWidth}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Center: ({ children, style }) => <div style={style}>{children}</div>,
|
||||
Divider: ({ style }) => <hr style={style} />,
|
||||
Image: ({ src, alt }) => <img src={src} alt={alt} />,
|
||||
Paper: ({ children, style }) => <div style={style}>{children}</div>,
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children, size, color, align }) => (
|
||||
<span data-size={size} data-color={color} data-align={align}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
TextInput: ({ label, name, value, onChange, required, type }) => (
|
||||
<div>
|
||||
<label htmlFor={name}>{label}</label>
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
data-testid={`input-${name}`}
|
||||
type={type ?? 'text'}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
required={required}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
Title: ({ children, order, align }) => {
|
||||
const Tag = `h${order ?? 1}`;
|
||||
return <Tag data-align={align}>{children}</Tag>;
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import SuperuserForm from '../SuperuserForm';
|
||||
import API from '../../../api';
|
||||
import useAuthStore from '../../../store/auth';
|
||||
import useSettingsStore from '../../../store/settings';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const setupMocks = ({
|
||||
version = {},
|
||||
fetchVersion = vi.fn(),
|
||||
setSuperuserExists = vi.fn(),
|
||||
} = {}) => {
|
||||
vi.mocked(useAuthStore).mockImplementation((sel) =>
|
||||
sel({ setSuperuserExists })
|
||||
);
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ fetchVersion, version })
|
||||
);
|
||||
return { fetchVersion, setSuperuserExists };
|
||||
};
|
||||
|
||||
describe('SuperuserForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the Dispatcharr title', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
expect(screen.getByText('Dispatcharr')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the welcome message', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Welcome! Create your Super User Account to get started.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the logo image', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
expect(screen.getByAltText('Dispatcharr Logo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Username input', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
expect(screen.getByTestId('input-username')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Password input with type="password"', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
const input = screen.getByTestId('input-password');
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
it('renders Email input with type="email"', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
const input = screen.getByTestId('input-email');
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute('type', 'email');
|
||||
});
|
||||
|
||||
it('renders the Create Account button', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Create Account' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render version text when version is not loaded', () => {
|
||||
setupMocks({ version: {} });
|
||||
render(<SuperuserForm />);
|
||||
expect(screen.queryByText(/^v/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders version text when version is loaded', () => {
|
||||
setupMocks({ version: { version: '1.2.3' } });
|
||||
render(<SuperuserForm />);
|
||||
expect(screen.getByText('v1.2.3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── useEffect ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useEffect', () => {
|
||||
it('calls fetchVersion on mount', () => {
|
||||
const { fetchVersion } = setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
expect(fetchVersion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call fetchVersion again on re-render with same fetchVersion ref', () => {
|
||||
const { fetchVersion } = setupMocks();
|
||||
const { rerender } = render(<SuperuserForm />);
|
||||
rerender(<SuperuserForm />);
|
||||
// fetchVersion is stable, so useEffect should only fire once
|
||||
expect(fetchVersion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form field interactions ────────────────────────────────────────────────
|
||||
|
||||
describe('form field interactions', () => {
|
||||
it('updates username field when typed', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
fireEvent.change(screen.getByTestId('input-username'), {
|
||||
target: { name: 'username', value: 'admin' },
|
||||
});
|
||||
expect(screen.getByTestId('input-username')).toHaveValue('admin');
|
||||
});
|
||||
|
||||
it('updates password field when typed', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
fireEvent.change(screen.getByTestId('input-password'), {
|
||||
target: { name: 'password', value: 'secret123' },
|
||||
});
|
||||
expect(screen.getByTestId('input-password')).toHaveValue('secret123');
|
||||
});
|
||||
|
||||
it('updates email field when typed', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
fireEvent.change(screen.getByTestId('input-email'), {
|
||||
target: { name: 'email', value: 'admin@example.com' },
|
||||
});
|
||||
expect(screen.getByTestId('input-email')).toHaveValue('admin@example.com');
|
||||
});
|
||||
|
||||
it('initializes all fields as empty strings', () => {
|
||||
setupMocks();
|
||||
render(<SuperuserForm />);
|
||||
expect(screen.getByTestId('input-username')).toHaveValue('');
|
||||
expect(screen.getByTestId('input-password')).toHaveValue('');
|
||||
expect(screen.getByTestId('input-email')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form submission ────────────────────────────────────────────────────────
|
||||
|
||||
describe('form submission', () => {
|
||||
it('calls API.createSuperUser with form values on submit', async () => {
|
||||
setupMocks();
|
||||
vi.mocked(API.createSuperUser).mockResolvedValue({
|
||||
superuser_exists: false,
|
||||
});
|
||||
render(<SuperuserForm />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-username'), {
|
||||
target: { name: 'username', value: 'admin' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-password'), {
|
||||
target: { name: 'password', value: 'secret' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-email'), {
|
||||
target: { name: 'email', value: 'admin@test.com' },
|
||||
});
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Create Account' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.createSuperUser).toHaveBeenCalledWith({
|
||||
username: 'admin',
|
||||
password: 'secret',
|
||||
email: 'admin@test.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setSuperuserExists(true) when response.superuser_exists is true', async () => {
|
||||
const { setSuperuserExists } = setupMocks();
|
||||
vi.mocked(API.createSuperUser).mockResolvedValue({
|
||||
superuser_exists: true,
|
||||
});
|
||||
render(<SuperuserForm />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-username'), {
|
||||
target: { name: 'username', value: 'admin' },
|
||||
});
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Create Account' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setSuperuserExists).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call setSuperuserExists when response.superuser_exists is false', async () => {
|
||||
const { setSuperuserExists } = setupMocks();
|
||||
vi.mocked(API.createSuperUser).mockResolvedValue({
|
||||
superuser_exists: false,
|
||||
});
|
||||
render(<SuperuserForm />);
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Create Account' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.createSuperUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(setSuperuserExists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not throw when API.createSuperUser rejects', async () => {
|
||||
setupMocks();
|
||||
vi.mocked(API.createSuperUser).mockRejectedValue(new Error('Network'));
|
||||
render(<SuperuserForm />);
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Create Account' }).closest('form')
|
||||
);
|
||||
|
||||
await expect(
|
||||
waitFor(() => expect(API.createSuperUser).toHaveBeenCalled())
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('does not call setSuperuserExists when API throws', async () => {
|
||||
const { setSuperuserExists } = setupMocks();
|
||||
vi.mocked(API.createSuperUser).mockRejectedValue(new Error('Network'));
|
||||
render(<SuperuserForm />);
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Create Account' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.createSuperUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(setSuperuserExists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits with empty email when email field is left blank', async () => {
|
||||
setupMocks();
|
||||
vi.mocked(API.createSuperUser).mockResolvedValue({
|
||||
superuser_exists: false,
|
||||
});
|
||||
render(<SuperuserForm />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-username'), {
|
||||
target: { name: 'username', value: 'admin' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('input-password'), {
|
||||
target: { name: 'password', value: 'secret' },
|
||||
});
|
||||
|
||||
fireEvent.submit(
|
||||
screen.getByRole('button', { name: 'Create Account' }).closest('form')
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.createSuperUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: '' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
686
frontend/src/components/forms/__tests__/User.test.jsx
Normal file
686
frontend/src/components/forms/__tests__/User.test.jsx
Normal file
|
|
@ -0,0 +1,686 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../constants', () => ({
|
||||
USER_LEVELS: { ADMIN: 1, STREAMER: 2, USER: 3 },
|
||||
USER_LEVEL_LABELS: { 1: 'Admin', 2: 'Streamer', 3: 'User' },
|
||||
}));
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../../store/outputProfiles', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../../store/auth', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils', () => ({ copyToClipboard: vi.fn() }));
|
||||
|
||||
vi.mock('../../../utils/forms/UserUtils.js', () => ({
|
||||
createUser: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
generateApiKey: vi.fn(),
|
||||
revokeApiKey: vi.fn(),
|
||||
formValuesToPayload: vi.fn(),
|
||||
getFormInitialValues: vi.fn(() => ({})),
|
||||
getFormValidators: vi.fn(() => ({})),
|
||||
userToFormValues: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
// ── Mantine form ───────────────────────────────────────────────────────────────
|
||||
const mockForm = {
|
||||
getInputProps: vi.fn(() => ({})),
|
||||
key: vi.fn((k) => k),
|
||||
setValues: vi.fn(),
|
||||
setFieldValue: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
getValues: vi.fn(() => ({ user_level: '3' })),
|
||||
onSubmit: vi.fn((fn) => (e) => { e?.preventDefault?.(); return fn(); }),
|
||||
submitting: false,
|
||||
};
|
||||
|
||||
vi.mock('@mantine/form', () => ({
|
||||
useForm: vi.fn(() => mockForm),
|
||||
}));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
Copy: () => <svg data-testid="icon-copy" />,
|
||||
Key: () => <svg data-testid="icon-key" />,
|
||||
RotateCcwKey: () => <svg data-testid="icon-rotate-ccw-key" />,
|
||||
X: () => <svg data-testid="icon-x" />,
|
||||
}));
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
ActionIcon: ({ children, onClick, disabled }) => (
|
||||
<button data-testid="action-icon" onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Button: ({ children, onClick, disabled, loading, type, leftSection }) => (
|
||||
<button
|
||||
type={type || 'button'}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
data-loading={String(loading)}
|
||||
>
|
||||
{leftSection}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>×</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
MultiSelect: ({ label, onChange }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input
|
||||
data-testid={`multiselect-${label}`}
|
||||
onChange={(e) => onChange?.(e.target.value ? e.target.value.split(',') : [])}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
NumberInput: ({ label }) => <div><label>{label}</label><input /></div>,
|
||||
PasswordInput: ({ label, disabled }) => (
|
||||
<div><label>{label}</label><input type="password" disabled={disabled} /></div>
|
||||
),
|
||||
Select: ({ label, disabled }) => (
|
||||
<div><label>{label}</label><select disabled={disabled} /></div>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</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>,
|
||||
Text: ({ children }) => <span>{children}</span>,
|
||||
TextInput: ({ label, value, disabled, rightSection }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input data-testid={`textinput-${label}`} defaultValue={value} disabled={disabled} />
|
||||
{rightSection}
|
||||
</div>
|
||||
),
|
||||
useMantineTheme: () => ({
|
||||
colors: { red: Array(10).fill('#ff0000') },
|
||||
}),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import useChannelsStore from '../../../store/channels';
|
||||
import useOutputProfilesStore from '../../../store/outputProfiles';
|
||||
import useAuthStore from '../../../store/auth';
|
||||
import * as UserUtils from '../../../utils/forms/UserUtils.js';
|
||||
import { copyToClipboard } from '../../../utils';
|
||||
import User from '../User';
|
||||
|
||||
// ── Factories ──────────────────────────────────────────────────────────────────
|
||||
const makeAdminUser = (overrides = {}) => ({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
user_level: 1,
|
||||
api_key: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeRegularUser = (overrides = {}) => ({
|
||||
id: 2,
|
||||
username: 'user1',
|
||||
user_level: 3,
|
||||
api_key: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupMocks = ({
|
||||
authUser = makeAdminUser(),
|
||||
profiles = {},
|
||||
outputProfiles = [],
|
||||
} = {}) => {
|
||||
const mockSetUser = vi.fn();
|
||||
|
||||
vi.mocked(useChannelsStore).mockImplementation((sel) => sel({ profiles }));
|
||||
vi.mocked(useOutputProfilesStore).mockImplementation((sel) =>
|
||||
sel({ profiles: outputProfiles })
|
||||
);
|
||||
vi.mocked(useAuthStore).mockImplementation((sel) =>
|
||||
sel({ user: authUser, setUser: mockSetUser })
|
||||
);
|
||||
|
||||
// Reset form state
|
||||
mockForm.getValues.mockReturnValue({ user_level: '3' });
|
||||
mockForm.onSubmit.mockImplementation(
|
||||
(fn) => (e) => { e?.preventDefault?.(); return fn(); }
|
||||
);
|
||||
|
||||
return { mockSetUser };
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('User', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(UserUtils.createUser).mockResolvedValue({});
|
||||
vi.mocked(UserUtils.updateUser).mockResolvedValue({});
|
||||
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'new-api-key' });
|
||||
vi.mocked(UserUtils.revokeApiKey).mockResolvedValue({ success: true });
|
||||
vi.mocked(UserUtils.formValuesToPayload).mockReturnValue({ user_level: 3 });
|
||||
vi.mocked(UserUtils.getFormInitialValues).mockReturnValue({});
|
||||
vi.mocked(UserUtils.getFormValidators).mockReturnValue({});
|
||||
vi.mocked(UserUtils.userToFormValues).mockReturnValue({});
|
||||
});
|
||||
|
||||
// ── Visibility ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders nothing when isOpen is false', () => {
|
||||
setupMocks();
|
||||
render(<User isOpen={false} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the modal when isOpen is true', () => {
|
||||
setupMocks();
|
||||
render(<User isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when the modal close button is clicked', () => {
|
||||
setupMocks();
|
||||
const onClose = vi.fn();
|
||||
render(<User isOpen={true} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('tabs', () => {
|
||||
it('always renders Account, EPG, and API tabs', () => {
|
||||
setupMocks();
|
||||
render(<User isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('tab-account')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tab-epg')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tab-api')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Permissions tab when admin edits another user', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser()} />
|
||||
);
|
||||
expect(screen.getByTestId('tab-permissions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Permissions tab when admin edits themselves', () => {
|
||||
const admin = makeAdminUser();
|
||||
setupMocks({ authUser: admin });
|
||||
render(<User isOpen={true} onClose={vi.fn()} user={admin} />);
|
||||
expect(screen.queryByTestId('tab-permissions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Permissions tab for non-admin user', () => {
|
||||
setupMocks({ authUser: makeRegularUser() });
|
||||
render(
|
||||
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 99 })} />
|
||||
);
|
||||
expect(screen.queryByTestId('tab-permissions')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Admin-only fields ────────────────────────────────────────────────────────
|
||||
|
||||
describe('admin-only fields', () => {
|
||||
it('shows Output Format Override for admin', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
|
||||
);
|
||||
expect(screen.getByText('Output Format Override')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Output Format Override for non-admin', () => {
|
||||
setupMocks({ authUser: makeRegularUser() });
|
||||
render(
|
||||
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 5 })} />
|
||||
);
|
||||
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 })} />
|
||||
);
|
||||
expect(screen.getByText('Output Profile Override')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Allowed IPs for admin', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
|
||||
);
|
||||
expect(screen.getByText('Allowed IPs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Allowed IPs for non-admin', () => {
|
||||
setupMocks({ authUser: makeRegularUser() });
|
||||
render(
|
||||
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 5 })} />
|
||||
);
|
||||
expect(screen.queryByText('Allowed IPs')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── API key generation ────────────────────────────────────────────────────────
|
||||
|
||||
describe('API key generation', () => {
|
||||
it('shows "Generate API Key" when no key exists', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
|
||||
);
|
||||
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Regenerate API Key" when user already has an api_key', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2, api_key: 'existing-key' })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls generateApiKey and switches button to "Regenerate API Key"', async () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'brand-new-key' });
|
||||
|
||||
render(
|
||||
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Generate API Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(UserUtils.generateApiKey).toHaveBeenCalledWith({ user_id: 2 });
|
||||
expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('also sets the key when response contains raw_key instead of key', async () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ raw_key: 'raw-value' });
|
||||
|
||||
render(
|
||||
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Generate API Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call generateApiKey when canGenerateKey is false', async () => {
|
||||
// non-admin editing someone else
|
||||
setupMocks({ authUser: makeRegularUser({ id: 5 }) });
|
||||
render(
|
||||
<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();
|
||||
expect(UserUtils.generateApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not update key when generateApiKey returns a response without key/raw_key', async () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({});
|
||||
|
||||
render(
|
||||
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Generate API Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
// button text should NOT change since no key was received
|
||||
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the generated API key in a text input', async () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'show-me' });
|
||||
|
||||
render(
|
||||
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Generate API Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
const keyInput = screen.getByTestId('textinput-API Key');
|
||||
expect(keyInput).toHaveValue('show-me');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── API key revocation ───────────────────────────────────────────────────────
|
||||
|
||||
describe('API key revocation', () => {
|
||||
it('shows "Revoke API Key" when a key exists', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2, api_key: 'some-key' })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Revoke API Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides "Revoke API Key" when no key exists', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
|
||||
);
|
||||
expect(screen.queryByText('Revoke API Key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls revokeApiKey with user_id and clears the key on success', async () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2, api_key: 'some-key' })}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Revoke API Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(UserUtils.revokeApiKey).toHaveBeenCalledWith({ user_id: 2 });
|
||||
expect(screen.queryByText('Revoke API Key')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates auth store when admin revokes their own key', async () => {
|
||||
const admin = makeAdminUser({ id: 1, api_key: 'my-key' });
|
||||
const { mockSetUser } = setupMocks({ authUser: admin });
|
||||
|
||||
render(<User isOpen={true} onClose={vi.fn()} user={admin} />);
|
||||
fireEvent.click(screen.getByText('Revoke API Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ api_key: null })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not clear key when revokeApiKey returns success: false', async () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
vi.mocked(UserUtils.revokeApiKey).mockResolvedValue({ success: false });
|
||||
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2, api_key: 'still-here' })}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Revoke API Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Revoke API Key')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not update auth store when revoking another user\'s key', async () => {
|
||||
const admin = makeAdminUser({ id: 1 });
|
||||
const { mockSetUser } = setupMocks({ authUser: admin });
|
||||
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2, api_key: 'other-key' })}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Revoke API Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Copy to clipboard ────────────────────────────────────────────────────────
|
||||
|
||||
describe('copy API key to clipboard', () => {
|
||||
it('calls copyToClipboard with the current key', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
user={makeRegularUser({ id: 2, api_key: 'copy-me' })}
|
||||
/>
|
||||
);
|
||||
|
||||
const copyButton = screen.getByTestId('icon-copy').closest('button');
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(copyToClipboard).toHaveBeenCalledWith(
|
||||
'copy-me',
|
||||
expect.objectContaining({ successTitle: 'API Key Copied!' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form submission ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('form submission', () => {
|
||||
it('calls createUser when no user prop is given', async () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
|
||||
render(<User isOpen={true} onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(UserUtils.createUser).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls updateUser when editing an existing user', async () => {
|
||||
const admin = makeAdminUser();
|
||||
setupMocks({ authUser: admin });
|
||||
vi.mocked(UserUtils.updateUser).mockResolvedValue({ id: 2 });
|
||||
|
||||
render(
|
||||
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
|
||||
);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(UserUtils.updateUser).toHaveBeenCalledWith(
|
||||
2,
|
||||
expect.any(Object),
|
||||
true, // isAdmin
|
||||
admin
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates auth store when admin saves their own profile', async () => {
|
||||
const admin = makeAdminUser({ id: 1 });
|
||||
const updatedAdmin = { ...admin, email: 'new@example.com' };
|
||||
const { mockSetUser } = setupMocks({ authUser: admin });
|
||||
vi.mocked(UserUtils.updateUser).mockResolvedValue(updatedAdmin);
|
||||
|
||||
render(<User isOpen={true} onClose={vi.fn()} user={admin} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetUser).toHaveBeenCalledWith(updatedAdmin);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets form and calls onClose after successful submission', async () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(<User isOpen={true} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForm.reset).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('removes password from payload when updating and payload.password is falsy', async () => {
|
||||
const admin = makeAdminUser();
|
||||
setupMocks({ authUser: admin });
|
||||
vi.mocked(UserUtils.formValuesToPayload).mockReturnValue({
|
||||
user_level: 3,
|
||||
password: '',
|
||||
});
|
||||
vi.mocked(UserUtils.updateUser).mockResolvedValue({ id: 2 });
|
||||
|
||||
render(
|
||||
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
|
||||
);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
const payload = vi.mocked(UserUtils.updateUser).mock.calls[0][1];
|
||||
expect(payload).not.toHaveProperty('password');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── useEffect ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useEffect on user prop', () => {
|
||||
it('calls userToFormValues and sets form values when user has an id', () => {
|
||||
const user = makeRegularUser({ id: 2 });
|
||||
vi.mocked(UserUtils.userToFormValues).mockReturnValue({ username: 'user1' });
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
|
||||
render(<User isOpen={true} onClose={vi.fn()} user={user} />);
|
||||
|
||||
expect(UserUtils.userToFormValues).toHaveBeenCalledWith(user);
|
||||
expect(mockForm.setValues).toHaveBeenCalledWith({ username: 'user1' });
|
||||
});
|
||||
|
||||
it('resets form when no user is provided', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(<User isOpen={true} onClose={vi.fn()} />);
|
||||
expect(mockForm.reset).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── XC password generation ───────────────────────────────────────────────────
|
||||
|
||||
describe('XC password generation', () => {
|
||||
it('calls setValues with a generated xc_password when rotate icon is clicked', () => {
|
||||
setupMocks({ authUser: makeAdminUser() });
|
||||
render(
|
||||
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 2 })} />
|
||||
);
|
||||
|
||||
const rotateButton = screen
|
||||
.getByTestId('icon-rotate-ccw-key')
|
||||
.closest('button');
|
||||
fireEvent.click(rotateButton);
|
||||
|
||||
expect(mockForm.setValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ xc_password: expect.any(String) })
|
||||
);
|
||||
});
|
||||
|
||||
it('disables the XC password rotate button for non-admin', () => {
|
||||
setupMocks({ authUser: makeRegularUser() });
|
||||
render(
|
||||
<User isOpen={true} onClose={vi.fn()} user={makeRegularUser({ id: 5 })} />
|
||||
);
|
||||
|
||||
const rotateButton = screen
|
||||
.getByTestId('icon-rotate-ccw-key')
|
||||
.closest('button');
|
||||
expect(rotateButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Channel profiles logic ───────────────────────────────────────────────────
|
||||
|
||||
describe('channel profiles logic', () => {
|
||||
const profiles = { 1: { id: 1, name: 'HD' }, 2: { id: 2, name: 'SD' } };
|
||||
|
||||
it('excludes "All" (0) when other profiles are selected alongside it', () => {
|
||||
setupMocks({ authUser: makeAdminUser(), profiles });
|
||||
render(
|
||||
<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',
|
||||
expect.not.arrayContaining(['0'])
|
||||
);
|
||||
});
|
||||
|
||||
it('sets only ["0"] when "All" is newly selected', () => {
|
||||
setupMocks({ authUser: makeAdminUser(), profiles });
|
||||
render(
|
||||
<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']
|
||||
);
|
||||
});
|
||||
|
||||
it('allows multiple non-all profiles together', () => {
|
||||
setupMocks({ authUser: makeAdminUser(), profiles });
|
||||
render(
|
||||
<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']
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
239
frontend/src/components/forms/__tests__/UserAgent.test.jsx
Normal file
239
frontend/src/components/forms/__tests__/UserAgent.test.jsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../utils/forms/UserAgentUtils.js', () => ({
|
||||
addUserAgent: vi.fn(),
|
||||
updateUserAgent: vi.fn(),
|
||||
getResolver: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Button: ({ children, disabled, type }) => (
|
||||
<button type={type} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Checkbox: ({ label, checked, onChange }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="checkbox-is-active"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Modal: ({ children, opened, onClose, title }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<button data-testid="modal-close" onClick={onClose}>×</button>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
Space: () => <div />,
|
||||
TextInput: ({ label, error, ...rest }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<input data-testid={`input-${label}`} aria-label={label} {...rest} />
|
||||
{error && <span data-testid={`error-${label}`}>{error}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import * as UserAgentUtils from '../../../utils/forms/UserAgentUtils.js';
|
||||
import UserAgent from '../UserAgent';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const makeUserAgent = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: 'Chrome',
|
||||
user_agent: 'Mozilla/5.0',
|
||||
description: 'Chrome browser',
|
||||
is_active: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('UserAgent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(UserAgentUtils.addUserAgent).mockResolvedValue({});
|
||||
vi.mocked(UserAgentUtils.updateUserAgent).mockResolvedValue({});
|
||||
vi.mocked(UserAgentUtils.getResolver).mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
// ── Visibility ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders nothing when isOpen is false', () => {
|
||||
render(<UserAgent isOpen={false} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the modal when isOpen is true', () => {
|
||||
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders modal title "User-Agent"', () => {
|
||||
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('modal-title')).toHaveTextContent('User-Agent');
|
||||
});
|
||||
|
||||
it('calls onClose when modal close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<UserAgent isOpen={true} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByTestId('modal-close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Default values ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('default values', () => {
|
||||
it('renders empty fields when no userAgent prop is given', () => {
|
||||
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('input-Name')).toHaveValue('');
|
||||
expect(screen.getByTestId('input-User-Agent')).toHaveValue('');
|
||||
expect(screen.getByTestId('input-Description')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('pre-fills fields from userAgent prop', () => {
|
||||
render(
|
||||
<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');
|
||||
});
|
||||
|
||||
it('checks Is Active checkbox when userAgent.is_active is true', () => {
|
||||
render(
|
||||
<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 })} />
|
||||
);
|
||||
expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('defaults Is Active to checked when no userAgent prop given', () => {
|
||||
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Field interactions ───────────────────────────────────────────────────────
|
||||
|
||||
describe('field interactions', () => {
|
||||
it('toggles Is Active checkbox', () => {
|
||||
render(
|
||||
<UserAgent isOpen={true} onClose={vi.fn()} userAgent={makeUserAgent({ is_active: true })} />
|
||||
);
|
||||
const checkbox = screen.getByTestId('checkbox-is-active');
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form submission ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('form submission', () => {
|
||||
it('calls addUserAgent when no userAgent id is given', async () => {
|
||||
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(UserAgentUtils.addUserAgent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls updateUserAgent with the id when editing an existing userAgent', async () => {
|
||||
render(
|
||||
<UserAgent isOpen={true} onClose={vi.fn()} userAgent={makeUserAgent()} />
|
||||
);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(UserAgentUtils.updateUserAgent).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ name: 'Chrome' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call addUserAgent when updating', async () => {
|
||||
render(
|
||||
<UserAgent isOpen={true} onClose={vi.fn()} userAgent={makeUserAgent()} />
|
||||
);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(UserAgentUtils.addUserAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call updateUserAgent when creating', async () => {
|
||||
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(UserAgentUtils.updateUserAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onClose after successful submission', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<UserAgent isOpen={true} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes is_active value in the submitted payload', async () => {
|
||||
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
|
||||
const checkbox = screen.getByTestId('checkbox-is-active');
|
||||
fireEvent.click(checkbox); // uncheck it
|
||||
fireEvent.click(screen.getByText('Submit'));
|
||||
await waitFor(() => {
|
||||
expect(UserAgentUtils.addUserAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ is_active: false })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── useEffect reset ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('form reset on userAgent change', () => {
|
||||
it('resets form values when userAgent prop changes', () => {
|
||||
const { rerender } = render(
|
||||
<UserAgent isOpen={true} onClose={vi.fn()} userAgent={makeUserAgent()} />
|
||||
);
|
||||
expect(screen.getByTestId('input-Name')).toHaveValue('Chrome');
|
||||
|
||||
rerender(
|
||||
<UserAgent
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
userAgent={makeUserAgent({ id: 2, name: 'Firefox', user_agent: 'Gecko/20100101' })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('input-Name')).toHaveValue('Firefox');
|
||||
});
|
||||
|
||||
it('resets to empty when userAgent prop is removed', () => {
|
||||
const { rerender } = render(
|
||||
<UserAgent isOpen={true} onClose={vi.fn()} userAgent={makeUserAgent()} />
|
||||
);
|
||||
rerender(<UserAgent isOpen={true} onClose={vi.fn()} userAgent={null} />);
|
||||
expect(screen.getByTestId('input-Name')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,362 @@
|
|||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── lucide-react ───────────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
CircleCheck: () => <svg data-testid="icon-circle-check" />,
|
||||
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}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Checkbox: ({ label, checked, onChange, disabled }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={label}
|
||||
checked={checked}
|
||||
onChange={(e) => onChange?.({ currentTarget: { checked: e.target.checked } })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
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>,
|
||||
TextInput: ({ label, value, onChange, placeholder }) => (
|
||||
<input
|
||||
aria-label={label ?? placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
),
|
||||
SegmentedControl: ({ value, onChange, data }) => (
|
||||
<div data-testid="segmented-control">
|
||||
{data.map((item) => (
|
||||
<button
|
||||
key={item.value ?? item}
|
||||
data-testid={`segment-${item.value ?? item}`}
|
||||
onClick={() => onChange(item.value ?? item)}
|
||||
data-active={value === (item.value ?? item) ? 'true' : 'false'}
|
||||
>
|
||||
{item.label ?? item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
import useVODStore from '../../../store/useVODStore';
|
||||
import VODCategoryFilter from '../VODCategoryFilter';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const makeCategories = () => [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Action',
|
||||
m3u_accounts: [{ m3u_account: 10, enabled: true }],
|
||||
category_type: 'movies',
|
||||
},
|
||||
{ id: 2, name: 'Comedy', m3u_accounts: [{ m3u_account: 10, enabled: false }], category_type: 'movies' },
|
||||
{ id: 3, name: 'Drama', m3u_accounts: [{ m3u_account: 10, enabled: true }], category_type: 'movies' },
|
||||
{ id: 4, name: 'News', m3u_accounts: [{ m3u_account: 10, enabled: true }], category_type: 'series' },
|
||||
];
|
||||
|
||||
const makePlaylist = (overrides = {}) => ({ id: 10, name: 'My Playlist', ...overrides });
|
||||
|
||||
const categoriesToDict = (arr) =>
|
||||
arr.reduce((acc, cat) => ({ ...acc, [cat.id]: cat }), {});
|
||||
|
||||
const setupMocks = ({ categories = makeCategories() } = {}) => {
|
||||
const dict = Array.isArray(categories) ? categoriesToDict(categories) : categories;
|
||||
vi.mocked(useVODStore).mockImplementation((sel) => sel({ categories: dict }));
|
||||
};
|
||||
|
||||
const defaultProps = (overrides = {}) => {
|
||||
return {
|
||||
playlist: makePlaylist(),
|
||||
categoryStates: [
|
||||
{ id: 1, name: 'Action', enabled: true },
|
||||
{ id: 2, name: 'Comedy', enabled: false },
|
||||
{ id: 3, name: 'Drama', enabled: true },
|
||||
],
|
||||
setCategoryStates: vi.fn(),
|
||||
type: 'movies',
|
||||
autoEnableNewGroups: true,
|
||||
setAutoEnableNewGroups: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('VODCategoryFilter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
expect(screen.getByTestId('simple-grid')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('does not render categories of a different type', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
expect(screen.queryByRole('checkbox', { name: 'News' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the text filter input', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the segmented status control', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
expect(screen.getByTestId('segmented-control')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Enable All and Disable All buttons', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
expect(screen.getByText('Select Visible')).toBeInTheDocument();
|
||||
expect(screen.getByText('Deselect Visible')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Auto-enable new groups checkbox', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
expect(screen.getByLabelText(/automatically enable new/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Text filter ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('text filter', () => {
|
||||
it('hides categories that do not match the filter', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('shows all categories when filter is cleared', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
const input = screen.getByPlaceholderText(/filter/i);
|
||||
fireEvent.change(input, { target: { value: 'act' } });
|
||||
fireEvent.change(input, { target: { value: '' } });
|
||||
expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Drama' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no categories when filter matches nothing', () => {
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
|
||||
target: { value: 'zzznomatch' },
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Status filter ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('status filter', () => {
|
||||
it('shows only enabled categories when "Enabled" segment is selected', () => {
|
||||
const props = defaultProps({
|
||||
categoryStates: [ { id: 1, name: 'Action', enabled: true }, { id: 2, name: 'Comedy', enabled: false }, { id: 3, name: 'Drama', enabled: true } ],
|
||||
});
|
||||
render(<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: 'Drama' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows only disabled categories when "Disabled" segment is selected', () => {
|
||||
const props = defaultProps({
|
||||
categoryStates: [ { id: 1, name: 'Action', enabled: true }, { id: 2, name: 'Comedy', enabled: false }, { id: 3, name: 'Drama', enabled: true } ],
|
||||
});
|
||||
render(<VODCategoryFilter {...props} />);
|
||||
fireEvent.click(screen.getByTestId('segment-disabled'));
|
||||
expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all categories when "All" segment is active', () => {
|
||||
const props = defaultProps({
|
||||
categoryStates: [ { id: 1, name: 'Action', enabled: true }, { id: 2, name: 'Comedy', enabled: false }, { id: 3, name: 'Drama', enabled: true } ],
|
||||
});
|
||||
render(<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();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Combined filters ──────────────────────────────────────────────────────
|
||||
|
||||
describe('combined text + status filters', () => {
|
||||
it('applies both text and status filters simultaneously', () => {
|
||||
const props = defaultProps();
|
||||
render(<VODCategoryFilter {...props} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/filter/i), { target: { value: 'o' } });
|
||||
fireEvent.click(screen.getByTestId('segment-disabled'));
|
||||
// "Comedy" matches "o" AND is disabled; "Action" matches "o" but is enabled
|
||||
expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Enable All / Disable All ──────────────────────────────────────────────
|
||||
|
||||
describe('Enable All button', () => {
|
||||
it('calls setCategoryStates with all visible categories set to true', () => {
|
||||
const setCategoryStates = vi.fn();
|
||||
render(
|
||||
<VODCategoryFilter
|
||||
{...defaultProps({
|
||||
setCategoryStates,
|
||||
categoryStates: [ { id: 1, name: 'Action', enabled: false }, { id: 2, name: 'Comedy', enabled: false }, { id: 3, name: 'Drama', enabled: false } ],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText('Select Visible'));
|
||||
const called = setCategoryStates.mock.calls.at(-1)[0];
|
||||
expect(called.find((s) => s.id === 1).enabled).toBe(true);
|
||||
expect(called.find((s) => s.id === 2).enabled).toBe(true);
|
||||
expect(called.find((s) => s.id === 3).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('only enables filtered categories when a text filter is active', () => {
|
||||
const setCategoryStates = vi.fn();
|
||||
render(
|
||||
<VODCategoryFilter
|
||||
{...defaultProps({
|
||||
setCategoryStates,
|
||||
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.click(screen.getByText('Select Visible'));
|
||||
const called = setCategoryStates.mock.calls.at(-1)[0];
|
||||
expect(called.find((s) => s.id === 1).enabled).toBe(true);
|
||||
// Comedy and Drama were filtered out — their state should be unchanged
|
||||
expect(called.find((s) => s.id === 2).enabled).toBe(false);
|
||||
expect(called.find((s) => s.id === 3).enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Disable All button', () => {
|
||||
it('calls setCategoryStates with all visible categories set to false', () => {
|
||||
const setCategoryStates = vi.fn();
|
||||
render(<VODCategoryFilter {...defaultProps({ setCategoryStates })} />);
|
||||
fireEvent.click(screen.getByText('Deselect Visible'));
|
||||
const called = setCategoryStates.mock.calls.at(-1)[0];
|
||||
expect(called.find((s) => s.id === 1).enabled).toBe(false);
|
||||
expect(called.find((s) => s.id === 2).enabled).toBe(false);
|
||||
expect(called.find((s) => s.id === 3).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('only disables filtered categories when a text filter is active', () => {
|
||||
const setCategoryStates = vi.fn();
|
||||
render(
|
||||
<VODCategoryFilter
|
||||
{...defaultProps({
|
||||
setCategoryStates,
|
||||
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.click(screen.getByText('Deselect Visible'));
|
||||
const called = setCategoryStates.mock.calls.at(-1)[0];
|
||||
expect(called.find((s) => s.id === 2).enabled).toBe(false);
|
||||
expect(called.find((s) => s.id === 1).enabled).toBe(true);
|
||||
expect(called.find((s) => s.id === 3).enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Auto-enable new groups ────────────────────────────────────────────────
|
||||
|
||||
describe('autoEnableNewGroups checkbox', () => {
|
||||
it('calls setAutoEnableNewGroups(true) when checked', () => {
|
||||
const setAutoEnableNewGroups = vi.fn();
|
||||
render(
|
||||
<VODCategoryFilter
|
||||
{...defaultProps({ autoEnableNewGroups: false, setAutoEnableNewGroups })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(/automatically enable new/i));
|
||||
expect(setAutoEnableNewGroups).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('calls setAutoEnableNewGroups(false) when unchecked', () => {
|
||||
const setAutoEnableNewGroups = vi.fn();
|
||||
render(
|
||||
<VODCategoryFilter
|
||||
{...defaultProps({ autoEnableNewGroups: true, setAutoEnableNewGroups })}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(/automatically enable new/i));
|
||||
expect(setAutoEnableNewGroups).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── No playlist / empty categories ───────────────────────────────────────
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('renders no category buttons when categories list is empty', () => {
|
||||
setupMocks({ categories: [] });
|
||||
render(<VODCategoryFilter {...defaultProps({ categoryStates: [] })} />);
|
||||
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();
|
||||
});
|
||||
|
||||
it('renders categories only for the matching playlist id', () => {
|
||||
setupMocks({
|
||||
categories: [
|
||||
...makeCategories(),
|
||||
{ id: 99, name: 'Foreign', m3u_accounts: [{ m3u_account: 99, enabled: true }], category_type: 'movies' },
|
||||
],
|
||||
});
|
||||
render(<VODCategoryFilter {...defaultProps()} />);
|
||||
expect(screen.queryByRole('button', { name: 'Foreign' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue