mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-21 01:05:30 +00:00
Added forms/settings tests
This commit is contained in:
parent
5b9b0162da
commit
e95285ec8c
6 changed files with 2703 additions and 0 deletions
|
|
@ -0,0 +1,483 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import DvrSettingsForm from '../DvrSettingsForm';
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../store/settings.jsx', () => {
|
||||
const mock = vi.fn();
|
||||
mock.getState = vi.fn();
|
||||
return { default: mock };
|
||||
});
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({
|
||||
getChangedSettings: vi.fn(),
|
||||
parseSettings: vi.fn(),
|
||||
saveChangedSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../utils/forms/settings/DvrSettingsFormUtils.js', () => ({
|
||||
getComskipConfig: vi.fn(),
|
||||
getDvrSettingsFormInitialValues: vi.fn(),
|
||||
uploadComskipIni: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Mantine form ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/form', () => ({
|
||||
useForm: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Alert: ({ title }) => <div data-testid="alert">{title}</div>,
|
||||
Button: ({ children, onClick, disabled, type, variant }) => (
|
||||
<button
|
||||
type={type || 'button'}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
data-variant={variant}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
FileInput: ({ placeholder, onChange, disabled }) => (
|
||||
<input
|
||||
data-testid="file-input"
|
||||
type="file"
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
onChange?.(file);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
NumberInput: ({ label, id, name, ...rest }) => (
|
||||
<input
|
||||
data-testid={id}
|
||||
id={id}
|
||||
name={name}
|
||||
aria-label={label}
|
||||
type="number"
|
||||
onChange={(e) => rest.onChange?.(Number(e.target.value))}
|
||||
/>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Switch: ({ label, id, checked, onChange }) => (
|
||||
<input
|
||||
data-testid={id}
|
||||
id={id}
|
||||
type="checkbox"
|
||||
aria-label={label}
|
||||
checked={checked ?? false}
|
||||
onChange={onChange}
|
||||
/>
|
||||
),
|
||||
Text: ({ children }) => <span>{children}</span>,
|
||||
TextInput: ({ label, id, name, placeholder, ...rest }) => (
|
||||
<input
|
||||
data-testid={id}
|
||||
id={id}
|
||||
name={name}
|
||||
aria-label={label}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => rest.onChange?.(e)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Imports after mocks
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
import useSettingsStore from '../../../../store/settings.jsx';
|
||||
import { useForm } from '@mantine/form';
|
||||
import {
|
||||
getChangedSettings,
|
||||
parseSettings,
|
||||
saveChangedSettings,
|
||||
} from '../../../../utils/pages/SettingsUtils.js';
|
||||
import { showNotification } from '../../../../utils/notificationUtils.js';
|
||||
import {
|
||||
getComskipConfig,
|
||||
getDvrSettingsFormInitialValues,
|
||||
uploadComskipIni,
|
||||
} from '../../../../utils/forms/settings/DvrSettingsFormUtils.js';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
const mockFormValues = {
|
||||
comskip_enabled: false,
|
||||
comskip_custom_path: '',
|
||||
pre_offset_minutes: 0,
|
||||
post_offset_minutes: 0,
|
||||
tv_template: '',
|
||||
tv_fallback_template: '',
|
||||
movie_template: '',
|
||||
movie_fallback_template: '',
|
||||
};
|
||||
|
||||
const makeFormMock = (overrides = {}) => ({
|
||||
getInputProps: vi.fn((field, opts) => {
|
||||
if (opts?.type === 'checkbox') return { checked: mockFormValues[field] ?? false, onChange: vi.fn() };
|
||||
return { value: mockFormValues[field] ?? '', onChange: vi.fn() };
|
||||
}),
|
||||
setValues: vi.fn(),
|
||||
setFieldValue: vi.fn(),
|
||||
getValues: vi.fn(() => mockFormValues),
|
||||
onSubmit: vi.fn((handler) => (e) => { e?.preventDefault?.(); return handler(); }),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeSettings = (overrides = {}) => ({
|
||||
comskip_enabled: { key: 'comskip_enabled', value: 'false' },
|
||||
comskip_custom_path: { key: 'comskip_custom_path', value: '' },
|
||||
pre_offset_minutes: { key: 'pre_offset_minutes', value: '0' },
|
||||
post_offset_minutes: { key: 'post_offset_minutes', value: '0' },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupMocks = ({ settings = makeSettings(), formOverrides = {} } = {}) => {
|
||||
const formMock = makeFormMock(formOverrides);
|
||||
|
||||
vi.mocked(useForm).mockReturnValue(formMock);
|
||||
vi.mocked(getDvrSettingsFormInitialValues).mockReturnValue(mockFormValues);
|
||||
vi.mocked(parseSettings).mockReturnValue(mockFormValues);
|
||||
vi.mocked(getComskipConfig).mockResolvedValue({ path: '', exists: false });
|
||||
vi.mocked(getChangedSettings).mockReturnValue({});
|
||||
vi.mocked(saveChangedSettings).mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings }));
|
||||
vi.mocked(useSettingsStore).getState = vi.fn(() => ({
|
||||
updateSetting: vi.fn(),
|
||||
}));
|
||||
|
||||
return { formMock };
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('DvrSettingsForm', () => {
|
||||
let formMock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
({ formMock } = setupMocks());
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
describe('rendering', () => {
|
||||
it('renders the form without crashing', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('comskip_enabled')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the comskip enabled switch', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('comskip_enabled')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the comskip custom path text input', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('comskip_custom_path')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the file input for comskip.ini upload', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('file-input')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the Upload comskip.ini button', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Upload comskip.ini')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the pre_offset_minutes number input', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('pre_offset_minutes')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the post_offset_minutes number input', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('post_offset_minutes')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders all template text inputs', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('tv_template')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tv_fallback_template')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('movie_template')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('movie_fallback_template')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the Save button', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show success alert on initial render', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "No custom comskip.ini uploaded." when no config exists', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No custom comskip.ini uploaded.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows config path when comskipConfig has path and exists', async () => {
|
||||
vi.mocked(getComskipConfig).mockResolvedValue({
|
||||
path: '/app/docker/comskip.ini',
|
||||
exists: true,
|
||||
});
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Using /app/docker/comskip.ini')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Initialization ─────────────────────────────────────────────────────────
|
||||
describe('initialization', () => {
|
||||
it('calls getDvrSettingsFormInitialValues on mount', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(getDvrSettingsFormInitialValues).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls parseSettings with settings on mount', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(parseSettings).toHaveBeenCalledWith(makeSettings());
|
||||
expect(formMock.setValues).toHaveBeenCalledWith(mockFormValues);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls getComskipConfig on mount', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(getComskipConfig).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('sets comskip path from getComskipConfig response', async () => {
|
||||
vi.mocked(getComskipConfig).mockResolvedValue({
|
||||
path: '/custom/path/comskip.ini',
|
||||
exists: true,
|
||||
});
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(formMock.setFieldValue).toHaveBeenCalledWith(
|
||||
'comskip_custom_path',
|
||||
'/custom/path/comskip.ini'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles getComskipConfig error gracefully', async () => {
|
||||
vi.mocked(getComskipConfig).mockRejectedValue(new Error('network error'));
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to load comskip config',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not call setFieldValue when getComskipConfig returns no path', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(getComskipConfig).toHaveBeenCalled();
|
||||
});
|
||||
expect(formMock.setFieldValue).not.toHaveBeenCalledWith(
|
||||
'comskip_custom_path',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── active prop ────────────────────────────────────────────────────────────
|
||||
describe('active prop', () => {
|
||||
it('resets saved state when active becomes false', async () => {
|
||||
vi.mocked(getChangedSettings).mockReturnValue({ comskip_enabled: true });
|
||||
const { rerender } = render(<DvrSettingsForm active={true} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(<DvrSettingsForm active={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form submission ────────────────────────────────────────────────────────
|
||||
describe('form submission', () => {
|
||||
it('calls getChangedSettings and saveChangedSettings on submit', async () => {
|
||||
vi.mocked(getChangedSettings).mockReturnValue({ pre_offset_minutes: 5 });
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getChangedSettings).toHaveBeenCalledWith(mockFormValues, makeSettings());
|
||||
expect(saveChangedSettings).toHaveBeenCalledWith(makeSettings(), { pre_offset_minutes: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success alert after successful save', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Saved Successfully')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show success alert when saveChangedSettings throws', async () => {
|
||||
vi.mocked(saveChangedSettings).mockRejectedValue(new Error('save failed'));
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Error saving settings:',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── comskip upload ─────────────────────────────────────────────────────────
|
||||
describe('comskip upload', () => {
|
||||
it('Upload button is disabled when no file is selected', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Upload comskip.ini')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls uploadComskipIni with the selected file', async () => {
|
||||
const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' });
|
||||
vi.mocked(uploadComskipIni).mockResolvedValue({ path: '/uploaded/comskip.ini' });
|
||||
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
fireEvent.change(screen.getByTestId('file-input'), { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByText('Upload comskip.ini'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadComskipIni).toHaveBeenCalledWith(mockFile);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success notification after successful upload', async () => {
|
||||
const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' });
|
||||
vi.mocked(uploadComskipIni).mockResolvedValue({ path: '/uploaded/comskip.ini' });
|
||||
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
fireEvent.change(screen.getByTestId('file-input'), { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByText('Upload comskip.ini'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'comskip.ini uploaded', color: 'green' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets comskip_custom_path form field after successful upload', async () => {
|
||||
const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' });
|
||||
vi.mocked(uploadComskipIni).mockResolvedValue({ path: '/uploaded/comskip.ini' });
|
||||
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
fireEvent.change(screen.getByTestId('file-input'), { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByText('Upload comskip.ini'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(formMock.setFieldValue).toHaveBeenCalledWith(
|
||||
'comskip_custom_path',
|
||||
'/uploaded/comskip.ini'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles upload error gracefully', async () => {
|
||||
const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' });
|
||||
vi.mocked(uploadComskipIni).mockRejectedValue(new Error('upload failed'));
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
fireEvent.change(screen.getByTestId('file-input'), { target: { files: [mockFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByText('Upload comskip.ini'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to upload comskip.ini',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not call uploadComskipIni when no file is selected', async () => {
|
||||
render(<DvrSettingsForm active={true} />);
|
||||
await waitFor(() => screen.getByTestId('file-input'));
|
||||
expect(uploadComskipIni).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,378 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import NetworkAccessForm from '../NetworkAccessForm';
|
||||
|
||||
// ── Constants mock ─────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../constants.js', () => ({
|
||||
NETWORK_ACCESS_OPTIONS: [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'local', label: 'Local Only' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
],
|
||||
}));
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({
|
||||
checkSetting: vi.fn(),
|
||||
updateSetting: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../utils/forms/settings/NetworkAccessFormUtils.js', () => ({
|
||||
getNetworkAccessFormInitialValues: vi.fn(),
|
||||
getNetworkAccessFormValidation: vi.fn(),
|
||||
getNetworkAccessDefaults: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Mantine form ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/form', () => ({ useForm: vi.fn() }));
|
||||
|
||||
// ── ConfirmationDialog mock ────────────────────────────────────────────────────
|
||||
vi.mock('../../../ConfirmationDialog.jsx', () => ({
|
||||
default: ({ opened, onConfirm, onClose, title, message }) =>
|
||||
opened ? (
|
||||
<div data-testid="confirmation-dialog">
|
||||
<div data-testid="confirm-title">{title}</div>
|
||||
<div data-testid="confirm-message">{message}</div>
|
||||
<button data-testid="confirm-ok" onClick={onConfirm}>Confirm</button>
|
||||
<button data-testid="confirm-cancel" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Alert: ({ title, children }) => (
|
||||
<div data-testid="alert">
|
||||
<span data-testid="alert-title">{title}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, onClick, disabled, loading, type, variant }) => (
|
||||
<button
|
||||
type={type || 'button'}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
data-variant={variant}
|
||||
data-loading={String(loading)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children }) => <span>{children}</span>,
|
||||
TextInput: ({ label, id, placeholder, error, ...rest }) => (
|
||||
<div>
|
||||
<input
|
||||
data-testid={id}
|
||||
id={id}
|
||||
aria-label={label}
|
||||
placeholder={placeholder}
|
||||
data-error={error}
|
||||
onChange={(e) => rest.onChange?.(e)}
|
||||
value={rest.value ?? ''}
|
||||
/>
|
||||
{error && <span data-testid={`${id}-error`}>{error}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Imports after mocks
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
import useSettingsStore from '../../../../store/settings.jsx';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { checkSetting, updateSetting } from '../../../../utils/pages/SettingsUtils.js';
|
||||
import {
|
||||
getNetworkAccessFormInitialValues,
|
||||
getNetworkAccessFormValidation,
|
||||
getNetworkAccessDefaults,
|
||||
} from '../../../../utils/forms/settings/NetworkAccessFormUtils.js';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
const mockInitialValues = {
|
||||
m3u_access: 'local',
|
||||
epg_access: 'local',
|
||||
recordings_access: 'all',
|
||||
m3u_custom_cidrs: '',
|
||||
epg_custom_cidrs: '',
|
||||
recordings_custom_cidrs: '',
|
||||
};
|
||||
|
||||
const makeFormMock = (overrides = {}) => ({
|
||||
key: vi.fn(),
|
||||
getInputProps: vi.fn((field) => ({
|
||||
value: mockInitialValues[field] ?? '',
|
||||
onChange: vi.fn(),
|
||||
})),
|
||||
setValues: vi.fn(),
|
||||
setErrors: vi.fn(),
|
||||
setFieldValue: vi.fn(),
|
||||
getValues: vi.fn(() => mockInitialValues),
|
||||
validate: vi.fn(() => ({ hasErrors: false })),
|
||||
onSubmit: vi.fn((handler) => (e) => {
|
||||
e?.preventDefault?.();
|
||||
return handler(mockInitialValues);
|
||||
}),
|
||||
errors: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeSettings = (overrides = {}) => ({
|
||||
network_access: {
|
||||
key: 'network_access',
|
||||
value: {
|
||||
m3u_access: 'local',
|
||||
epg_access: 'local',
|
||||
recordings_access: 'all',
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupMocks = ({ settings = makeSettings(), formOverrides = {} } = {}) => {
|
||||
const formMock = makeFormMock(formOverrides);
|
||||
|
||||
vi.mocked(useForm).mockReturnValue(formMock);
|
||||
vi.mocked(getNetworkAccessFormInitialValues).mockReturnValue(mockInitialValues);
|
||||
vi.mocked(getNetworkAccessFormValidation).mockReturnValue({});
|
||||
vi.mocked(getNetworkAccessDefaults).mockReturnValue(mockInitialValues);
|
||||
vi.mocked(checkSetting).mockResolvedValue({ client_ip: '192.168.1.1', UI: ['192.168.0.0/16'] });
|
||||
vi.mocked(updateSetting).mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings }));
|
||||
|
||||
return { formMock };
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('NetworkAccessForm', () => {
|
||||
let formMock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
({ formMock } = setupMocks());
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
describe('rendering', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Save button', () => {
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show success alert on initial render', () => {
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show confirmation dialog on initial render', () => {
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show error alert on initial render', () => {
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Initialization ─────────────────────────────────────────────────────────
|
||||
describe('initialization', () => {
|
||||
it('calls getNetworkAccessFormInitialValues on mount', () => {
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
expect(getNetworkAccessFormInitialValues).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls getNetworkAccessFormValidation on mount', () => {
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
expect(getNetworkAccessFormValidation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call checkSetting on mount', () => {
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
expect(checkSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls checkSetting when form is submitted', async () => {
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(checkSetting).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('sets form values from network_access settings on mount', async () => {
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(formMock.setValues).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing network_access setting gracefully', async () => {
|
||||
({ formMock } = setupMocks({ settings: {} }));
|
||||
expect(() => render(<NetworkAccessForm active={true} />)).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles checkSetting error gracefully', async () => {
|
||||
vi.mocked(checkSetting).mockRejectedValue(new Error('network error'));
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
});
|
||||
});
|
||||
|
||||
// ── active prop ────────────────────────────────────────────────────────────
|
||||
describe('active prop', () => {
|
||||
it('resets saved state when active becomes false', async () => {
|
||||
vi.mocked(updateSetting).mockResolvedValue(undefined);
|
||||
const { rerender } = render(<NetworkAccessForm active={true} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form') ?? screen.getByText('Save').closest('div'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirmation-dialog')).not.toBeNull();
|
||||
}).catch(() => {});
|
||||
|
||||
rerender(<NetworkAccessForm active={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form submission ────────────────────────────────────────────────────────
|
||||
describe('form submission', () => {
|
||||
it('opens confirmation dialog on Save click', async () => {
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes confirmation dialog on Cancel click', async () => {
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => screen.getByTestId('confirmation-dialog'));
|
||||
fireEvent.click(screen.getByTestId('confirm-cancel'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls updateSetting on confirm', async () => {
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => screen.getByTestId('confirmation-dialog'));
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSetting).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success alert after confirmed save', async () => {
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => screen.getByTestId('confirmation-dialog'));
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('alert-title')).toHaveTextContent('Saved Successfully');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call updateSetting when confirmation is cancelled', async () => {
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => screen.getByTestId('confirmation-dialog'));
|
||||
fireEvent.click(screen.getByTestId('confirm-cancel'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show success alert when updateSetting throws', async () => {
|
||||
vi.mocked(updateSetting).mockRejectedValue(
|
||||
{ body: { value: { m3u_custom_cidrs: 'Invalid CIDR' } } });
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => screen.getByTestId('confirmation-dialog'));
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'));
|
||||
|
||||
await waitFor(() => {
|
||||
const alertTitle = screen.queryByTestId('alert-title');
|
||||
expect(alertTitle?.textContent).not.toBe('Saved Successfully');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Client IP display ──────────────────────────────────────────────────────
|
||||
describe('client IP display', () => {
|
||||
it('displays client IP address fetched on submit', async () => {
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => screen.getByTestId('confirmation-dialog'));
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/192\.168\.1\.1/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not display IP when checkSetting returns null', async () => {
|
||||
vi.mocked(checkSetting).mockResolvedValue(null);
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/\d+\.\d+\.\d+\.\d+/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Network access error ───────────────────────────────────────────────────
|
||||
describe('network access error state', () => {
|
||||
it('clears error state after successful save', async () => {
|
||||
vi.mocked(checkSetting).mockResolvedValue(
|
||||
{ error: true, message: 'Invalid CIDR', data: 'Error details' }
|
||||
);
|
||||
|
||||
render(<NetworkAccessForm active={true} />);
|
||||
|
||||
// First save — fails
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => screen.getByTestId('alert'));
|
||||
|
||||
vi.mocked(checkSetting).mockResolvedValue(
|
||||
{ client_ip: '192.168.1.1', UI: ['192.168.0.0/16'] });
|
||||
|
||||
// Second save — succeeds
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => screen.getByTestId('confirmation-dialog'));
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert-title')).toHaveTextContent('Saved Successfully');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import ProxySettingsForm from '../ProxySettingsForm';
|
||||
|
||||
// ── Constants mock ─────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../constants.js', () => ({
|
||||
PROXY_SETTINGS_OPTIONS: {
|
||||
buffering_timeout: { label: 'Buffering Timeout', description: 'Timeout in seconds' },
|
||||
buffering_speed: { label: 'Buffering Speed', description: 'Speed multiplier' },
|
||||
redis_url: { label: 'Redis URL', description: 'Redis connection URL' },
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({
|
||||
updateSetting: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../utils/forms/settings/ProxySettingsFormUtils.js', () => ({
|
||||
getProxySettingsFormInitialValues: vi.fn(),
|
||||
getProxySettingDefaults: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Mantine form ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/form', () => ({ useForm: vi.fn() }));
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Alert: ({ title, children }) => (
|
||||
<div data-testid="alert">
|
||||
<span data-testid="alert-title">{title}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, onClick, disabled, loading, type, variant, color }) => (
|
||||
<button
|
||||
type={type || 'button'}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
data-loading={String(loading)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
NumberInput: ({ label, description, min, max, step, ...rest }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
{description && <span data-testid={`desc-${label}`}>{description}</span>}
|
||||
<input
|
||||
data-testid={`number-input-${label}`}
|
||||
type="number"
|
||||
aria-label={label}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(e) => rest.onChange?.(Number(e.target.value))}
|
||||
value={rest.value ?? 0}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
TextInput: ({ label, description, ...rest }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
{description && <span data-testid={`desc-${label}`}>{description}</span>}
|
||||
<input
|
||||
data-testid={`text-input-${label}`}
|
||||
aria-label={label}
|
||||
onChange={(e) => rest.onChange?.(e)}
|
||||
value={rest.value ?? ''}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Imports after mocks
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
import useSettingsStore from '../../../../store/settings.jsx';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { updateSetting } from '../../../../utils/pages/SettingsUtils.js';
|
||||
import {
|
||||
getProxySettingsFormInitialValues,
|
||||
getProxySettingDefaults,
|
||||
} from '../../../../utils/forms/settings/ProxySettingsFormUtils.js';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
const mockInitialValues = {
|
||||
buffering_timeout: 30,
|
||||
buffering_speed: 1.0,
|
||||
redis_url: 'redis://localhost:6379',
|
||||
};
|
||||
|
||||
const mockDefaults = {
|
||||
buffering_timeout: 30,
|
||||
buffering_speed: 1.0,
|
||||
redis_url: '',
|
||||
};
|
||||
|
||||
const makeFormMock = (overrides = {}) => ({
|
||||
getInputProps: vi.fn((field) => ({
|
||||
value: mockInitialValues[field] ?? '',
|
||||
onChange: vi.fn(),
|
||||
})),
|
||||
setValues: vi.fn(),
|
||||
getValues: vi.fn(() => mockInitialValues),
|
||||
onSubmit: vi.fn((handler) => (e) => {
|
||||
e?.preventDefault?.();
|
||||
return handler();
|
||||
}),
|
||||
submitting: false,
|
||||
errors: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeSettings = (overrides = {}) => ({
|
||||
proxy_settings: {
|
||||
key: 'proxy_settings',
|
||||
value: { ...mockInitialValues },
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupMocks = ({ settings = makeSettings(), formOverrides = {} } = {}) => {
|
||||
const formMock = makeFormMock(formOverrides);
|
||||
|
||||
vi.mocked(useForm).mockReturnValue(formMock);
|
||||
vi.mocked(getProxySettingsFormInitialValues).mockReturnValue(mockInitialValues);
|
||||
vi.mocked(getProxySettingDefaults).mockReturnValue(mockDefaults);
|
||||
vi.mocked(updateSetting).mockResolvedValue({ success: true });
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings }));
|
||||
|
||||
return { formMock };
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('ProxySettingsForm', () => {
|
||||
let formMock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
({ formMock } = setupMocks());
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
describe('rendering', () => {
|
||||
it('does not show success alert on initial render', () => {
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders NumberInput for buffering_timeout', () => {
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
expect(screen.getByTestId('number-input-Buffering Timeout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders NumberInput for buffering_speed', () => {
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
expect(screen.getByTestId('number-input-Buffering Speed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders TextInput for redis_url', () => {
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
expect(screen.getByTestId('text-input-Redis URL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description text for fields that have one', () => {
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
expect(screen.getByTestId('desc-Buffering Timeout')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Initialization ─────────────────────────────────────────────────────────
|
||||
describe('initialization', () => {
|
||||
it('calls getProxySettingsFormInitialValues on mount', () => {
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
expect(getProxySettingsFormInitialValues).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls setValues with merged defaults and stored settings on mount', async () => {
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(formMock.setValues).toHaveBeenCalledWith({
|
||||
...mockDefaults,
|
||||
...makeSettings().proxy_settings.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call setValues when proxy_settings is missing', async () => {
|
||||
({ formMock } = setupMocks({ settings: {} }));
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(formMock.setValues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call setValues when proxy_settings.value is missing', async () => {
|
||||
({ formMock } = setupMocks({
|
||||
settings: { proxy_settings: { key: 'proxy_settings' } },
|
||||
}));
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(formMock.setValues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles null settings gracefully', () => {
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings: null }));
|
||||
expect(() => render(<ProxySettingsForm active={true} />)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── active prop ────────────────────────────────────────────────────────────
|
||||
describe('active prop', () => {
|
||||
it('clears saved state when active becomes false', async () => {
|
||||
vi.mocked(updateSetting).mockResolvedValue({ success: true });
|
||||
const { rerender } = render(<ProxySettingsForm active={true} />);
|
||||
|
||||
// Trigger a successful save
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(<ProxySettingsForm active={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form submission ────────────────────────────────────────────────────────
|
||||
describe('form submission', () => {
|
||||
it('calls updateSetting with proxy_settings on Save', async () => {
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSetting).toHaveBeenCalledWith({
|
||||
...makeSettings().proxy_settings,
|
||||
value: mockInitialValues,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success alert when updateSetting returns a truthy result', async () => {
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('alert-title')).toHaveTextContent('Saved Successfully');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show success alert when updateSetting returns undefined', async () => {
|
||||
vi.mocked(updateSetting).mockResolvedValue(undefined);
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSetting).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show success alert when updateSetting returns null', async () => {
|
||||
vi.mocked(updateSetting).mockResolvedValue(null);
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSetting).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show success alert when updateSetting throws', async () => {
|
||||
vi.mocked(updateSetting).mockRejectedValue(new Error('save failed'));
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resets saved to false at the start of a new submission', async () => {
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
|
||||
// First save — succeeds
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
await waitFor(() => screen.getByTestId('alert'));
|
||||
|
||||
// Second save — returns undefined (no result)
|
||||
vi.mocked(updateSetting).mockResolvedValue(undefined);
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Reset to Defaults ──────────────────────────────────────────────────────
|
||||
describe('Reset to Defaults', () => {
|
||||
it('calls getProxySettingDefaults when Reset to Defaults is clicked', () => {
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Reset to Defaults'));
|
||||
expect(getProxySettingDefaults).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls form.setValues with defaults when Reset to Defaults is clicked', () => {
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Reset to Defaults'));
|
||||
expect(formMock.setValues).toHaveBeenCalledWith(mockDefaults);
|
||||
});
|
||||
|
||||
it('does not submit the form when Reset to Defaults is clicked', async () => {
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Reset to Defaults'));
|
||||
await waitFor(() => {
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── ProxySettingsOptions field routing ─────────────────────────────────────
|
||||
describe('ProxySettingsOptions field routing', () => {
|
||||
it('calls getInputProps for each PROXY_SETTINGS_OPTIONS key', () => {
|
||||
render(<ProxySettingsForm active={true} />);
|
||||
expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_timeout');
|
||||
expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_speed');
|
||||
expect(formMock.getInputProps).toHaveBeenCalledWith('redis_url');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,760 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import StreamSettingsForm from '../StreamSettingsForm';
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../../../store/warnings.jsx', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../../../store/userAgents.jsx', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../../../store/streamProfiles.jsx', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Constants mock ─────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../constants.js', () => ({
|
||||
REGION_CHOICES: [
|
||||
{ label: 'US', value: 'us' },
|
||||
{ label: 'EU', value: 'eu' },
|
||||
],
|
||||
}));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({
|
||||
getChangedSettings: vi.fn(),
|
||||
parseSettings: vi.fn(),
|
||||
rehashStreams: vi.fn(),
|
||||
saveChangedSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../utils/forms/settings/StreamSettingsFormUtils.js', () => ({
|
||||
getStreamSettingsFormInitialValues: vi.fn(),
|
||||
getStreamSettingsFormValidation: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Mantine form ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/form', () => ({ useForm: vi.fn() }));
|
||||
|
||||
// ── ConfirmationDialog mock ────────────────────────────────────────────────────
|
||||
vi.mock('../../../ConfirmationDialog.jsx', () => ({
|
||||
default: ({
|
||||
opened,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
actionKey,
|
||||
onSuppressChange,
|
||||
}) =>
|
||||
opened ? (
|
||||
<div data-testid="confirmation-dialog">
|
||||
<div data-testid="confirm-title">{title}</div>
|
||||
<div data-testid="confirm-message">{message}</div>
|
||||
<button data-testid="confirm-ok" onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
<button data-testid="confirm-cancel" onClick={onClose}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
{actionKey && (
|
||||
<button
|
||||
data-testid="confirm-suppress"
|
||||
onClick={() => onSuppressChange?.(actionKey)}
|
||||
>
|
||||
Don't show again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Alert: ({ title, children }) => (
|
||||
<div data-testid="alert">
|
||||
<span data-testid="alert-title">{title}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, onClick, disabled, loading, type, variant, color }) => (
|
||||
<button
|
||||
type={type || 'button'}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
data-variant={variant}
|
||||
data-color={color}
|
||||
data-loading={String(loading)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
MultiSelect: ({ label, id, data, ...rest }) => (
|
||||
<div>
|
||||
<label htmlFor={id}>{label}</label>
|
||||
<select
|
||||
data-testid={id}
|
||||
id={id}
|
||||
aria-label={label}
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const selected = Array.from(e.target.selectedOptions).map((o) => o.value);
|
||||
rest.onChange?.(selected);
|
||||
}}
|
||||
value={rest.value ?? []}
|
||||
>
|
||||
{data?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
Select: ({ label, id, data, ...rest }) => (
|
||||
<div>
|
||||
<label htmlFor={id}>{label}</label>
|
||||
<select
|
||||
data-testid={id}
|
||||
id={id}
|
||||
aria-label={label}
|
||||
onChange={(e) => rest.onChange?.(e.target.value)}
|
||||
value={rest.value ?? ''}
|
||||
>
|
||||
{data?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
Switch: ({ id, ...rest }) => (
|
||||
<input
|
||||
data-testid={id}
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={rest.checked ?? false}
|
||||
onChange={(e) => rest.onChange?.(e)}
|
||||
/>
|
||||
),
|
||||
Text: ({ children, size, fw }) => (
|
||||
<span data-size={size} data-fw={fw}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Imports after mocks
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
import useSettingsStore from '../../../../store/settings.jsx';
|
||||
import useWarningsStore from '../../../../store/warnings.jsx';
|
||||
import useUserAgentsStore from '../../../../store/userAgents.jsx';
|
||||
import useStreamProfilesStore from '../../../../store/streamProfiles.jsx';
|
||||
import { useForm } from '@mantine/form';
|
||||
import {
|
||||
getChangedSettings,
|
||||
parseSettings,
|
||||
rehashStreams,
|
||||
saveChangedSettings,
|
||||
} from '../../../../utils/pages/SettingsUtils.js';
|
||||
import {
|
||||
getStreamSettingsFormInitialValues,
|
||||
getStreamSettingsFormValidation,
|
||||
} from '../../../../utils/forms/settings/StreamSettingsFormUtils.js';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
const mockFormValues = {
|
||||
default_user_agent: '1',
|
||||
default_stream_profile: '2',
|
||||
preferred_region: 'us',
|
||||
auto_import_mapped_files: false,
|
||||
m3u_hash_key: ['name'],
|
||||
};
|
||||
|
||||
const makeFormMock = (overrides = {}) => ({
|
||||
getInputProps: vi.fn((field, opts) => {
|
||||
if (opts?.type === 'checkbox') {
|
||||
return { checked: mockFormValues[field] ?? false, onChange: vi.fn() };
|
||||
}
|
||||
return { value: mockFormValues[field] ?? '', onChange: vi.fn() };
|
||||
}),
|
||||
setValues: vi.fn(),
|
||||
getValues: vi.fn(() => mockFormValues),
|
||||
onSubmit: vi.fn((handler) => (e) => {
|
||||
e?.preventDefault?.();
|
||||
return handler();
|
||||
}),
|
||||
submitting: false,
|
||||
errors: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeSettings = (overrides = {}) => ({
|
||||
stream_settings: {
|
||||
key: 'stream_settings',
|
||||
value: { m3u_hash_key: 'name' },
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeUserAgents = () => [
|
||||
{ id: 1, name: 'Chrome' },
|
||||
{ id: 2, name: 'Firefox' },
|
||||
];
|
||||
|
||||
const makeStreamProfiles = () => [
|
||||
{ id: 1, name: 'Default' },
|
||||
{ id: 2, name: 'HLS' },
|
||||
];
|
||||
|
||||
const setupMocks = ({
|
||||
settings = makeSettings(),
|
||||
formOverrides = {},
|
||||
warningSuppressed = false,
|
||||
userAgents = makeUserAgents(),
|
||||
streamProfiles = makeStreamProfiles(),
|
||||
} = {}) => {
|
||||
const formMock = makeFormMock(formOverrides);
|
||||
|
||||
vi.mocked(useForm).mockReturnValue(formMock);
|
||||
vi.mocked(getStreamSettingsFormInitialValues).mockReturnValue(mockFormValues);
|
||||
vi.mocked(getStreamSettingsFormValidation).mockReturnValue({});
|
||||
vi.mocked(parseSettings).mockReturnValue(mockFormValues);
|
||||
vi.mocked(getChangedSettings).mockReturnValue({ preferred_region: 'eu' });
|
||||
vi.mocked(saveChangedSettings).mockResolvedValue(undefined);
|
||||
vi.mocked(rehashStreams).mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings }));
|
||||
vi.mocked(useWarningsStore).mockImplementation((sel) =>
|
||||
sel({
|
||||
suppressWarning: vi.fn(),
|
||||
isWarningSuppressed: vi.fn(() => warningSuppressed),
|
||||
})
|
||||
);
|
||||
vi.mocked(useUserAgentsStore).mockImplementation((sel) =>
|
||||
sel({ userAgents })
|
||||
);
|
||||
vi.mocked(useStreamProfilesStore).mockImplementation((sel) =>
|
||||
sel({ profiles: streamProfiles })
|
||||
);
|
||||
|
||||
return { formMock };
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('StreamSettingsForm', () => {
|
||||
let formMock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
({ formMock } = setupMocks());
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
describe('rendering', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Save button', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Rehash Streams button', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(screen.getByText('Rehash Streams')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Default User Agent select', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(screen.getByTestId('default_user_agent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Default Stream Profile select', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(screen.getByTestId('default_stream_profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Preferred Region select', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(screen.getByTestId('preferred_region')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Auto-Import Mapped Files switch', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(screen.getByTestId('auto_import_mapped_files')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the M3U Hash Key multiselect', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(screen.getByTestId('m3u_hash_key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates user agent options from store', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(screen.getByText('Chrome')).toBeInTheDocument();
|
||||
expect(screen.getByText('Firefox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates stream profile options from store', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(screen.getByText('Default')).toBeInTheDocument();
|
||||
expect(screen.getByText('HLS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates region options from REGION_CHOICES', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(screen.getByText('US')).toBeInTheDocument();
|
||||
expect(screen.getByText('EU')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show success alert on initial render', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show confirmation dialog on initial render', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Initialization ─────────────────────────────────────────────────────────
|
||||
describe('initialization', () => {
|
||||
it('calls getStreamSettingsFormInitialValues on mount', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(getStreamSettingsFormInitialValues).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls getStreamSettingsFormValidation on mount', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(getStreamSettingsFormValidation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls parseSettings with settings from store on mount', async () => {
|
||||
const settings = makeSettings();
|
||||
setupMocks({ settings });
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(parseSettings).toHaveBeenCalledWith(settings);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls form.setValues with parsed settings on mount', async () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(formMock.setValues).toHaveBeenCalledWith(mockFormValues);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call parseSettings when settings is null', async () => {
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ settings: null })
|
||||
);
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
await waitFor(() => {
|
||||
expect(parseSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── active prop ────────────────────────────────────────────────────────────
|
||||
describe('active prop', () => {
|
||||
it('clears saved state when active becomes false', async () => {
|
||||
vi.mocked(saveChangedSettings).mockResolvedValue(undefined);
|
||||
vi.mocked(getChangedSettings).mockReturnValue({});
|
||||
const { rerender } = render(<StreamSettingsForm active={true} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(<StreamSettingsForm active={false} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears rehashSuccess when active becomes false', async () => {
|
||||
({ formMock } = setupMocks({ warningSuppressed: true }));
|
||||
const { rerender } = render(<StreamSettingsForm active={true} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Rehash Streams'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(<StreamSettingsForm active={false} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form submission — no hash key change ───────────────────────────────────
|
||||
describe('form submission (no M3U hash key change)', () => {
|
||||
beforeEach(() => {
|
||||
// Same hash key before and after → no dialog
|
||||
vi.mocked(getChangedSettings).mockReturnValue({ preferred_region: 'eu' });
|
||||
setupMocks();
|
||||
formMock = makeFormMock();
|
||||
vi.mocked(useForm).mockReturnValue(formMock);
|
||||
});
|
||||
|
||||
it('calls saveChangedSettings with settings and changed values on submit', async () => {
|
||||
const settings = makeSettings();
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(saveChangedSettings).toHaveBeenCalledWith(
|
||||
settings,
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success alert after successful save', async () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('alert-title')).toHaveTextContent('Saved Successfully');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show success alert when saveChangedSettings throws', async () => {
|
||||
vi.mocked(saveChangedSettings).mockRejectedValue(new Error('save failed'));
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs error when saveChangedSettings throws', async () => {
|
||||
const error = new Error('save failed');
|
||||
vi.mocked(saveChangedSettings).mockRejectedValue(error);
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Error saving settings:', error);
|
||||
});
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not open confirmation dialog when hash key is unchanged', async () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(saveChangedSettings).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Form submission — hash key changed ────────────────────────────────────
|
||||
describe('form submission (M3U hash key changed)', () => {
|
||||
const makeHashChangedFormMock = () =>
|
||||
makeFormMock({
|
||||
getValues: vi.fn(() => ({
|
||||
...mockFormValues,
|
||||
m3u_hash_key: ['url'], // different from stored 'name'
|
||||
})),
|
||||
});
|
||||
|
||||
it('opens confirmation dialog with save title when hash key changes', async () => {
|
||||
const fm = makeHashChangedFormMock();
|
||||
vi.mocked(useForm).mockReturnValue(fm);
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('confirm-title')).toHaveTextContent(
|
||||
'Save Settings and Rehash Streams'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Save and Rehash" confirm label for hash key change dialog', async () => {
|
||||
const fm = makeHashChangedFormMock();
|
||||
vi.mocked(useForm).mockReturnValue(fm);
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-ok')).toHaveTextContent('Save and Rehash');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call saveChangedSettings before dialog is confirmed', async () => {
|
||||
const fm = makeHashChangedFormMock();
|
||||
vi.mocked(useForm).mockReturnValue(fm);
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
|
||||
});
|
||||
expect(saveChangedSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls saveChangedSettings after confirming save-and-rehash dialog', async () => {
|
||||
const fm = makeHashChangedFormMock();
|
||||
vi.mocked(useForm).mockReturnValue(fm);
|
||||
const settings = makeSettings();
|
||||
setupMocks({ settings });
|
||||
vi.mocked(useForm).mockReturnValue(fm);
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
await waitFor(() =>
|
||||
screen.getByTestId('confirmation-dialog')
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveChangedSettings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success alert after confirming save-and-rehash dialog', async () => {
|
||||
const fm = makeHashChangedFormMock();
|
||||
vi.mocked(useForm).mockReturnValue(fm);
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
await waitFor(() => screen.getByTestId('confirmation-dialog'));
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert-title')).toHaveTextContent('Saved Successfully');
|
||||
});
|
||||
});
|
||||
|
||||
it('closes dialog after confirming save-and-rehash', async () => {
|
||||
const fm = makeHashChangedFormMock();
|
||||
vi.mocked(useForm).mockReturnValue(fm);
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
await waitFor(() => screen.getByTestId('confirmation-dialog'));
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes dialog and does not save when Cancel is clicked', async () => {
|
||||
const fm = makeHashChangedFormMock();
|
||||
vi.mocked(useForm).mockReturnValue(fm);
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
await waitFor(() => screen.getByTestId('confirmation-dialog'));
|
||||
fireEvent.click(screen.getByTestId('confirm-cancel'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(saveChangedSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips dialog entirely when rehash-streams warning is suppressed', async () => {
|
||||
const fm = makeHashChangedFormMock();
|
||||
vi.mocked(useForm).mockReturnValue(fm);
|
||||
setupMocks({ warningSuppressed: true });
|
||||
vi.mocked(useForm).mockReturnValue(fm);
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
|
||||
fireEvent.submit(screen.getByText('Save').closest('form'));
|
||||
await waitFor(() => {
|
||||
expect(saveChangedSettings).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rehash Streams ─────────────────────────────────────────────────────────
|
||||
describe('Rehash Streams button', () => {
|
||||
it('opens confirmation dialog with rehash title when warning is not suppressed', async () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Rehash Streams'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('confirm-title')).toHaveTextContent(
|
||||
'Confirm Stream Rehash'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Start Rehash" confirm label for rehash-only dialog', async () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Rehash Streams'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-ok')).toHaveTextContent('Start Rehash');
|
||||
});
|
||||
});
|
||||
|
||||
it('calls rehashStreams after confirming rehash dialog', async () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Rehash Streams'));
|
||||
await waitFor(() => screen.getByTestId('confirmation-dialog'));
|
||||
fireEvent.click(screen.getByTestId('confirm-ok'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rehashStreams).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('skips confirmation dialog and calls rehashStreams directly when suppressed', async () => {
|
||||
setupMocks({ warningSuppressed: true });
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Rehash Streams'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rehashStreams).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows rehash success alert after rehash completes', async () => {
|
||||
setupMocks({ warningSuppressed: true });
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Rehash Streams'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert-title')).toHaveTextContent(
|
||||
'Rehash task queued successfully'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show rehash success alert when rehashStreams throws', async () => {
|
||||
setupMocks({ warningSuppressed: true });
|
||||
vi.mocked(rehashStreams).mockRejectedValue(new Error('fail'));
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Rehash Streams'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs error when rehashStreams throws', async () => {
|
||||
setupMocks({ warningSuppressed: true });
|
||||
const error = new Error('network error');
|
||||
vi.mocked(rehashStreams).mockRejectedValue(error);
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Rehash Streams'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Error rehashing streams:', error);
|
||||
});
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('closes confirmation dialog when Cancel is clicked on rehash dialog', async () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Rehash Streams'));
|
||||
await waitFor(() => screen.getByTestId('confirmation-dialog'));
|
||||
fireEvent.click(screen.getByTestId('confirm-cancel'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(rehashStreams).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls suppressWarning when "Don\'t show again" is clicked', async () => {
|
||||
const suppressWarning = vi.fn();
|
||||
vi.mocked(useWarningsStore).mockImplementation((sel) =>
|
||||
sel({
|
||||
suppressWarning,
|
||||
isWarningSuppressed: vi.fn(() => false),
|
||||
})
|
||||
);
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Rehash Streams'));
|
||||
await waitFor(() => screen.getByTestId('confirmation-dialog'));
|
||||
fireEvent.click(screen.getByTestId('confirm-suppress'));
|
||||
|
||||
expect(suppressWarning).toHaveBeenCalledWith('rehash-streams');
|
||||
});
|
||||
|
||||
it('Rehash Streams button is disabled while rehashing', async () => {
|
||||
let resolveRehash;
|
||||
vi.mocked(rehashStreams).mockReturnValue(
|
||||
new Promise((res) => { resolveRehash = res; })
|
||||
);
|
||||
setupMocks({ warningSuppressed: true });
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Rehash Streams'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Rehash Streams')).toBeDisabled();
|
||||
});
|
||||
|
||||
resolveRehash();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Rehash Streams')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── getInputProps wiring ───────────────────────────────────────────────────
|
||||
describe('getInputProps wiring', () => {
|
||||
it('calls getInputProps for default_user_agent', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(formMock.getInputProps).toHaveBeenCalledWith('default_user_agent');
|
||||
});
|
||||
|
||||
it('calls getInputProps for default_stream_profile', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(formMock.getInputProps).toHaveBeenCalledWith('default_stream_profile');
|
||||
});
|
||||
|
||||
it('calls getInputProps for preferred_region', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(formMock.getInputProps).toHaveBeenCalledWith('preferred_region');
|
||||
});
|
||||
|
||||
it('calls getInputProps for auto_import_mapped_files with checkbox type', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(formMock.getInputProps).toHaveBeenCalledWith(
|
||||
'auto_import_mapped_files',
|
||||
{ type: 'checkbox' }
|
||||
);
|
||||
});
|
||||
|
||||
it('calls getInputProps for m3u_hash_key', () => {
|
||||
render(<StreamSettingsForm active={true} />);
|
||||
expect(formMock.getInputProps).toHaveBeenCalledWith('m3u_hash_key');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import SystemSettingsForm from '../SystemSettingsForm';
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../utils/pages/SettingsUtils.js', () => ({
|
||||
getChangedSettings: vi.fn(),
|
||||
parseSettings: vi.fn(),
|
||||
saveChangedSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../utils/forms/settings/SystemSettingsFormUtils.js', () => ({
|
||||
getSystemSettingsFormInitialValues: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Mantine form ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/form', () => ({
|
||||
useForm: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Alert: ({ title }) => <div data-testid="alert">{title}</div>,
|
||||
Button: ({ children, onClick, disabled }) => (
|
||||
<button onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Flex: ({ children }) => <div>{children}</div>,
|
||||
NumberInput: ({ label, value, onChange, min, max, step, description }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<p>{description}</p>
|
||||
<input
|
||||
data-testid="number-input"
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Imports after mocks
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
import useSettingsStore from '../../../../store/settings.jsx';
|
||||
import { getChangedSettings, parseSettings, saveChangedSettings } from '../../../../utils/pages/SettingsUtils.js';
|
||||
import { getSystemSettingsFormInitialValues } from '../../../../utils/forms/settings/SystemSettingsFormUtils.js';
|
||||
import { useForm } from '@mantine/form';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
const makeSettings = (overrides = {}) => ({
|
||||
max_system_events: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const setupMocks = ({ settings = makeSettings() } = {}) => {
|
||||
const formValues = { max_system_events: settings?.max_system_events ?? 100 };
|
||||
|
||||
const formMock = {
|
||||
values: formValues,
|
||||
getValues: vi.fn().mockReturnValue(formValues),
|
||||
setValues: vi.fn(),
|
||||
setFieldValue: vi.fn((key, value) => {
|
||||
formMock.values[key] = value;
|
||||
}),
|
||||
onSubmit: vi.fn((handler) => handler),
|
||||
submitting: false,
|
||||
};
|
||||
|
||||
vi.mocked(useForm).mockReturnValue(formMock);
|
||||
vi.mocked(getSystemSettingsFormInitialValues).mockReturnValue(formValues);
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings }));
|
||||
vi.mocked(parseSettings).mockReturnValue(formValues);
|
||||
vi.mocked(getChangedSettings).mockReturnValue({ max_system_events: settings?.max_system_events ?? 100 });
|
||||
vi.mocked(saveChangedSettings).mockResolvedValue(undefined);
|
||||
|
||||
return { formMock };
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('SystemSettingsForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the Save button', () => {
|
||||
setupMocks();
|
||||
render(<SystemSettingsForm active={true} />);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the NumberInput for max_system_events', () => {
|
||||
setupMocks();
|
||||
render(<SystemSettingsForm active={true} />);
|
||||
expect(screen.getByTestId('number-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the NumberInput label', () => {
|
||||
setupMocks();
|
||||
render(<SystemSettingsForm active={true} />);
|
||||
expect(screen.getByText('Maximum System Events')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the NumberInput description', () => {
|
||||
setupMocks();
|
||||
render(<SystemSettingsForm active={true} />);
|
||||
expect(
|
||||
screen.getByText('Number of events to retain (minimum: 10, maximum: 1000)')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders descriptive text about system events', () => {
|
||||
setupMocks();
|
||||
render(<SystemSettingsForm active={true} />);
|
||||
expect(
|
||||
screen.getByText(/Configure how many system events/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show success alert on initial render', () => {
|
||||
setupMocks();
|
||||
render(<SystemSettingsForm active={true} />);
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders NumberInput with value from form values', () => {
|
||||
setupMocks({ settings: makeSettings({ max_system_events: 250 }) });
|
||||
render(<SystemSettingsForm active={true} />);
|
||||
expect(screen.getByTestId('number-input')).toHaveValue(250);
|
||||
});
|
||||
|
||||
it('falls back to 100 when max_system_events is 0/falsy', () => {
|
||||
const formValues = { max_system_events: 0 };
|
||||
const formMock = {
|
||||
values: formValues,
|
||||
getValues: vi.fn().mockReturnValue(formValues),
|
||||
setValues: vi.fn(),
|
||||
setFieldValue: vi.fn(),
|
||||
onSubmit: vi.fn((handler) => handler),
|
||||
submitting: false,
|
||||
};
|
||||
vi.mocked(useForm).mockReturnValue(formMock);
|
||||
vi.mocked(getSystemSettingsFormInitialValues).mockReturnValue(formValues);
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) =>
|
||||
sel({ settings: makeSettings({ max_system_events: 0 }) })
|
||||
);
|
||||
vi.mocked(parseSettings).mockReturnValue(formValues);
|
||||
vi.mocked(getChangedSettings).mockReturnValue({});
|
||||
vi.mocked(saveChangedSettings).mockResolvedValue(undefined);
|
||||
|
||||
render(<SystemSettingsForm active={true} />);
|
||||
expect(screen.getByTestId('number-input')).toHaveValue(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Settings initialization ────────────────────────────────────────────────
|
||||
|
||||
describe('settings initialization', () => {
|
||||
it('calls parseSettings with settings on mount', () => {
|
||||
const settings = makeSettings();
|
||||
setupMocks({ settings });
|
||||
render(<SystemSettingsForm active={true} />);
|
||||
expect(parseSettings).toHaveBeenCalledWith(settings);
|
||||
});
|
||||
|
||||
it('calls form.setValues with parsed settings on mount', () => {
|
||||
const settings = makeSettings();
|
||||
const { formMock } = setupMocks({ settings });
|
||||
render(<SystemSettingsForm active={true} />);
|
||||
expect(formMock.setValues).toHaveBeenCalledWith({ max_system_events: 100 });
|
||||
});
|
||||
|
||||
it('does not call parseSettings when settings is null', () => {
|
||||
const formMock = {
|
||||
values: { max_system_events: 100 },
|
||||
getValues: vi.fn().mockReturnValue({ max_system_events: 100 }),
|
||||
setValues: vi.fn(),
|
||||
setFieldValue: vi.fn(),
|
||||
onSubmit: vi.fn((handler) => handler),
|
||||
submitting: false,
|
||||
};
|
||||
vi.mocked(useForm).mockReturnValue(formMock);
|
||||
vi.mocked(getSystemSettingsFormInitialValues).mockReturnValue({ max_system_events: 100 });
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings: null }));
|
||||
vi.mocked(parseSettings).mockReturnValue({});
|
||||
vi.mocked(saveChangedSettings).mockResolvedValue(undefined);
|
||||
|
||||
render(<SystemSettingsForm active={true} />);
|
||||
expect(parseSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── NumberInput interaction ────────────────────────────────────────────────
|
||||
|
||||
describe('NumberInput interaction', () => {
|
||||
it('calls form.setFieldValue when NumberInput changes', () => {
|
||||
const { formMock } = setupMocks();
|
||||
render(<SystemSettingsForm active={true} />);
|
||||
fireEvent.change(screen.getByTestId('number-input'), { target: { value: '200' } });
|
||||
expect(formMock.setFieldValue).toHaveBeenCalledWith('max_system_events', 200);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Save / submit ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('save button', () => {
|
||||
it('calls getChangedSettings and saveChangedSettings on submit', async () => {
|
||||
const settings = makeSettings();
|
||||
const { formMock } = setupMocks({ settings });
|
||||
render(<SystemSettingsForm active={true} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getChangedSettings).toHaveBeenCalledWith(formMock.getValues(), settings);
|
||||
expect(saveChangedSettings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success alert after successful save', async () => {
|
||||
setupMocks();
|
||||
render(<SystemSettingsForm active={true} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Saved Successfully')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show success alert when saveChangedSettings throws', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
setupMocks();
|
||||
vi.mocked(saveChangedSettings).mockRejectedValue(new Error('save failed'));
|
||||
|
||||
render(<SystemSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs error when saveChangedSettings throws', async () => {
|
||||
const error = new Error('save failed');
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
setupMocks();
|
||||
vi.mocked(saveChangedSettings).mockRejectedValue(error);
|
||||
|
||||
render(<SystemSettingsForm active={true} />);
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Error saving settings:', error);
|
||||
});
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── active prop / saved state reset ───────────────────────────────────────
|
||||
|
||||
describe('active prop behavior', () => {
|
||||
it('clears saved alert when active becomes false', async () => {
|
||||
setupMocks();
|
||||
const { rerender } = render(<SystemSettingsForm active={true} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(<SystemSettingsForm active={false} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not clear saved alert while active remains true', async () => {
|
||||
setupMocks();
|
||||
const { rerender } = render(<SystemSettingsForm active={true} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(<SystemSettingsForm active={true} />);
|
||||
expect(screen.getByTestId('alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,424 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import UiSettingsForm from '../UiSettingsForm';
|
||||
|
||||
// ── Store mocks ────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../store/settings.jsx', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Hook mocks ─────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../hooks/useLocalStorage.jsx', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../../../hooks/useTablePreferences.jsx', () => ({ default: vi.fn() }));
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../utils/dateTimeUtils.js', () => ({
|
||||
buildTimeZoneOptions: vi.fn(),
|
||||
getDefaultTimeZone: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../utils/forms/settings/UiSettingsFormUtils.js', () => ({
|
||||
saveTimeZoneSetting: vi.fn(),
|
||||
}));
|
||||
|
||||
// ── Mantine core ───────────────────────────────────────────────────────────────
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Select: ({ label, value, onChange, data }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<select
|
||||
data-testid={`select-${label?.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
{data?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
Switch: ({ label, description, checked, onChange }) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
{description && <p>{description}</p>}
|
||||
<input
|
||||
data-testid="switch-header-pinned"
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Imports after mocks
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
import useSettingsStore from '../../../../store/settings.jsx';
|
||||
import useLocalStorage from '../../../../hooks/useLocalStorage.jsx';
|
||||
import useTablePreferences from '../../../../hooks/useTablePreferences.jsx';
|
||||
import { buildTimeZoneOptions, getDefaultTimeZone } from '../../../../utils/dateTimeUtils.js';
|
||||
import { showNotification } from '../../../../utils/notificationUtils.js';
|
||||
import { saveTimeZoneSetting } from '../../../../utils/forms/settings/UiSettingsFormUtils.js';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
const makeSettings = ({ timeZone = null } = {}) => ({
|
||||
system_settings: timeZone
|
||||
? { value: { time_zone: timeZone } }
|
||||
: { value: {} },
|
||||
});
|
||||
|
||||
const DEFAULT_TZ = 'America/New_York';
|
||||
const TZ_OPTIONS = [
|
||||
{ value: 'America/New_York', label: 'America/New_York' },
|
||||
{ value: 'America/Chicago', label: 'America/Chicago' },
|
||||
{ value: 'UTC', label: 'UTC' },
|
||||
];
|
||||
|
||||
const setupMocks = ({
|
||||
settings = makeSettings(),
|
||||
timeFormat = '12h',
|
||||
dateFormat = 'mdy',
|
||||
timeZone = DEFAULT_TZ,
|
||||
headerPinned = false,
|
||||
tableSize = 'default',
|
||||
} = {}) => {
|
||||
const setTimeFormat = vi.fn();
|
||||
const setDateFormat = vi.fn();
|
||||
const setTimeZone = vi.fn();
|
||||
const setHeaderPinned = vi.fn();
|
||||
const setTableSize = vi.fn();
|
||||
|
||||
vi.mocked(getDefaultTimeZone).mockReturnValue(DEFAULT_TZ);
|
||||
vi.mocked(buildTimeZoneOptions).mockReturnValue(TZ_OPTIONS);
|
||||
vi.mocked(saveTimeZoneSetting).mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings }));
|
||||
|
||||
vi.mocked(useLocalStorage)
|
||||
.mockImplementation((key, defaultVal) => {
|
||||
if (key === 'time-format') return [timeFormat, setTimeFormat];
|
||||
if (key === 'date-format') return [dateFormat, setDateFormat];
|
||||
if (key === 'time-zone') return [timeZone, setTimeZone];
|
||||
return [defaultVal, vi.fn()];
|
||||
});
|
||||
|
||||
vi.mocked(useTablePreferences).mockReturnValue({
|
||||
headerPinned,
|
||||
setHeaderPinned,
|
||||
tableSize,
|
||||
setTableSize,
|
||||
});
|
||||
|
||||
return { setTimeFormat, setDateFormat, setTimeZone, setHeaderPinned, setTableSize };
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('UiSettingsForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the Table Size select', () => {
|
||||
setupMocks();
|
||||
render(<UiSettingsForm />);
|
||||
expect(screen.getByTestId('select-table-size')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Time format select', () => {
|
||||
setupMocks();
|
||||
render(<UiSettingsForm />);
|
||||
expect(screen.getByTestId('select-time-format')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Date format select', () => {
|
||||
setupMocks();
|
||||
render(<UiSettingsForm />);
|
||||
expect(screen.getByTestId('select-date-format')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Time zone select', () => {
|
||||
setupMocks();
|
||||
render(<UiSettingsForm />);
|
||||
expect(screen.getByTestId('select-time-zone')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Pin Table Headers switch', () => {
|
||||
setupMocks();
|
||||
render(<UiSettingsForm />);
|
||||
expect(screen.getByTestId('switch-header-pinned')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the switch description', () => {
|
||||
setupMocks();
|
||||
render(<UiSettingsForm />);
|
||||
expect(screen.getByText('Keep table headers visible when scrolling')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Table Size select with initial value', () => {
|
||||
setupMocks({ tableSize: 'compact' });
|
||||
render(<UiSettingsForm />);
|
||||
expect(screen.getByTestId('select-table-size')).toHaveValue('compact');
|
||||
});
|
||||
|
||||
it('renders Time format select with initial value', () => {
|
||||
setupMocks({ timeFormat: '24h' });
|
||||
render(<UiSettingsForm />);
|
||||
expect(screen.getByTestId('select-time-format')).toHaveValue('24h');
|
||||
});
|
||||
|
||||
it('renders Date format select with initial value', () => {
|
||||
setupMocks({ dateFormat: 'dmy' });
|
||||
render(<UiSettingsForm />);
|
||||
expect(screen.getByTestId('select-date-format')).toHaveValue('dmy');
|
||||
});
|
||||
|
||||
it('renders Time zone select with initial value', () => {
|
||||
setupMocks({ timeZone: 'UTC' });
|
||||
render(<UiSettingsForm />);
|
||||
expect(screen.getByTestId('select-time-zone')).toHaveValue('UTC');
|
||||
});
|
||||
|
||||
it('renders switch unchecked when headerPinned is false', () => {
|
||||
setupMocks({ headerPinned: false });
|
||||
render(<UiSettingsForm />);
|
||||
expect(screen.getByTestId('switch-header-pinned')).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('renders switch checked when headerPinned is true', () => {
|
||||
setupMocks({ headerPinned: true });
|
||||
render(<UiSettingsForm />);
|
||||
expect(screen.getByTestId('switch-header-pinned')).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
// ── onChange handlers ──────────────────────────────────────────────────────
|
||||
|
||||
describe('onChange handlers', () => {
|
||||
it('calls setTableSize when Table Size select changes', () => {
|
||||
const { setTableSize } = setupMocks();
|
||||
render(<UiSettingsForm />);
|
||||
fireEvent.change(screen.getByTestId('select-table-size'), {
|
||||
target: { value: 'large' },
|
||||
});
|
||||
expect(setTableSize).toHaveBeenCalledWith('large');
|
||||
});
|
||||
|
||||
it('does not call setTableSize when value is empty', () => {
|
||||
const { setTableSize } = setupMocks();
|
||||
render(<UiSettingsForm />);
|
||||
fireEvent.change(screen.getByTestId('select-table-size'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
expect(setTableSize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls setTimeFormat when Time format select changes', () => {
|
||||
const { setTimeFormat } = setupMocks();
|
||||
render(<UiSettingsForm />);
|
||||
fireEvent.change(screen.getByTestId('select-time-format'), {
|
||||
target: { value: '24h' },
|
||||
});
|
||||
expect(setTimeFormat).toHaveBeenCalledWith('24h');
|
||||
});
|
||||
|
||||
it('does not call setTimeFormat when value is empty', () => {
|
||||
const { setTimeFormat } = setupMocks();
|
||||
render(<UiSettingsForm />);
|
||||
fireEvent.change(screen.getByTestId('select-time-format'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
expect(setTimeFormat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls setDateFormat when Date format select changes', () => {
|
||||
const { setDateFormat } = setupMocks();
|
||||
render(<UiSettingsForm />);
|
||||
fireEvent.change(screen.getByTestId('select-date-format'), {
|
||||
target: { value: 'dmy' },
|
||||
});
|
||||
expect(setDateFormat).toHaveBeenCalledWith('dmy');
|
||||
});
|
||||
|
||||
it('does not call setDateFormat when value is empty', () => {
|
||||
const { setDateFormat } = setupMocks();
|
||||
render(<UiSettingsForm />);
|
||||
fireEvent.change(screen.getByTestId('select-date-format'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
expect(setDateFormat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls setTimeZone and saveTimeZoneSetting when Time zone select changes', async () => {
|
||||
const { setTimeZone } = setupMocks();
|
||||
render(<UiSettingsForm />);
|
||||
fireEvent.change(screen.getByTestId('select-time-zone'), {
|
||||
target: { value: 'UTC' },
|
||||
});
|
||||
expect(setTimeZone).toHaveBeenCalledWith('UTC');
|
||||
await waitFor(() => {
|
||||
expect(saveTimeZoneSetting).toHaveBeenCalledWith('UTC', makeSettings());
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call setTimeZone when value is empty', () => {
|
||||
const { setTimeZone } = setupMocks();
|
||||
render(<UiSettingsForm />);
|
||||
fireEvent.change(screen.getByTestId('select-time-zone'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
expect(setTimeZone).not.toHaveBeenCalled();
|
||||
// Only called during initial sync, not on empty change
|
||||
expect(saveTimeZoneSetting).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls setHeaderPinned when switch is toggled on', () => {
|
||||
const { setHeaderPinned } = setupMocks({ headerPinned: false });
|
||||
render(<UiSettingsForm />);
|
||||
fireEvent.click(screen.getByTestId('switch-header-pinned'));
|
||||
expect(setHeaderPinned).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('calls setHeaderPinned when switch is toggled off', () => {
|
||||
const { setHeaderPinned } = setupMocks({ headerPinned: true });
|
||||
render(<UiSettingsForm />);
|
||||
fireEvent.click(screen.getByTestId('switch-header-pinned'));
|
||||
expect(setHeaderPinned).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Time zone sync from settings ───────────────────────────────────────────
|
||||
|
||||
describe('time zone sync from settings', () => {
|
||||
it('calls setTimeZone with system time_zone on mount when settings has tz', () => {
|
||||
const { setTimeZone } = setupMocks({
|
||||
settings: makeSettings({ timeZone: 'America/Chicago' }),
|
||||
timeZone: DEFAULT_TZ,
|
||||
});
|
||||
render(<UiSettingsForm />);
|
||||
expect(setTimeZone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not change timeZone when settings tz matches current tz', () => {
|
||||
const { setTimeZone } = setupMocks({
|
||||
settings: makeSettings({ timeZone: DEFAULT_TZ }),
|
||||
timeZone: DEFAULT_TZ,
|
||||
});
|
||||
render(<UiSettingsForm />);
|
||||
// setTimeZone is called with a function that returns prev when equal
|
||||
const callArg = setTimeZone.mock.calls[0]?.[0];
|
||||
if (typeof callArg === 'function') {
|
||||
expect(callArg(DEFAULT_TZ)).toBe(DEFAULT_TZ);
|
||||
}
|
||||
});
|
||||
|
||||
it('calls persistTimeZoneSetting (saveTimeZoneSetting) when no tz in settings and timeZone is set', async () => {
|
||||
setupMocks({
|
||||
settings: makeSettings({ timeZone: null }),
|
||||
timeZone: DEFAULT_TZ,
|
||||
});
|
||||
render(<UiSettingsForm />);
|
||||
await waitFor(() => {
|
||||
expect(saveTimeZoneSetting).toHaveBeenCalledWith(DEFAULT_TZ, makeSettings({ timeZone: null }));
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call saveTimeZoneSetting when settings is null', async () => {
|
||||
setupMocks({ settings: null, timeZone: DEFAULT_TZ });
|
||||
render(<UiSettingsForm />);
|
||||
await waitFor(() => {
|
||||
expect(saveTimeZoneSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call saveTimeZoneSetting when timeZone is falsy and no tz in settings', async () => {
|
||||
setupMocks({
|
||||
settings: makeSettings({ timeZone: null }),
|
||||
timeZone: '',
|
||||
});
|
||||
render(<UiSettingsForm />);
|
||||
await waitFor(() => {
|
||||
expect(saveTimeZoneSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error handling ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('error handling', () => {
|
||||
it('shows error notification when saveTimeZoneSetting throws on tz change', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
setupMocks({ settings: makeSettings({ timeZone: null }), timeZone: '' });
|
||||
vi.mocked(saveTimeZoneSetting).mockRejectedValue(new Error('network error'));
|
||||
|
||||
render(<UiSettingsForm />);
|
||||
fireEvent.change(screen.getByTestId('select-time-zone'), {
|
||||
target: { value: 'UTC' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ color: 'red', title: 'Failed to update time zone' })
|
||||
);
|
||||
});
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs error when saveTimeZoneSetting throws on tz change', async () => {
|
||||
const error = new Error('network error');
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
setupMocks({ settings: makeSettings({ timeZone: null }), timeZone: '' });
|
||||
vi.mocked(saveTimeZoneSetting).mockRejectedValue(error);
|
||||
|
||||
render(<UiSettingsForm />);
|
||||
fireEvent.change(screen.getByTestId('select-time-zone'), {
|
||||
target: { value: 'UTC' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to persist time zone setting',
|
||||
error
|
||||
);
|
||||
});
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('shows error notification when saveTimeZoneSetting throws during initial sync', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
setupMocks({ settings: makeSettings({ timeZone: null }), timeZone: DEFAULT_TZ });
|
||||
vi.mocked(saveTimeZoneSetting).mockRejectedValue(new Error('sync failed'));
|
||||
|
||||
render(<UiSettingsForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ color: 'red', title: 'Failed to update time zone' })
|
||||
);
|
||||
});
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildTimeZoneOptions ───────────────────────────────────────────────────
|
||||
|
||||
describe('buildTimeZoneOptions', () => {
|
||||
it('calls buildTimeZoneOptions with current timeZone value', () => {
|
||||
setupMocks({ timeZone: 'America/Chicago' });
|
||||
render(<UiSettingsForm />);
|
||||
expect(buildTimeZoneOptions).toHaveBeenCalledWith('America/Chicago');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue