diff --git a/frontend/src/components/forms/settings/__tests__/DvrSettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/DvrSettingsForm.test.jsx
new file mode 100644
index 00000000..ad86f1e0
--- /dev/null
+++ b/frontend/src/components/forms/settings/__tests__/DvrSettingsForm.test.jsx
@@ -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 }) =>
{title}
,
+ Button: ({ children, onClick, disabled, type, variant }) => (
+
+ ),
+ FileInput: ({ placeholder, onChange, disabled }) => (
+ {
+ const file = e.target.files?.[0] ?? null;
+ onChange?.(file);
+ }}
+ />
+ ),
+ Flex: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ NumberInput: ({ label, id, name, ...rest }) => (
+ rest.onChange?.(Number(e.target.value))}
+ />
+ ),
+ Stack: ({ children }) => {children}
,
+ Switch: ({ label, id, checked, onChange }) => (
+
+ ),
+ Text: ({ children }) => {children},
+ TextInput: ({ label, id, name, placeholder, ...rest }) => (
+ 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();
+ await waitFor(() => {
+ expect(screen.getByTestId('comskip_enabled')).toBeInTheDocument();
+ });
+ });
+
+ it('renders the comskip enabled switch', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByTestId('comskip_enabled')).toBeInTheDocument();
+ });
+ });
+
+ it('renders the comskip custom path text input', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByTestId('comskip_custom_path')).toBeInTheDocument();
+ });
+ });
+
+ it('renders the file input for comskip.ini upload', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByTestId('file-input')).toBeInTheDocument();
+ });
+ });
+
+ it('renders the Upload comskip.ini button', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('Upload comskip.ini')).toBeInTheDocument();
+ });
+ });
+
+ it('renders the pre_offset_minutes number input', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByTestId('pre_offset_minutes')).toBeInTheDocument();
+ });
+ });
+
+ it('renders the post_offset_minutes number input', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByTestId('post_offset_minutes')).toBeInTheDocument();
+ });
+ });
+
+ it('renders all template text inputs', async () => {
+ render();
+ 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();
+ await waitFor(() => {
+ expect(screen.getByText('Save')).toBeInTheDocument();
+ });
+ });
+
+ it('does not show success alert on initial render', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
+ });
+ });
+
+ it('shows "No custom comskip.ini uploaded." when no config exists', async () => {
+ render();
+ 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();
+ await waitFor(() => {
+ expect(screen.getByText('Using /app/docker/comskip.ini')).toBeInTheDocument();
+ });
+ });
+ });
+
+ // ── Initialization ─────────────────────────────────────────────────────────
+ describe('initialization', () => {
+ it('calls getDvrSettingsFormInitialValues on mount', async () => {
+ render();
+ await waitFor(() => {
+ expect(getDvrSettingsFormInitialValues).toHaveBeenCalled();
+ });
+ });
+
+ it('calls parseSettings with settings on mount', async () => {
+ render();
+ await waitFor(() => {
+ expect(parseSettings).toHaveBeenCalledWith(makeSettings());
+ expect(formMock.setValues).toHaveBeenCalledWith(mockFormValues);
+ });
+ });
+
+ it('calls getComskipConfig on mount', async () => {
+ render();
+ 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();
+ 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();
+ 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();
+ 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();
+
+ fireEvent.submit(screen.getByText('Save').closest('form'));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
+ });
+
+ rerender();
+
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ await waitFor(() => screen.getByTestId('file-input'));
+ expect(uploadComskipIni).not.toHaveBeenCalled();
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx b/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx
new file mode 100644
index 00000000..32271d0b
--- /dev/null
+++ b/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx
@@ -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 ? (
+
+
{title}
+
{message}
+
+
+
+ ) : null,
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Alert: ({ title, children }) => (
+
+ {title}
+ {children}
+
+ ),
+ Button: ({ children, onClick, disabled, loading, type, variant }) => (
+
+ ),
+ Flex: ({ children }) => {children}
,
+ Stack: ({ children }) => {children}
,
+ Text: ({ children }) => {children},
+ TextInput: ({ label, id, placeholder, error, ...rest }) => (
+
+ rest.onChange?.(e)}
+ value={rest.value ?? ''}
+ />
+ {error && {error}}
+
+ ),
+}));
+
+// ──────────────────────────────────────────────────────────────────────────────
+// 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();
+ expect(screen.getByText('Save')).toBeInTheDocument();
+ });
+
+ it('renders the Save button', () => {
+ render();
+ expect(screen.getByText('Save')).toBeInTheDocument();
+ });
+
+ it('does not show success alert on initial render', () => {
+ render();
+ expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
+ });
+
+ it('does not show confirmation dialog on initial render', () => {
+ render();
+ expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
+ });
+
+ it('does not show error alert on initial render', () => {
+ render();
+ expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Initialization ─────────────────────────────────────────────────────────
+ describe('initialization', () => {
+ it('calls getNetworkAccessFormInitialValues on mount', () => {
+ render();
+ expect(getNetworkAccessFormInitialValues).toHaveBeenCalled();
+ });
+
+ it('calls getNetworkAccessFormValidation on mount', () => {
+ render();
+ expect(getNetworkAccessFormValidation).toHaveBeenCalled();
+ });
+
+ it('does not call checkSetting on mount', () => {
+ render();
+ expect(checkSetting).not.toHaveBeenCalled();
+ });
+
+ it('calls checkSetting when form is submitted', async () => {
+ render();
+ fireEvent.click(screen.getByText('Save'));
+ await waitFor(() => {
+ expect(checkSetting).toHaveBeenCalled();
+ });
+ });
+
+ it('sets form values from network_access settings on mount', async () => {
+ render();
+ await waitFor(() => {
+ expect(formMock.setValues).toHaveBeenCalled();
+ });
+ });
+
+ it('handles missing network_access setting gracefully', async () => {
+ ({ formMock } = setupMocks({ settings: {} }));
+ expect(() => render()).not.toThrow();
+ });
+
+ it('handles checkSetting error gracefully', async () => {
+ vi.mocked(checkSetting).mockRejectedValue(new Error('network error'));
+ render();
+ });
+ });
+
+ // ── active prop ────────────────────────────────────────────────────────────
+ describe('active prop', () => {
+ it('resets saved state when active becomes false', async () => {
+ vi.mocked(updateSetting).mockResolvedValue(undefined);
+ const { rerender } = render();
+
+ fireEvent.submit(screen.getByText('Save').closest('form') ?? screen.getByText('Save').closest('div'));
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('confirmation-dialog')).not.toBeNull();
+ }).catch(() => {});
+
+ rerender();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ // ── Form submission ────────────────────────────────────────────────────────
+ describe('form submission', () => {
+ it('opens confirmation dialog on Save click', async () => {
+ render();
+ fireEvent.click(screen.getByText('Save'));
+ await waitFor(() => {
+ expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
+ });
+ });
+
+ it('closes confirmation dialog on Cancel click', async () => {
+ render();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+
+ // 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');
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx
new file mode 100644
index 00000000..193f528c
--- /dev/null
+++ b/frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx
@@ -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 }) => (
+
+ {title}
+ {children}
+
+ ),
+ Button: ({ children, onClick, disabled, loading, type, variant, color }) => (
+
+ ),
+ Flex: ({ children }) => {children}
,
+ NumberInput: ({ label, description, min, max, step, ...rest }) => (
+
+
+ {description && {description}}
+ rest.onChange?.(Number(e.target.value))}
+ value={rest.value ?? 0}
+ />
+
+ ),
+ Stack: ({ children }) => {children}
,
+ TextInput: ({ label, description, ...rest }) => (
+
+
+ {description && {description}}
+ rest.onChange?.(e)}
+ value={rest.value ?? ''}
+ />
+
+ ),
+}));
+
+// ──────────────────────────────────────────────────────────────────────────────
+// 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();
+ expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
+ });
+
+ it('renders NumberInput for buffering_timeout', () => {
+ render();
+ expect(screen.getByTestId('number-input-Buffering Timeout')).toBeInTheDocument();
+ });
+
+ it('renders NumberInput for buffering_speed', () => {
+ render();
+ expect(screen.getByTestId('number-input-Buffering Speed')).toBeInTheDocument();
+ });
+
+ it('renders TextInput for redis_url', () => {
+ render();
+ expect(screen.getByTestId('text-input-Redis URL')).toBeInTheDocument();
+ });
+
+ it('renders description text for fields that have one', () => {
+ render();
+ expect(screen.getByTestId('desc-Buffering Timeout')).toBeInTheDocument();
+ });
+ });
+
+ // ── Initialization ─────────────────────────────────────────────────────────
+ describe('initialization', () => {
+ it('calls getProxySettingsFormInitialValues on mount', () => {
+ render();
+ expect(getProxySettingsFormInitialValues).toHaveBeenCalled();
+ });
+
+ it('calls setValues with merged defaults and stored settings on mount', async () => {
+ render();
+ 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();
+ 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();
+ await waitFor(() => {
+ expect(formMock.setValues).not.toHaveBeenCalled();
+ });
+ });
+
+ it('handles null settings gracefully', () => {
+ vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings: null }));
+ expect(() => render()).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();
+
+ // Trigger a successful save
+ fireEvent.submit(screen.getByText('Save').closest('form'));
+ await waitFor(() => {
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
+ });
+
+ rerender();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ // ── Form submission ────────────────────────────────────────────────────────
+ describe('form submission', () => {
+ it('calls updateSetting with proxy_settings on Save', async () => {
+ render();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+
+ // 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();
+ fireEvent.click(screen.getByText('Reset to Defaults'));
+ expect(getProxySettingDefaults).toHaveBeenCalled();
+ });
+
+ it('calls form.setValues with defaults when Reset to Defaults is clicked', () => {
+ render();
+ 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();
+ 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();
+ expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_timeout');
+ expect(formMock.getInputProps).toHaveBeenCalledWith('buffering_speed');
+ expect(formMock.getInputProps).toHaveBeenCalledWith('redis_url');
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/settings/__tests__/StreamSettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/StreamSettingsForm.test.jsx
new file mode 100644
index 00000000..c0742c8e
--- /dev/null
+++ b/frontend/src/components/forms/settings/__tests__/StreamSettingsForm.test.jsx
@@ -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 ? (
+
+
{title}
+
{message}
+
+
+ {actionKey && (
+
+ )}
+
+ ) : null,
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Alert: ({ title, children }) => (
+
+ {title}
+ {children}
+
+ ),
+ Button: ({ children, onClick, disabled, loading, type, variant, color }) => (
+
+ ),
+ Flex: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ MultiSelect: ({ label, id, data, ...rest }) => (
+
+
+
+
+ ),
+ Select: ({ label, id, data, ...rest }) => (
+
+
+
+
+ ),
+ Switch: ({ id, ...rest }) => (
+ rest.onChange?.(e)}
+ />
+ ),
+ Text: ({ children, size, fw }) => (
+
+ {children}
+
+ ),
+}));
+
+// ──────────────────────────────────────────────────────────────────────────────
+// 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();
+ expect(screen.getByText('Save')).toBeInTheDocument();
+ });
+
+ it('renders the Save button', () => {
+ render();
+ expect(screen.getByText('Save')).toBeInTheDocument();
+ });
+
+ it('renders the Rehash Streams button', () => {
+ render();
+ expect(screen.getByText('Rehash Streams')).toBeInTheDocument();
+ });
+
+ it('renders the Default User Agent select', () => {
+ render();
+ expect(screen.getByTestId('default_user_agent')).toBeInTheDocument();
+ });
+
+ it('renders the Default Stream Profile select', () => {
+ render();
+ expect(screen.getByTestId('default_stream_profile')).toBeInTheDocument();
+ });
+
+ it('renders the Preferred Region select', () => {
+ render();
+ expect(screen.getByTestId('preferred_region')).toBeInTheDocument();
+ });
+
+ it('renders the Auto-Import Mapped Files switch', () => {
+ render();
+ expect(screen.getByTestId('auto_import_mapped_files')).toBeInTheDocument();
+ });
+
+ it('renders the M3U Hash Key multiselect', () => {
+ render();
+ expect(screen.getByTestId('m3u_hash_key')).toBeInTheDocument();
+ });
+
+ it('populates user agent options from store', () => {
+ render();
+ expect(screen.getByText('Chrome')).toBeInTheDocument();
+ expect(screen.getByText('Firefox')).toBeInTheDocument();
+ });
+
+ it('populates stream profile options from store', () => {
+ render();
+ expect(screen.getByText('Default')).toBeInTheDocument();
+ expect(screen.getByText('HLS')).toBeInTheDocument();
+ });
+
+ it('populates region options from REGION_CHOICES', () => {
+ render();
+ expect(screen.getByText('US')).toBeInTheDocument();
+ expect(screen.getByText('EU')).toBeInTheDocument();
+ });
+
+ it('does not show success alert on initial render', () => {
+ render();
+ expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
+ });
+
+ it('does not show confirmation dialog on initial render', () => {
+ render();
+ expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Initialization ─────────────────────────────────────────────────────────
+ describe('initialization', () => {
+ it('calls getStreamSettingsFormInitialValues on mount', () => {
+ render();
+ expect(getStreamSettingsFormInitialValues).toHaveBeenCalled();
+ });
+
+ it('calls getStreamSettingsFormValidation on mount', () => {
+ render();
+ expect(getStreamSettingsFormValidation).toHaveBeenCalled();
+ });
+
+ it('calls parseSettings with settings from store on mount', async () => {
+ const settings = makeSettings();
+ setupMocks({ settings });
+ render();
+ await waitFor(() => {
+ expect(parseSettings).toHaveBeenCalledWith(settings);
+ });
+ });
+
+ it('calls form.setValues with parsed settings on mount', async () => {
+ render();
+ 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();
+ 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();
+
+ fireEvent.submit(screen.getByText('Save').closest('form'));
+ await waitFor(() => {
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
+ });
+
+ rerender();
+ await waitFor(() => {
+ expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
+ });
+ });
+
+ it('clears rehashSuccess when active becomes false', async () => {
+ ({ formMock } = setupMocks({ warningSuppressed: true }));
+ const { rerender } = render();
+
+ fireEvent.click(screen.getByText('Rehash Streams'));
+ await waitFor(() => {
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
+ });
+
+ rerender();
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ expect(formMock.getInputProps).toHaveBeenCalledWith('default_user_agent');
+ });
+
+ it('calls getInputProps for default_stream_profile', () => {
+ render();
+ expect(formMock.getInputProps).toHaveBeenCalledWith('default_stream_profile');
+ });
+
+ it('calls getInputProps for preferred_region', () => {
+ render();
+ expect(formMock.getInputProps).toHaveBeenCalledWith('preferred_region');
+ });
+
+ it('calls getInputProps for auto_import_mapped_files with checkbox type', () => {
+ render();
+ expect(formMock.getInputProps).toHaveBeenCalledWith(
+ 'auto_import_mapped_files',
+ { type: 'checkbox' }
+ );
+ });
+
+ it('calls getInputProps for m3u_hash_key', () => {
+ render();
+ expect(formMock.getInputProps).toHaveBeenCalledWith('m3u_hash_key');
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/settings/__tests__/SystemSettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/SystemSettingsForm.test.jsx
new file mode 100644
index 00000000..b9baa711
--- /dev/null
+++ b/frontend/src/components/forms/settings/__tests__/SystemSettingsForm.test.jsx
@@ -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 }) => {title}
,
+ Button: ({ children, onClick, disabled }) => (
+
+ ),
+ Flex: ({ children }) => {children}
,
+ NumberInput: ({ label, value, onChange, min, max, step, description }) => (
+
+
+
{description}
+
onChange(Number(e.target.value))}
+ />
+
+ ),
+ Stack: ({ children }) => {children}
,
+ Text: ({ children }) => {children},
+}));
+
+// ──────────────────────────────────────────────────────────────────────────────
+// 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();
+ expect(screen.getByText('Save')).toBeInTheDocument();
+ });
+
+ it('renders the NumberInput for max_system_events', () => {
+ setupMocks();
+ render();
+ expect(screen.getByTestId('number-input')).toBeInTheDocument();
+ });
+
+ it('renders the NumberInput label', () => {
+ setupMocks();
+ render();
+ expect(screen.getByText('Maximum System Events')).toBeInTheDocument();
+ });
+
+ it('renders the NumberInput description', () => {
+ setupMocks();
+ render();
+ expect(
+ screen.getByText('Number of events to retain (minimum: 10, maximum: 1000)')
+ ).toBeInTheDocument();
+ });
+
+ it('renders descriptive text about system events', () => {
+ setupMocks();
+ render();
+ expect(
+ screen.getByText(/Configure how many system events/)
+ ).toBeInTheDocument();
+ });
+
+ it('does not show success alert on initial render', () => {
+ setupMocks();
+ render();
+ expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
+ });
+
+ it('renders NumberInput with value from form values', () => {
+ setupMocks({ settings: makeSettings({ max_system_events: 250 }) });
+ render();
+ 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();
+ 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();
+ expect(parseSettings).toHaveBeenCalledWith(settings);
+ });
+
+ it('calls form.setValues with parsed settings on mount', () => {
+ const settings = makeSettings();
+ const { formMock } = setupMocks({ settings });
+ render();
+ 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();
+ expect(parseSettings).not.toHaveBeenCalled();
+ });
+ });
+
+ // ── NumberInput interaction ────────────────────────────────────────────────
+
+ describe('NumberInput interaction', () => {
+ it('calls form.setFieldValue when NumberInput changes', () => {
+ const { formMock } = setupMocks();
+ render();
+ 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();
+
+ 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();
+
+ 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();
+ 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();
+ 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();
+
+ fireEvent.click(screen.getByText('Save'));
+ await waitFor(() => {
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
+ });
+
+ rerender();
+ await waitFor(() => {
+ expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
+ });
+ });
+
+ it('does not clear saved alert while active remains true', async () => {
+ setupMocks();
+ const { rerender } = render();
+
+ fireEvent.click(screen.getByText('Save'));
+ await waitFor(() => {
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
+ });
+
+ rerender();
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/settings/__tests__/UiSettingsForm.test.jsx b/frontend/src/components/forms/settings/__tests__/UiSettingsForm.test.jsx
new file mode 100644
index 00000000..b8c0b5ea
--- /dev/null
+++ b/frontend/src/components/forms/settings/__tests__/UiSettingsForm.test.jsx
@@ -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 }) => (
+
+
+
+
+ ),
+ Switch: ({ label, description, checked, onChange }) => (
+
+
+ {description &&
{description}
}
+
+
+ ),
+ Stack: ({ children }) => {children}
,
+}));
+
+// ──────────────────────────────────────────────────────────────────────────────
+// 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();
+ expect(screen.getByTestId('select-table-size')).toBeInTheDocument();
+ });
+
+ it('renders the Time format select', () => {
+ setupMocks();
+ render();
+ expect(screen.getByTestId('select-time-format')).toBeInTheDocument();
+ });
+
+ it('renders the Date format select', () => {
+ setupMocks();
+ render();
+ expect(screen.getByTestId('select-date-format')).toBeInTheDocument();
+ });
+
+ it('renders the Time zone select', () => {
+ setupMocks();
+ render();
+ expect(screen.getByTestId('select-time-zone')).toBeInTheDocument();
+ });
+
+ it('renders the Pin Table Headers switch', () => {
+ setupMocks();
+ render();
+ expect(screen.getByTestId('switch-header-pinned')).toBeInTheDocument();
+ });
+
+ it('renders the switch description', () => {
+ setupMocks();
+ render();
+ expect(screen.getByText('Keep table headers visible when scrolling')).toBeInTheDocument();
+ });
+
+ it('renders Table Size select with initial value', () => {
+ setupMocks({ tableSize: 'compact' });
+ render();
+ expect(screen.getByTestId('select-table-size')).toHaveValue('compact');
+ });
+
+ it('renders Time format select with initial value', () => {
+ setupMocks({ timeFormat: '24h' });
+ render();
+ expect(screen.getByTestId('select-time-format')).toHaveValue('24h');
+ });
+
+ it('renders Date format select with initial value', () => {
+ setupMocks({ dateFormat: 'dmy' });
+ render();
+ expect(screen.getByTestId('select-date-format')).toHaveValue('dmy');
+ });
+
+ it('renders Time zone select with initial value', () => {
+ setupMocks({ timeZone: 'UTC' });
+ render();
+ expect(screen.getByTestId('select-time-zone')).toHaveValue('UTC');
+ });
+
+ it('renders switch unchecked when headerPinned is false', () => {
+ setupMocks({ headerPinned: false });
+ render();
+ expect(screen.getByTestId('switch-header-pinned')).not.toBeChecked();
+ });
+
+ it('renders switch checked when headerPinned is true', () => {
+ setupMocks({ headerPinned: true });
+ render();
+ expect(screen.getByTestId('switch-header-pinned')).toBeChecked();
+ });
+ });
+
+ // ── onChange handlers ──────────────────────────────────────────────────────
+
+ describe('onChange handlers', () => {
+ it('calls setTableSize when Table Size select changes', () => {
+ const { setTableSize } = setupMocks();
+ render();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ // 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+
+ 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();
+ expect(buildTimeZoneOptions).toHaveBeenCalledWith('America/Chicago');
+ });
+ });
+});
\ No newline at end of file