- {Array.from({ length: itemCount }, (_, index) =>
- children({ index, style: {} })
- )}
+vi.mock('../AutoSyncBasic.jsx', () => ({
+ default: () =>
,
+}));
+
+vi.mock('../../ErrorBoundary.jsx', () => ({
+ default: ({ children }) => <>{children}>,
+}));
+
+vi.mock('../AutoSyncAdvanced.jsx', () => ({
+ default: ({
+ group,
+ onApplyGroupChange,
+ onScheduleRegexPreview,
+ onOpenLogoUpload,
+ }) => (
+
,
+vi.mock('../Logo.jsx', () => ({
+ default: ({ isOpen, onClose, onSuccess }) =>
+ isOpen ? (
+
+ ) : null,
}));
-// ── @mantine/core minimal mocks ────────────────────────────────────────────────
-vi.mock('@mantine/core', () => ({
- TextInput: ({ value, onChange, placeholder }) => (
-
+// ── Utility mocks ─────────────────────────────────────────────────────────────
+vi.mock('../../../utils/forms/LiveGroupFilterUtils.js', () => ({
+ abortTimers: vi.fn(),
+ computeAutoSyncStart: vi.fn(() => 101),
+ getChannelsInRange: vi.fn().mockResolvedValue({ occupants: [] }),
+ getEPGs: vi.fn().mockResolvedValue([]),
+ getRegexOptions: vi.fn((find, replace, match, exclude) => ({
+ find,
+ replace,
+ match,
+ exclude,
+ })),
+ getStreamsRegexPreview: vi.fn().mockResolvedValue(null),
+ isExpectedOccupantForGroup: vi.fn(() => true),
+ isGroupVisible: vi.fn((group, filter, status) => {
+ if (status === 'enabled') return group.enabled;
+ if (status === 'disabled') return !group.enabled;
+ if (filter) return group.name.toLowerCase().includes(filter.toLowerCase());
+ return true;
+ }),
+ rangeFor: vi.fn(() => null),
+}));
+
+// ── Mantine core mock ─────────────────────────────────────────────────────────
+vi.mock('@mantine/core', async () => ({
+ ActionIcon: ({ children, onClick, disabled }) => (
+
+ ),
+ Checkbox: ({ label, checked, onChange, description, disabled }) => (
- ),
- Tooltip: ({ children }) => <>{children}>,
- Popover: ({ children }) =>
- {data.map((opt) => (
+ Loader: () =>
,
+ SegmentedControl: ({ onChange, data }) => (
+
+ {(data || []).map((item) => (
))}
),
- ActionIcon: ({ children, onClick }) =>
,
- Switch: ({ label, checked, onChange }) => (
-
+ SimpleGrid: ({ children }) =>
{children}
,
+ Stack: ({ children, style }) =>
{children}
,
+ Text: ({ children }) =>
{children},
+ TextInput: ({ placeholder, value, onChange }) => (
+
),
+ Tooltip: ({ children }) => <>{children}>,
}));
-// ── Imports after mocks ────────────────────────────────────────────────────────
+// ── lucide-react mock ─────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ CircleCheck: () =>
,
+ CircleX: () =>
,
+ Info: () =>
,
+ Settings: () =>
,
+}));
+
+// ── Imports after mocks ───────────────────────────────────────────────────────
import LiveGroupFilter from '../LiveGroupFilter';
-import API from '../../../api';
-import { notifications } from '@mantine/notifications';
import useChannelsStore from '../../../store/channels';
import useStreamProfilesStore from '../../../store/streamProfiles';
+import { useChannelLogoSelection } from '../../../hooks/useSmartLogos';
+import {
+ getEPGs,
+ getRegexOptions,
+ getStreamsRegexPreview,
+ isGroupVisible,
+} from '../../../utils/forms/LiveGroupFilterUtils.js';
-// ──────────────────────────────────────────────────────────────────────────────
-
-const makePlaylist = (overrides = {}) => ({
- id: 7,
- channel_groups: [],
- custom_properties: null,
+// ── Factories ─────────────────────────────────────────────────────────────────
+const makeGroup = (overrides = {}) => ({
+ channel_group: 1,
+ name: 'Sports',
+ enabled: true,
+ auto_channel_sync: true,
+ auto_sync_channel_start: 1,
+ auto_sync_channel_end: null,
+ custom_properties: {},
+ is_stale: false,
+ original_enabled: true,
...overrides,
});
-const setupStores = () => {
- vi.mocked(useChannelsStore).mockImplementation((sel) =>
- sel({ channelGroups: {}, profiles: [] })
- );
- vi.mocked(useStreamProfilesStore).mockImplementation((sel) =>
- sel({ profiles: [], fetchProfiles: vi.fn() })
+const makePlaylist = (overrides = {}) => ({
+ id: 1,
+ name: 'Test Playlist',
+ channel_groups: [],
+ ...overrides,
+});
+
+// ── Stateful wrapper ──────────────────────────────────────────────────────────
+const Wrapper = ({
+ initialGroups = [],
+ initialAutoEnable = true,
+ playlist,
+}) => {
+ const [groupStates, setGroupStates] = useState(initialGroups);
+ const [autoEnable, setAutoEnable] = useState(initialAutoEnable);
+ return (
+
);
};
-const renderFilter = (playlistOverrides = {}) =>
- render(
-
- );
+// ── Test setup helpers ────────────────────────────────────────────────────────
+describe('LiveGroupFilter', () => {
+ let mockEnsureLogosLoaded;
+ let mockFetchProfiles;
-// The orphan-cleanup SegmentedControl shares its mocked testid with the
-// status-filter SegmentedControl that lives elsewhere in the form, so
-// disambiguate by walking from a uniquely-testided child button up to
-// its parent SegmentedControl element.
-const findCleanupControl = () =>
- screen.getByTestId('segmented-always').closest('[data-testid="segmented-control"]');
-
-describe('LiveGroupFilter orphan-cleanup SegmentedControl', () => {
beforeEach(() => {
vi.clearAllMocks();
- setupStores();
- });
+ mockEnsureLogosLoaded = vi.fn();
+ mockFetchProfiles = vi.fn();
- it('defaults to "always" when custom_properties is null', () => {
- renderFilter({ custom_properties: null });
- expect(findCleanupControl()).toHaveAttribute('data-value', 'always');
- });
-
- it('defaults to "always" when the orphan_channel_cleanup key is absent', () => {
- renderFilter({ custom_properties: { compact_numbering: true } });
- expect(findCleanupControl()).toHaveAttribute('data-value', 'always');
- });
-
- it('reads the persisted mode from custom_properties on mount', () => {
- renderFilter({
- custom_properties: { orphan_channel_cleanup: 'preserve_customized' },
- });
- expect(findCleanupControl()).toHaveAttribute(
- 'data-value',
- 'preserve_customized'
+ vi.mocked(useChannelsStore).mockImplementation((sel) =>
+ sel({ channelGroups: {} })
);
+ vi.mocked(useStreamProfilesStore).mockImplementation((sel) =>
+ sel({ profiles: [], fetchProfiles: mockFetchProfiles })
+ );
+ vi.mocked(useChannelLogoSelection).mockReturnValue({
+ logos: [],
+ ensureLogosLoaded: mockEnsureLogosLoaded,
+ isLoading: false,
+ });
+ vi.mocked(getEPGs).mockResolvedValue([]);
});
- it('PATCHes the playlist with merged custom_properties on click and updates the displayed value', async () => {
- renderFilter({
- custom_properties: { compact_numbering: true },
- });
- fireEvent.click(screen.getByTestId('segmented-never'));
-
- await waitFor(() => {
- expect(API.updatePlaylist).toHaveBeenCalledWith({
- id: 7,
- custom_properties: {
- compact_numbering: true,
- orphan_channel_cleanup: 'never',
- },
+ const renderWith = ({
+ initialGroups = [],
+ initialAutoEnable = true,
+ playlist,
+ channelGroups,
+ streamProfiles,
+ fetchProfiles,
+ ensureLogosLoaded,
+ } = {}) => {
+ if (channelGroups !== undefined) {
+ vi.mocked(useChannelsStore).mockImplementation((sel) =>
+ sel({ channelGroups })
+ );
+ }
+ if (streamProfiles !== undefined || fetchProfiles !== undefined) {
+ vi.mocked(useStreamProfilesStore).mockImplementation((sel) =>
+ sel({
+ profiles: streamProfiles ?? [],
+ fetchProfiles: fetchProfiles ?? mockFetchProfiles,
+ })
+ );
+ }
+ if (ensureLogosLoaded !== undefined) {
+ vi.mocked(useChannelLogoSelection).mockReturnValue({
+ logos: [],
+ ensureLogosLoaded,
+ isLoading: false,
});
- });
- expect(findCleanupControl()).toHaveAttribute('data-value', 'never');
- });
+ }
- it('reverts to the previous mode and surfaces an error toast when the PATCH fails', async () => {
- vi.mocked(API.updatePlaylist).mockRejectedValueOnce(
- new Error('Server error')
+ return render(
+
);
- renderFilter({
- custom_properties: { orphan_channel_cleanup: 'always' },
- });
- fireEvent.click(screen.getByTestId('segmented-never'));
+ };
- await waitFor(() => {
- expect(notifications.show).toHaveBeenCalledWith(
- expect.objectContaining({ color: 'red' })
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the info alert', () => {
+ renderWith();
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
+ expect(screen.getByText(/Auto Channel Sync/i)).toBeInTheDocument();
+ });
+
+ it('renders the auto-enable new groups checkbox', () => {
+ renderWith();
+ expect(
+ screen.getByRole('checkbox', {
+ name: /Automatically enable new groups/i,
+ })
+ ).toBeInTheDocument();
+ });
+
+ it('reflects initialAutoEnable=true on the checkbox', () => {
+ renderWith({ initialAutoEnable: true });
+ expect(
+ screen.getByRole('checkbox', {
+ name: /Automatically enable new groups/i,
+ })
+ ).toBeChecked();
+ });
+
+ it('reflects initialAutoEnable=false on the checkbox', () => {
+ renderWith({ initialAutoEnable: false });
+ expect(
+ screen.getByRole('checkbox', {
+ name: /Automatically enable new groups/i,
+ })
+ ).not.toBeChecked();
+ });
+
+ it('renders OrphanCleanupControl with the playlist', () => {
+ renderWith({ playlist: makePlaylist({ id: 7 }) });
+ expect(screen.getByTestId('orphan-cleanup')).toHaveAttribute(
+ 'data-playlist-id',
+ '7'
);
});
- expect(findCleanupControl()).toHaveAttribute('data-value', 'always');
+
+ it('renders the group name filter input', () => {
+ renderWith();
+ expect(screen.getByTestId('group-filter-input')).toBeInTheDocument();
+ });
+
+ it('renders All / Enabled / Disabled status filter buttons', () => {
+ renderWith();
+ expect(screen.getByTestId('seg-all')).toBeInTheDocument();
+ expect(screen.getByTestId('seg-enabled')).toBeInTheDocument();
+ expect(screen.getByTestId('seg-disabled')).toBeInTheDocument();
+ });
+
+ it('renders Select Visible and Deselect Visible buttons', () => {
+ renderWith();
+ expect(screen.getByText('Select Visible')).toBeInTheDocument();
+ expect(screen.getByText('Deselect Visible')).toBeInTheDocument();
+ });
+
+ it('renders a card for each group in groupStates', () => {
+ renderWith({
+ initialGroups: [
+ makeGroup({ channel_group: 1, name: 'Sports' }),
+ makeGroup({ channel_group: 2, name: 'News' }),
+ ],
+ });
+ expect(screen.getByText('Sports')).toBeInTheDocument();
+ expect(screen.getByText('News')).toBeInTheDocument();
+ });
+
+ it('renders groups sorted alphabetically by name', () => {
+ renderWith({
+ initialGroups: [
+ makeGroup({ channel_group: 3, name: 'Sports' }),
+ makeGroup({ channel_group: 1, name: 'Arts' }),
+ makeGroup({ channel_group: 2, name: 'News' }),
+ ],
+ });
+ const names = screen
+ .getAllByText(/Arts|News|Sports/)
+ .map((el) => el.textContent);
+ expect(names).toEqual(['Arts', 'News', 'Sports']);
+ });
+
+ it('renders no group cards when groupStates is empty', () => {
+ renderWith({ initialGroups: [] });
+ expect(screen.queryByTestId('group-card')).not.toBeInTheDocument();
+ });
+
+ it('does not render the configure modal on initial render', () => {
+ renderWith({ initialGroups: [makeGroup()] });
+ expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Mount effects ──────────────────────────────────────────────────────────
+
+ describe('mount effects', () => {
+ it('calls ensureLogosLoaded on mount', () => {
+ renderWith({ ensureLogosLoaded: mockEnsureLogosLoaded });
+ expect(mockEnsureLogosLoaded).toHaveBeenCalled();
+ });
+
+ it('calls fetchStreamProfiles when profiles array is empty', () => {
+ renderWith({ streamProfiles: [], fetchProfiles: mockFetchProfiles });
+ expect(mockFetchProfiles).toHaveBeenCalled();
+ });
+
+ it('does not call fetchStreamProfiles when profiles already exist', () => {
+ renderWith({
+ streamProfiles: [{ id: 'p1', name: 'Default' }],
+ fetchProfiles: mockFetchProfiles,
+ });
+ expect(mockFetchProfiles).not.toHaveBeenCalled();
+ });
+
+ it('calls getEPGs on mount', async () => {
+ renderWith();
+ await waitFor(() => {
+ expect(getEPGs).toHaveBeenCalled();
+ });
+ });
+
+ it('initializes groupStates from playlist.channel_groups when channelGroups are loaded', async () => {
+ const channelGroups = { 10: { id: 10, name: 'Movies' } };
+ const playlist = makePlaylist({
+ id: 2,
+ channel_groups: [
+ {
+ channel_group: 10,
+ enabled: true,
+ auto_channel_sync: false,
+ auto_sync_channel_start: 1,
+ auto_sync_channel_end: null,
+ custom_properties: {},
+ },
+ ],
+ });
+ renderWith({ channelGroups, playlist, initialGroups: [] });
+ await waitFor(() => {
+ expect(screen.getByText('Movies')).toBeInTheDocument();
+ });
+ });
+
+ it('skips groupStates initialization when channelGroups is empty', () => {
+ renderWith({
+ channelGroups: {},
+ playlist: makePlaylist({
+ channel_groups: [
+ {
+ channel_group: 1,
+ enabled: true,
+ auto_channel_sync: false,
+ auto_sync_channel_start: 1,
+ },
+ ],
+ }),
+ initialGroups: [],
+ });
+ // Init effect bails early — no group cards rendered
+ expect(screen.queryByTestId('group-card')).not.toBeInTheDocument();
+ });
+
+ it('does not re-initialize groupStates for the same playlist/channelGroups key', async () => {
+ // Covers the lastInitKey guard preventing re-init on prop-reference changes
+ const channelGroups = { 1: { id: 1, name: 'Sports' } };
+ const playlist = makePlaylist({
+ id: 1,
+ channel_groups: [
+ {
+ channel_group: 1,
+ enabled: true,
+ auto_channel_sync: false,
+ auto_sync_channel_start: 1,
+ custom_properties: {},
+ },
+ ],
+ });
+ const { rerender } = renderWith({ channelGroups, playlist });
+ await waitFor(() =>
+ expect(screen.getByText('Sports')).toBeInTheDocument()
+ );
+
+ // Re-render with identical playlist reference — init must not fire again
+ vi.mocked(useChannelsStore).mockImplementation((sel) =>
+ sel({ channelGroups })
+ );
+ rerender(
);
+ // Still renders Sports; no duplicate init
+ expect(screen.getAllByText('Sports')).toHaveLength(1);
+ });
+ });
+
+ // ── autoEnableNewGroupsLive checkbox ──────────────────────────────────────
+
+ describe('autoEnableNewGroupsLive checkbox', () => {
+ it('toggles off when clicked while checked', () => {
+ renderWith({ initialAutoEnable: true });
+ const cb = screen.getByRole('checkbox', {
+ name: /Automatically enable new groups/i,
+ });
+ fireEvent.click(cb);
+ expect(cb).not.toBeChecked();
+ });
+
+ it('toggles on when clicked while unchecked', () => {
+ renderWith({ initialAutoEnable: false });
+ const cb = screen.getByRole('checkbox', {
+ name: /Automatically enable new groups/i,
+ });
+ fireEvent.click(cb);
+ expect(cb).toBeChecked();
+ });
+ });
+
+ // ── Group text filter ──────────────────────────────────────────────────────
+
+ describe('group text filter', () => {
+ it('shows groups matching the filter and hides non-matching groups', () => {
+ renderWith({
+ initialGroups: [
+ makeGroup({ channel_group: 1, name: 'Sports' }),
+ makeGroup({ channel_group: 2, name: 'News' }),
+ ],
+ });
+ fireEvent.change(screen.getByTestId('group-filter-input'), {
+ target: { value: 'sport' },
+ });
+ expect(screen.getByText('Sports')).toBeInTheDocument();
+ expect(screen.queryByText('News')).not.toBeInTheDocument();
+ });
+
+ it('shows all groups when filter is cleared', () => {
+ renderWith({
+ initialGroups: [
+ makeGroup({ channel_group: 1, name: 'Sports' }),
+ makeGroup({ channel_group: 2, name: 'News' }),
+ ],
+ });
+ fireEvent.change(screen.getByTestId('group-filter-input'), {
+ target: { value: 'sport' },
+ });
+ fireEvent.change(screen.getByTestId('group-filter-input'), {
+ target: { value: '' },
+ });
+ expect(screen.getByText('Sports')).toBeInTheDocument();
+ expect(screen.getByText('News')).toBeInTheDocument();
+ });
+
+ it('calls isGroupVisible with the current filter text and status', () => {
+ renderWith({
+ initialGroups: [makeGroup({ channel_group: 1, name: 'Sports' })],
+ });
+ fireEvent.change(screen.getByTestId('group-filter-input'), {
+ target: { value: 'sp' },
+ });
+ expect(isGroupVisible).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'Sports' }),
+ 'sp',
+ 'all'
+ );
+ });
+ });
+
+ // ── Status filter ──────────────────────────────────────────────────────────
+
+ describe('status filter', () => {
+ it('shows only enabled groups when Enabled filter is selected', () => {
+ renderWith({
+ initialGroups: [
+ makeGroup({ channel_group: 1, name: 'Sports', enabled: true }),
+ makeGroup({ channel_group: 2, name: 'News', enabled: false }),
+ ],
+ });
+ fireEvent.click(screen.getByTestId('seg-enabled'));
+ expect(screen.getByText('Sports')).toBeInTheDocument();
+ expect(screen.queryByText('News')).not.toBeInTheDocument();
+ });
+
+ it('shows only disabled groups when Disabled filter is selected', () => {
+ renderWith({
+ initialGroups: [
+ makeGroup({ channel_group: 1, name: 'Sports', enabled: true }),
+ makeGroup({ channel_group: 2, name: 'News', enabled: false }),
+ ],
+ });
+ fireEvent.click(screen.getByTestId('seg-disabled'));
+ expect(screen.queryByText('Sports')).not.toBeInTheDocument();
+ expect(screen.getByText('News')).toBeInTheDocument();
+ });
+
+ it('passes the active status value to isGroupVisible', () => {
+ renderWith({
+ initialGroups: [
+ makeGroup({ channel_group: 1, name: 'Sports', enabled: true }),
+ ],
+ });
+ fireEvent.click(screen.getByTestId('seg-disabled'));
+ expect(isGroupVisible).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'Sports' }),
+ '',
+ 'disabled'
+ );
+ });
+
+ it('reverts to showing all groups when All filter is selected', () => {
+ renderWith({
+ initialGroups: [
+ makeGroup({ channel_group: 1, name: 'Sports', enabled: true }),
+ makeGroup({ channel_group: 2, name: 'News', enabled: false }),
+ ],
+ });
+ fireEvent.click(screen.getByTestId('seg-enabled'));
+ fireEvent.click(screen.getByTestId('seg-all'));
+ expect(screen.getByText('Sports')).toBeInTheDocument();
+ expect(screen.getByText('News')).toBeInTheDocument();
+ });
+ });
+
+ // ── Select Visible / Deselect Visible ─────────────────────────────────────
+
+ describe('Select Visible / Deselect Visible', () => {
+ it('Select Visible enables all currently visible groups', () => {
+ renderWith({
+ initialGroups: [
+ makeGroup({ channel_group: 1, name: 'Sports', enabled: false }),
+ makeGroup({ channel_group: 2, name: 'News', enabled: false }),
+ ],
+ });
+ fireEvent.click(screen.getByText('Select Visible'));
+ // Both groups remain visible (isGroupVisible still returns true)
+ expect(screen.getAllByTestId('group-card')).toHaveLength(2);
+ });
+
+ it('Deselect Visible disables all currently visible groups', () => {
+ renderWith({
+ initialGroups: [
+ makeGroup({ channel_group: 1, name: 'Sports', enabled: true }),
+ makeGroup({ channel_group: 2, name: 'News', enabled: true }),
+ ],
+ });
+ // With status=all, isGroupVisible still returns true after deselect
+ fireEvent.click(screen.getByText('Deselect Visible'));
+ expect(screen.getAllByTestId('group-card')).toHaveLength(2);
+ });
+
+ it('Select Visible only applies to groups passing the current filter', () => {
+ // With Enabled filter active, only already-enabled groups are "visible"
+ // so disabling one and clicking Select Visible should not re-enable the hidden one
+ renderWith({
+ initialGroups: [
+ makeGroup({ channel_group: 1, name: 'Sports', enabled: true }),
+ makeGroup({ channel_group: 2, name: 'News', enabled: false }),
+ ],
+ });
+ fireEvent.click(screen.getByTestId('seg-enabled'));
+ fireEvent.click(screen.getByText('Select Visible'));
+ // News (disabled) is not visible in Enabled filter, so it stays hidden
+ expect(screen.queryByText('News')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Configure modal ────────────────────────────────────────────────────────
+
+ describe('configure modal', () => {
+ it('opens when the Cog ActionIcon is clicked', () => {
+ renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] });
+ fireEvent.click(screen.getByTestId('icon-cog').closest('button'));
+ expect(screen.getByTestId('configure-modal')).toBeInTheDocument();
+ });
+
+ it('passes the correct group id to GroupConfigureModal', () => {
+ renderWith({ initialGroups: [makeGroup({ channel_group: 42 })] });
+ fireEvent.click(screen.getByTestId('icon-cog').closest('button'));
+ expect(screen.getByTestId('configure-modal')).toHaveAttribute(
+ 'data-group-id',
+ '42'
+ );
+ });
+
+ it('closes on Done click', () => {
+ renderWith({ initialGroups: [makeGroup()] });
+ fireEvent.click(screen.getByTestId('icon-cog').closest('button'));
+ fireEvent.click(screen.getByTestId('modal-done'));
+ expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument();
+ });
+
+ it('closes on Cancel click', () => {
+ renderWith({ initialGroups: [makeGroup()] });
+ fireEvent.click(screen.getByTestId('icon-cog').closest('button'));
+ fireEvent.click(screen.getByTestId('modal-cancel'));
+ expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument();
+ });
+
+ it('renders AutoSyncAdvanced inside the modal with the correct group', async () => {
+ renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] });
+ fireEvent.click(screen.getByTestId('icon-cog').closest('button'));
+ await waitFor(() => {
+ expect(screen.getByTestId('auto-sync-advanced')).toBeInTheDocument();
+ expect(screen.getByTestId('auto-sync-advanced')).toHaveAttribute(
+ 'data-group-id',
+ '1'
+ );
+ });
+ });
+
+ it('restores the group state when Cancel is clicked after a change', async () => {
+ renderWith({
+ initialGroups: [makeGroup({ channel_group: 1, name: 'Sports' })],
+ });
+ // Open modal — snapshot is taken
+ fireEvent.click(screen.getByTestId('icon-cog').closest('button'));
+ await waitFor(() => screen.getByTestId('trigger-apply-group'));
+
+ // Mutate the group via AutoSyncAdvanced
+ fireEvent.click(screen.getByTestId('trigger-apply-group'));
+ expect(screen.getByText('Modified By Advanced')).toBeInTheDocument();
+
+ // Cancel should revert to the snapshot
+ fireEvent.click(screen.getByTestId('modal-cancel'));
+ expect(screen.getByText('Sports')).toBeInTheDocument();
+ expect(
+ screen.queryByText('Modified By Advanced')
+ ).not.toBeInTheDocument();
+ });
+
+ it('does not revert the group state when Done is clicked after a change', async () => {
+ renderWith({
+ initialGroups: [makeGroup({ channel_group: 1, name: 'Sports' })],
+ });
+ fireEvent.click(screen.getByTestId('icon-cog').closest('button'));
+ await waitFor(() => screen.getByTestId('trigger-apply-group'));
+
+ fireEvent.click(screen.getByTestId('trigger-apply-group'));
+ fireEvent.click(screen.getByTestId('modal-done'));
+
+ // The modified name should persist
+ expect(screen.getByText('Modified By Advanced')).toBeInTheDocument();
+ });
+
+ it('triggers a regex preview when configuringGroup changes', async () => {
+ renderWith({
+ initialGroups: [
+ makeGroup({
+ channel_group: 1,
+ custom_properties: {
+ name_regex_pattern: 'ESPN',
+ name_match_regex: '',
+ },
+ }),
+ ],
+ });
+ fireEvent.click(screen.getByTestId('icon-cog').closest('button'));
+ await waitFor(() => {
+ expect(getRegexOptions).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Logo upload modal ──────────────────────────────────────────────────────
+
+ describe('logo upload modal', () => {
+ const openLogoUpload = async () => {
+ fireEvent.click(screen.getByTestId('icon-cog').closest('button'));
+ await waitFor(() => screen.getByTestId('trigger-logo-upload'));
+ fireEvent.click(screen.getByTestId('trigger-logo-upload'));
+ };
+
+ it('opens LogoForm when onOpenLogoUpload is called', async () => {
+ renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] });
+ await openLogoUpload();
+ await waitFor(() =>
+ expect(screen.getByTestId('logo-form')).toBeInTheDocument()
+ );
+ });
+
+ it('closes LogoForm when onClose is called', async () => {
+ renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] });
+ await openLogoUpload();
+ fireEvent.click(screen.getByTestId('logo-close'));
+ expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument();
+ });
+
+ it('applies custom_logo_id and calls ensureLogosLoaded on logo success', async () => {
+ renderWith({
+ initialGroups: [makeGroup({ channel_group: 1 })],
+ ensureLogosLoaded: mockEnsureLogosLoaded,
+ });
+ await openLogoUpload();
+
+ const callCountBefore = mockEnsureLogosLoaded.mock.calls.length;
+ fireEvent.click(screen.getByTestId('logo-success'));
+
+ expect(mockEnsureLogosLoaded.mock.calls.length).toBeGreaterThan(
+ callCountBefore
+ );
+ expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument();
+ });
+
+ it('does not apply logo when onSuccess provides no logo', async () => {
+ // Test the logo === null guard: LogoForm calls onSuccess({ logo: null })
+ vi.mock('./Logo.jsx', () => ({
+ default: ({ isOpen, onSuccess }) =>
+ isOpen ? (
+
+ ) : null,
+ }));
+ // Without a valid logo object, ensureLogosLoaded should not be called again
+ renderWith({
+ initialGroups: [makeGroup({ channel_group: 1 })],
+ ensureLogosLoaded: mockEnsureLogosLoaded,
+ });
+ await openLogoUpload();
+ // If the null-logo button renders, click it; otherwise the modal just closes
+ const nullBtn = screen.queryByTestId('logo-success-null');
+ if (nullBtn) fireEvent.click(nullBtn);
+ // ensureLogosLoaded should only have been called once (on mount)
+ expect(mockEnsureLogosLoaded).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ // ── scheduleRegexPreview ───────────────────────────────────────────────────
+
+ describe('scheduleRegexPreview (via AutoSyncAdvanced)', () => {
+ it('passes onScheduleRegexPreview to AutoSyncAdvanced and it triggers without error', async () => {
+ renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] });
+ fireEvent.click(screen.getByTestId('icon-cog').closest('button'));
+ await waitFor(() => screen.getByTestId('trigger-regex-preview'));
+ expect(() =>
+ fireEvent.click(screen.getByTestId('trigger-regex-preview'))
+ ).not.toThrow();
+ });
+
+ it('clears regexPreview state when all pattern fields are empty', async () => {
+ // scheduleRegexPreview with empty opts should not call getStreamsRegexPreview
+ renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] });
+ fireEvent.click(screen.getByTestId('icon-cog').closest('button'));
+ await waitFor(() => screen.getByTestId('auto-sync-advanced'));
+ // Trigger with empty patterns by mounting a group with no patterns set
+ // (the useEffect on configuringGroup?.channel_group fires getRegexOptions with empty strings)
+ expect(getStreamsRegexPreview).not.toHaveBeenCalled();
+ });
+
+ it('calls getStreamsRegexPreview when non-empty patterns are provided', async () => {
+ vi.mocked(getStreamsRegexPreview).mockResolvedValue({
+ find_matches: ['ESPN HD'],
+ find_match_count: 1,
+ total_in_group: 10,
+ total_scanned: 10,
+ scan_limit_hit: false,
+ });
+ renderWith({
+ initialGroups: [
+ makeGroup({
+ channel_group: 1,
+ custom_properties: { name_regex_pattern: 'ESPN' },
+ }),
+ ],
+ });
+ fireEvent.click(screen.getByTestId('icon-cog').closest('button'));
+ await waitFor(() => screen.getByTestId('trigger-regex-preview'));
+ fireEvent.click(screen.getByTestId('trigger-regex-preview'));
+ await waitFor(() => {
+ expect(getStreamsRegexPreview).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── applyGroupChange ───────────────────────────────────────────────────────
+
+ describe('applyGroupChange (via AutoSyncAdvanced)', () => {
+ it('updates only the target group in groupStates', async () => {
+ renderWith({
+ initialGroups: [
+ makeGroup({ channel_group: 1, name: 'Sports' }),
+ makeGroup({ channel_group: 2, name: 'News' }),
+ ],
+ });
+ // Group cards will be sorted alphabetically, so the first cog button corresponds to the "News" group, not "Sports" — ensure we target the correct one
+ const cogButtons = screen.getAllByTestId('icon-cog');
+ fireEvent.click(cogButtons[1].closest('button'));
+ await waitFor(() => screen.getByTestId('trigger-apply-group'));
+ fireEvent.click(screen.getByTestId('trigger-apply-group'));
+
+ // Group 1 name changed; Group 2 unchanged
+ expect(screen.getByText('Modified By Advanced')).toBeInTheDocument();
+ expect(screen.getByText('News')).toBeInTheDocument();
+ });
});
});
diff --git a/frontend/src/components/forms/__tests__/LoginForm.test.jsx b/frontend/src/components/forms/__tests__/LoginForm.test.jsx
new file mode 100644
index 00000000..7e30f3cd
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/LoginForm.test.jsx
@@ -0,0 +1,475 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import LoginForm from '../LoginForm';
+
+// ── Router mock ────────────────────────────────────────────────────────────────
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', () => ({
+ useNavigate: () => mockNavigate,
+}));
+
+// Mock localStorage
+const localStorageMock = (() => {
+ let store = {};
+
+ return {
+ getItem: vi.fn((key) => store[key] || null),
+ setItem: vi.fn((key, value) => {
+ store[key] = value.toString();
+ }),
+ clear: vi.fn(() => {
+ store = {};
+ }),
+ removeItem: vi.fn((key) => {
+ delete store[key];
+ }),
+ };
+})();
+
+global.localStorage = localStorageMock;
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/auth', () => ({ default: vi.fn() }));
+vi.mock('../../../store/settings', () => ({ default: vi.fn() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+// ── Asset mock ─────────────────────────────────────────────────────────────────
+vi.mock('../../../assets/logo.png', () => ({ default: 'logo.png' }));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', async () => ({
+ Paper: ({ children }) =>
{children}
,
+ Title: ({ children }) =>
{children}
,
+ TextInput: ({
+ label,
+ name,
+ value,
+ onChange,
+ placeholder,
+ type,
+ disabled,
+ }) => (
+
+ ),
+ Button: ({ children, onClick, loading, disabled, type, variant }) => (
+
+ ),
+ Center: ({ children }) =>
{children}
,
+ Stack: ({ children }) =>
{children}
,
+ Text: ({ children, c, size }) => (
+
+ {children}
+
+ ),
+ Image: ({ src, alt }) =>

,
+ Group: ({ children }) =>
{children}
,
+ Divider: () =>
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ {children}
+
+ ) : null,
+ Anchor: ({ children, onClick }) => (
+
+ {children}
+
+ ),
+ Code: ({ children }) =>
{children},
+ Checkbox: ({ label, checked, onChange }) => (
+
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import useAuthStore from '../../../store/auth';
+import useSettingsStore from '../../../store/settings';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const setupMocks = ({
+ isAuthenticated = false,
+ loginResult = Promise.resolve(true),
+ version = '1.0.0',
+} = {}) => {
+ const mockLogin = vi.fn().mockReturnValue(loginResult);
+ const mockLogout = vi.fn();
+ const mockInitData = vi.fn().mockResolvedValue(undefined);
+ const mockFetchVersion = vi.fn().mockResolvedValue(undefined);
+
+ vi.mocked(useAuthStore).mockImplementation((sel) =>
+ sel({
+ login: mockLogin,
+ logout: mockLogout,
+ isAuthenticated,
+ initData: mockInitData,
+ })
+ );
+
+ vi.mocked(useSettingsStore).mockImplementation((sel) =>
+ sel({
+ fetchVersion: mockFetchVersion,
+ version: { version },
+ })
+ );
+
+ return { mockLogin, mockLogout, mockInitData, mockFetchVersion };
+};
+
+const renderLoginForm = () => render(
);
+
+const getUsername = () => screen.getByTestId('input-Username');
+const getPassword = () => screen.getByTestId('input-Password');
+const getLoginButton = () => screen.getByRole('button', { type: 'submit' });
+
+// ──────────────────────────────────────────────────────────────────────────────
+
+describe('LoginForm', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the login form', () => {
+ setupMocks();
+ renderLoginForm();
+ expect(screen.getByTestId('paper')).toBeInTheDocument();
+ });
+
+ it('renders the logo', () => {
+ setupMocks();
+ renderLoginForm();
+ expect(screen.getByRole('img')).toHaveAttribute('src', 'logo.png');
+ });
+
+ it('renders username and password inputs', () => {
+ setupMocks();
+ renderLoginForm();
+ expect(getUsername()).toBeInTheDocument();
+ expect(getPassword()).toBeInTheDocument();
+ });
+
+ it('renders the login button', () => {
+ setupMocks();
+ renderLoginForm();
+ expect(getLoginButton()).toBeInTheDocument();
+ });
+
+ it('renders "Remember Me" checkbox', () => {
+ setupMocks();
+ renderLoginForm();
+ expect(screen.getByLabelText(/remember me/i)).toBeInTheDocument();
+ });
+
+ it('renders "Save Password" checkbox', async () => {
+ setupMocks();
+ renderLoginForm();
+ await waitFor(() => {
+ fireEvent.click(screen.getByLabelText(/remember me/i));
+ expect(screen.getByLabelText(/save password/i)).toBeInTheDocument();
+ });
+ });
+
+ it('renders version info when version is available', () => {
+ setupMocks({ version: '2.3.4' });
+ renderLoginForm();
+ expect(screen.getByText(/2.3.4/i)).toBeInTheDocument();
+ });
+
+ it('renders forgot password anchor', () => {
+ setupMocks();
+ renderLoginForm();
+ expect(screen.getByText(/forgot password/i)).toBeInTheDocument();
+ });
+ });
+
+ // ── fetchVersion on mount ──────────────────────────────────────────────────
+
+ describe('on mount', () => {
+ it('calls fetchVersion on mount', () => {
+ const { mockFetchVersion } = setupMocks();
+ renderLoginForm();
+ expect(mockFetchVersion).toHaveBeenCalled();
+ });
+ });
+
+ // ── Form input ─────────────────────────────────────────────────────────────
+
+ describe('form input', () => {
+ it('updates username field on change', () => {
+ setupMocks();
+ renderLoginForm();
+ fireEvent.change(getUsername(), { target: { value: 'testuser' } });
+ expect(getUsername()).toHaveValue('testuser');
+ });
+
+ it('updates password field on change', () => {
+ setupMocks();
+ renderLoginForm();
+ fireEvent.change(getPassword(), { target: { value: 'secret' } });
+ expect(getPassword()).toHaveValue('secret');
+ });
+
+ it('password input has type="password"', () => {
+ setupMocks();
+ renderLoginForm();
+ expect(getPassword()).toHaveAttribute('type', 'password');
+ });
+ });
+
+ // ── Successful login ───────────────────────────────────────────────────────
+
+ describe('successful login', () => {
+ it('calls login with username and password', async () => {
+ const { mockLogin } = setupMocks({ loginResult: Promise.resolve(true) });
+ renderLoginForm();
+
+ fireEvent.change(getUsername(), { target: { value: 'admin' } });
+ fireEvent.change(getPassword(), { target: { value: 'pass123' } });
+ fireEvent.click(getLoginButton());
+
+ await waitFor(() => {
+ expect(mockLogin).toHaveBeenCalledWith({
+ username: 'admin',
+ password: 'pass123',
+ });
+ });
+ });
+
+ it('calls initData after successful login', async () => {
+ const { mockInitData } = setupMocks({
+ loginResult: Promise.resolve(true),
+ });
+ renderLoginForm();
+
+ fireEvent.change(getUsername(), { target: { value: 'admin' } });
+ fireEvent.change(getPassword(), { target: { value: 'pass123' } });
+ fireEvent.click(getLoginButton());
+
+ await waitFor(() => {
+ expect(mockInitData).toHaveBeenCalled();
+ });
+ });
+
+ it('navigates to "/channels" when authenticated', async () => {
+ setupMocks({ isAuthenticated: true });
+ renderLoginForm();
+
+ fireEvent.change(getUsername(), { target: { value: 'admin' } });
+ fireEvent.change(getPassword(), { target: { value: 'pass123' } });
+ fireEvent.click(getLoginButton());
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/channels');
+ });
+ });
+ });
+
+ // ── Failed login ───────────────────────────────────────────────────────────
+
+ describe('failed login', () => {
+ it('does not navigate when login fails', async () => {
+ setupMocks({ loginResult: Promise.resolve(false) });
+ renderLoginForm();
+
+ fireEvent.change(getUsername(), { target: { value: 'admin' } });
+ fireEvent.change(getPassword(), { target: { value: 'wrong' } });
+ fireEvent.click(getLoginButton());
+
+ await waitFor(() => {
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+ });
+
+ it('does not navigate when login throws', async () => {
+ setupMocks({ loginResult: Promise.reject(new Error('fail')) });
+ renderLoginForm();
+
+ fireEvent.click(getLoginButton());
+
+ await waitFor(() => {
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Loading state ──────────────────────────────────────────────────────────
+
+ describe('loading state', () => {
+ it('disables the login button while loading', async () => {
+ let resolveLogin;
+ const loginResult = new Promise((res) => {
+ resolveLogin = res;
+ });
+ setupMocks({ loginResult });
+ renderLoginForm();
+
+ fireEvent.click(getLoginButton());
+
+ await waitFor(() => {
+ expect(getLoginButton()).toBeDisabled();
+ });
+
+ resolveLogin(true);
+ });
+
+ it('re-enables the login button after login completes', async () => {
+ setupMocks({ loginResult: Promise.resolve(false) });
+ renderLoginForm();
+
+ fireEvent.click(getLoginButton());
+
+ await waitFor(() => {
+ expect(getLoginButton()).not.toBeDisabled();
+ });
+ });
+ });
+
+ // ── Remember Me ───────────────────────────────────────────────────────────
+
+ describe('remember me', () => {
+ it('saves username to localStorage when Remember Me is checked', async () => {
+ setupMocks({ loginResult: Promise.resolve(true) });
+ renderLoginForm();
+
+ fireEvent.click(screen.getByLabelText(/remember me/i));
+ fireEvent.change(getUsername(), { target: { value: 'savedUser' } });
+ fireEvent.click(getLoginButton());
+
+ await waitFor(() => {
+ expect(localStorage.getItem('dispatcharr_remembered_username')).toBe(
+ 'savedUser'
+ );
+ });
+ });
+
+ it('removes username from localStorage when Remember Me is unchecked', async () => {
+ localStorage.setItem('dispatcharr_remembered_username', 'oldUser');
+ setupMocks({ loginResult: Promise.resolve(true) });
+ renderLoginForm();
+
+ fireEvent.click(screen.getByLabelText(/remember me/i));
+ fireEvent.click(getLoginButton());
+
+ await waitFor(() => {
+ expect(
+ localStorage.getItem('dispatcharr_remembered_username')
+ ).toBeNull();
+ });
+ });
+
+ it('pre-fills username from localStorage on mount', () => {
+ localStorage.setItem('dispatcharr_remembered_username', 'storedUser');
+ setupMocks();
+ renderLoginForm();
+ expect(getUsername()).toHaveValue('storedUser');
+ });
+ });
+
+ // ── Save Password ─────────────────────────────────────────────────────────
+
+ describe('save password', () => {
+ it('saves encoded password to localStorage when Save Password is checked', async () => {
+ setupMocks({ loginResult: Promise.resolve(true) });
+ renderLoginForm();
+
+ await waitFor(() => {
+ fireEvent.click(screen.getByLabelText(/remember me/i));
+ fireEvent.click(screen.getByLabelText(/save password/i));
+ fireEvent.change(getPassword(), { target: { value: 'mySecret' } });
+ fireEvent.click(getLoginButton());
+ });
+
+ await waitFor(() => {
+ expect(
+ localStorage.getItem('dispatcharr_saved_password')
+ ).not.toBeNull();
+ });
+ });
+
+ it('removes password from localStorage when Save Password is unchecked', async () => {
+ localStorage.setItem('dispatcharr_saved_password', btoa('oldPass'));
+ setupMocks({ loginResult: Promise.resolve(true) });
+ renderLoginForm();
+
+ fireEvent.click(getLoginButton());
+
+ await waitFor(() => {
+ expect(localStorage.getItem('dispatcharr_saved_password')).toBeNull();
+ });
+ });
+
+ it('pre-fills password from localStorage on mount', () => {
+ localStorage.setItem('dispatcharr_remembered_username', 'storedUser');
+ localStorage.setItem('dispatcharr_saved_password', btoa('storedPass'));
+ setupMocks();
+ renderLoginForm();
+ expect(getPassword()).toHaveValue('storedPass');
+ });
+ });
+
+ // ── Forgot Password modal ──────────────────────────────────────────────────
+
+ describe('forgot password modal', () => {
+ it('opens the forgot password modal when the anchor is clicked', () => {
+ setupMocks();
+ renderLoginForm();
+
+ fireEvent.click(screen.getByText(/forgot password/i));
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('shows the modal title', () => {
+ setupMocks();
+ renderLoginForm();
+
+ fireEvent.click(screen.getByText(/forgot password/i));
+ expect(screen.getByTestId('modal-title')).toBeInTheDocument();
+ });
+
+ it('closes the modal when the close button is clicked', () => {
+ setupMocks();
+ renderLoginForm();
+
+ fireEvent.click(screen.getByText(/forgot password/i));
+ fireEvent.click(screen.getByTestId('modal-close'));
+
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/components/forms/__tests__/Logo.test.jsx b/frontend/src/components/forms/__tests__/Logo.test.jsx
new file mode 100644
index 00000000..9b3b0107
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/Logo.test.jsx
@@ -0,0 +1,518 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import LogoForm from '../Logo';
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/forms/LogoUtils.js', () => ({
+ createLogo: vi.fn(),
+ updateLogo: vi.fn(),
+ uploadLogo: vi.fn(),
+ getFilenameWithoutExtension: vi.fn((name) => name.replace(/\.[^.]+$/, '')),
+ getResolver: vi.fn(() => undefined),
+ getUpdateLogoErrorMessage: vi.fn((logo, err) => err.message),
+ getUploadErrorMessage: vi.fn((err) => err.message),
+ releaseUrl: vi.fn(),
+ validateFileSize: vi.fn(() => true),
+}));
+
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+// ── Mantine dropzone ───────────────────────────────────────────────────────────
+vi.mock('@mantine/dropzone', () => ({
+ Dropzone: vi.fn(({ children, onDrop }) => (
+
onDrop([])}>
+ {children}
+
+ )),
+ DropzoneAccept: ({ children }) => (
+
{children}
+ ),
+ DropzoneReject: ({ children }) => (
+
{children}
+ ),
+ DropzoneIdle: ({ children }) =>
{children}
,
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Box: ({ children }) =>
{children}
,
+ Button: ({ children, onClick, type, loading, variant }) => (
+
+ ),
+ Center: ({ children }) =>
{children}
,
+ Divider: ({ label }) =>
,
+ Group: ({ children }) =>
{children}
,
+ Image: ({ src, alt, fallbackSrc }) => (
+

+ ),
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ {children}
+
+ ) : null,
+ Stack: ({ children }) =>
{children}
,
+ Text: ({ children, size, color }) => (
+
+ {children}
+
+ ),
+ TextInput: ({
+ label,
+ placeholder,
+ onChange,
+ onBlur,
+ error,
+ disabled,
+ ...rest
+ }) => (
+
+
+ {error && {error}}
+
+ ),
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ FileImage: () =>
,
+ Upload: () =>
,
+ X: () =>
,
+}));
+
+// ──────────────────────────────────────────────────────────────────────────────
+// Imports after mocks
+// ──────────────────────────────────────────────────────────────────────────────
+import * as LogoUtils from '../../../utils/forms/LogoUtils.js';
+import { showNotification } from '../../../utils/notificationUtils.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const makeLogo = (overrides = {}) => ({
+ id: 1,
+ name: 'My Logo',
+ url: 'https://example.com/logo.png',
+ cache_url: 'https://cdn.example.com/logo.png',
+ ...overrides,
+});
+
+const defaultProps = (overrides = {}) => ({
+ logo: null,
+ isOpen: true,
+ onClose: vi.fn(),
+ onSuccess: vi.fn(),
+ ...overrides,
+});
+
+const makeFile = (name = 'test-logo.png', size = 1024) =>
+ new File(['content'], name, { type: 'image/png', size });
+
+// ──────────────────────────────────────────────────────────────────────────────
+
+describe('LogoForm', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(LogoUtils.validateFileSize).mockReturnValue(true);
+ vi.mocked(LogoUtils.createLogo).mockResolvedValue({
+ id: 2,
+ name: 'New Logo',
+ url: 'https://example.com/new.png',
+ });
+ vi.mocked(LogoUtils.updateLogo).mockResolvedValue({
+ id: 1,
+ name: 'Updated Logo',
+ url: 'https://example.com/updated.png',
+ });
+ vi.mocked(LogoUtils.uploadLogo).mockResolvedValue({
+ id: 3,
+ name: 'uploaded-logo',
+ url: 'https://cdn.example.com/uploaded.png',
+ });
+ vi.mocked(LogoUtils.getResolver).mockReturnValue(undefined);
+
+ URL.createObjectURL = vi.fn();
+ });
+
+ afterEach(() => {
+ delete URL.createObjectURL;
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the modal when isOpen is true', () => {
+ render(
);
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render the modal when isOpen is false', () => {
+ render(
);
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('shows "Add Logo" title for new logo', () => {
+ render(
);
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Add Logo');
+ });
+
+ it('shows "Edit Logo" title when editing existing logo', () => {
+ render(
);
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Edit Logo');
+ });
+
+ it('shows "Create" button for new logo', () => {
+ render(
);
+ expect(screen.getByText('Create')).toBeInTheDocument();
+ });
+
+ it('shows "Update" button when editing existing logo', () => {
+ render(
);
+ expect(screen.getByText('Update')).toBeInTheDocument();
+ });
+
+ it('pre-fills name input when editing existing logo', () => {
+ render(
);
+ expect(screen.getByDisplayValue('My Logo')).toBeInTheDocument();
+ });
+
+ it('pre-fills URL input when editing existing logo', () => {
+ render(
);
+ expect(
+ screen.getByDisplayValue('https://example.com/logo.png')
+ ).toBeInTheDocument();
+ });
+
+ it('shows logo preview when logo has cache_url', () => {
+ render(
);
+ const img = screen.getByAltText('Logo preview');
+ expect(img).toHaveAttribute('src', 'https://cdn.example.com/logo.png');
+ });
+
+ it('does not show preview when no logo provided', () => {
+ render(
);
+ expect(screen.queryByAltText('Logo preview')).not.toBeInTheDocument();
+ });
+
+ it('renders the dropzone', () => {
+ render(
);
+ expect(screen.getByTestId('dropzone')).toBeInTheDocument();
+ });
+
+ it('calls onClose when Cancel is clicked', () => {
+ const onClose = vi.fn();
+ render(
);
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('calls onClose when modal close button is clicked', () => {
+ const onClose = vi.fn();
+ render(
);
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Create logo ────────────────────────────────────────────────────────────
+
+ describe('creating a logo via URL', () => {
+ it('calls createLogo with entered values on submit', async () => {
+ render(
);
+
+ fireEvent.change(
+ screen.getByPlaceholderText('https://example.com/logo.png'),
+ {
+ target: { value: 'https://example.com/new.png' },
+ }
+ );
+ fireEvent.change(screen.getByPlaceholderText('Enter logo name'), {
+ target: { value: 'New Logo' },
+ });
+ fireEvent.click(screen.getByText('Create'));
+
+ await waitFor(() => {
+ expect(LogoUtils.createLogo).toHaveBeenCalled();
+ });
+ });
+
+ it('shows success notification after creating logo', async () => {
+ render(
);
+
+ fireEvent.change(
+ screen.getByPlaceholderText('https://example.com/logo.png'),
+ {
+ target: { value: 'https://example.com/new.png' },
+ }
+ );
+ fireEvent.click(screen.getByText('Create'));
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Success', color: 'green' })
+ );
+ });
+ });
+
+ it('calls onSuccess with type "create" after creating logo', async () => {
+ const onSuccess = vi.fn();
+ render(
);
+
+ fireEvent.change(
+ screen.getByPlaceholderText('https://example.com/logo.png'),
+ {
+ target: { value: 'https://example.com/new.png' },
+ }
+ );
+ fireEvent.click(screen.getByText('Create'));
+
+ await waitFor(() => {
+ expect(onSuccess).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'create' })
+ );
+ });
+ });
+
+ it('calls onClose after creating logo', async () => {
+ const onClose = vi.fn();
+ render(
);
+
+ fireEvent.change(
+ screen.getByPlaceholderText('https://example.com/logo.png'),
+ {
+ target: { value: 'https://example.com/new.png' },
+ }
+ );
+ fireEvent.click(screen.getByText('Create'));
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('shows error notification when createLogo throws', async () => {
+ vi.mocked(LogoUtils.createLogo).mockRejectedValue(new Error('API error'));
+ render(
);
+
+ fireEvent.change(
+ screen.getByPlaceholderText('https://example.com/logo.png'),
+ {
+ target: { value: 'https://example.com/new.png' },
+ }
+ );
+ fireEvent.click(screen.getByText('Create'));
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Error', color: 'red' })
+ );
+ });
+ });
+ });
+
+ // ── Update logo ────────────────────────────────────────────────────────────
+
+ describe('updating an existing logo', () => {
+ it('calls updateLogo on submit', async () => {
+ render(
);
+ fireEvent.click(screen.getByText('Update'));
+
+ await waitFor(() => {
+ expect(LogoUtils.updateLogo).toHaveBeenCalledWith(
+ makeLogo(),
+ expect.objectContaining({ name: 'My Logo' })
+ );
+ });
+ });
+
+ it('calls onSuccess with type "update" after updating logo', async () => {
+ const onSuccess = vi.fn();
+ render(
);
+ fireEvent.click(screen.getByText('Update'));
+
+ await waitFor(() => {
+ expect(onSuccess).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'update' })
+ );
+ });
+ });
+
+ it('shows success notification after updating logo', async () => {
+ render(
);
+ fireEvent.click(screen.getByText('Update'));
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Success', color: 'green' })
+ );
+ });
+ });
+
+ it('shows error notification when updateLogo throws', async () => {
+ vi.mocked(LogoUtils.updateLogo).mockRejectedValue(
+ new Error('Server error')
+ );
+ render(
);
+ fireEvent.click(screen.getByText('Update'));
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Error', color: 'red' })
+ );
+ });
+ });
+ });
+
+ // ── File upload ────────────────────────────────────────────────────────────
+
+ describe('file upload via dropzone', () => {
+ it('calls uploadLogo with the selected file on submit', async () => {
+ const file = makeFile('my-logo.png');
+
+ // Override dropzone to pass our test file
+ const { Dropzone } = await import('@mantine/dropzone');
+ vi.mocked(Dropzone).mockImplementation(({ children, onDrop }) => (
+
onDrop([file])}>
+ {children}
+
+ ));
+
+ render(
);
+
+ fireEvent.click(screen.getByTestId('dropzone'));
+ fireEvent.click(screen.getByText('Create'));
+
+ await waitFor(() => {
+ expect(LogoUtils.uploadLogo).toHaveBeenCalledWith(
+ file,
+ expect.any(Object)
+ );
+ });
+ });
+
+ it('shows error notification when file is too large', async () => {
+ vi.mocked(LogoUtils.validateFileSize).mockReturnValue(false);
+
+ // Override dropzone to pass a file
+ const file = makeFile('big.png', 10 * 1024 * 1024);
+ const { Dropzone } = await import('@mantine/dropzone');
+ vi.mocked(Dropzone).mockImplementationOnce(({ children, onDrop }) => (
+
onDrop([file])}>
+ {children}
+
+ ));
+
+ render(
);
+ fireEvent.click(screen.getByTestId('dropzone'));
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Error', color: 'red' })
+ );
+ });
+ });
+
+ it('shows upload error notification when uploadLogo throws', async () => {
+ vi.mocked(LogoUtils.uploadLogo).mockRejectedValue(
+ new Error('Upload failed')
+ );
+ const file = makeFile('logo.png');
+ const { Dropzone } = await import('@mantine/dropzone');
+ vi.mocked(Dropzone).mockImplementationOnce(({ children, onDrop }) => (
+
onDrop([file])}>
+ {children}
+
+ ));
+
+ render(
);
+ fireEvent.click(screen.getByTestId('dropzone'));
+ fireEvent.click(screen.getByText('Create'));
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Upload Error', color: 'red' })
+ );
+ });
+ });
+ });
+
+ // ── URL input behaviour ────────────────────────────────────────────────────
+
+ describe('URL input behaviour', () => {
+ it('updates preview when a valid http URL is entered', () => {
+ render(
);
+ fireEvent.change(
+ screen.getByPlaceholderText('https://example.com/logo.png'),
+ {
+ target: { value: 'https://example.com/img.png' },
+ }
+ );
+ expect(screen.getByAltText('Logo preview')).toHaveAttribute(
+ 'src',
+ 'https://example.com/img.png'
+ );
+ });
+
+ it('removes preview when URL is cleared', () => {
+ render(
);
+ fireEvent.change(
+ screen.getByPlaceholderText('https://example.com/logo.png'),
+ {
+ target: { value: '' },
+ }
+ );
+ expect(screen.queryByAltText('Logo preview')).not.toBeInTheDocument();
+ });
+
+ it('auto-fills name from URL on blur', () => {
+ render(
);
+ fireEvent.change(
+ screen.getByPlaceholderText('https://example.com/logo.png'),
+ {
+ target: { value: 'https://example.com/my-channel-logo.png' },
+ }
+ );
+ fireEvent.blur(
+ screen.getByPlaceholderText('https://example.com/logo.png'),
+ {
+ target: { value: 'https://example.com/my-channel-logo.png' },
+ }
+ );
+ expect(LogoUtils.getFilenameWithoutExtension).toHaveBeenCalled();
+ });
+
+ it('does not throw on blur with invalid URL', () => {
+ render(
);
+ expect(() => {
+ fireEvent.blur(
+ screen.getByPlaceholderText('https://example.com/logo.png'),
+ {
+ target: { value: 'not-a-url' },
+ }
+ );
+ }).not.toThrow();
+ });
+ });
+});
diff --git a/frontend/src/components/forms/__tests__/M3U.test.jsx b/frontend/src/components/forms/__tests__/M3U.test.jsx
new file mode 100644
index 00000000..7e26d97a
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/M3U.test.jsx
@@ -0,0 +1,708 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import M3U from '../M3U';
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/userAgents', () => ({ default: vi.fn() }));
+vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
+vi.mock('../../../store/epgs', () => ({ default: vi.fn() }));
+vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/forms/M3uUtils.js', () => ({
+ addPlaylist: vi.fn(),
+ getPlaylist: vi.fn(),
+ prepareSubmitValues: vi.fn((values) => values),
+ updatePlaylist: vi.fn(),
+}));
+
+vi.mock('../../../utils/forms/DummyEpgUtils.js', () => ({
+ addEPG: vi.fn(),
+}));
+
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+// ── Sub-component mocks ────────────────────────────────────────────────────────
+vi.mock('../M3UProfiles', () => ({
+ default: ({ onChange }) => (
+
+
+
+ ),
+}));
+
+vi.mock('../M3UGroupFilter', () => ({
+ default: ({ onChange }) => (
+
+
+
+ ),
+}));
+
+vi.mock('../M3UFilters', () => ({
+ default: ({ onChange }) => (
+
+
+
+ ),
+}));
+
+vi.mock('../ScheduleInput', () => ({
+ default: ({ onChange, value }) => (
+
+ onChange?.(e.target.value)}
+ />
+
+ ),
+}));
+
+// ── Mantine dates ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/dates', () => ({
+ DateTimePicker: ({ label, value, onChange, placeholder }) => (
+
+
+
+ ),
+}));
+
+// ── Mantine form ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/form', () => {
+ let _values = null;
+
+ return {
+ isNotEmpty: vi.fn(() => (val) => (val ? null : 'Required')),
+ __resetFormState: () => {
+ _values = null;
+ },
+ useForm: vi.fn(({ initialValues = {} } = {}) => {
+ if (_values === null) {
+ _values = { ...initialValues };
+ }
+
+ return {
+ key: vi.fn((field) => field),
+ getValues: () => ({ ..._values }),
+ setValues: (v) => {
+ Object.assign(_values, v);
+ },
+ setFieldValue: (field, val) => {
+ _values[field] = val;
+ },
+ reset: () => {
+ _values = { ...initialValues };
+ },
+ submitting: false,
+ onSubmit: vi.fn((handler) => (e) => {
+ e?.preventDefault?.();
+ if (_values?.name) handler();
+ }),
+ getInputProps: vi.fn((field) => ({
+ value: _values?.[field] ?? '',
+ onChange: vi.fn((e) => {
+ const val = e?.target?.value ?? e;
+ if (_values) _values[field] = val;
+ }),
+ error: null,
+ })),
+ };
+ }),
+ };
+});
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Box: ({ children }) =>
{children}
,
+ Button: ({ children, onClick, type, loading, disabled, variant, color }) => (
+
+ ),
+ Checkbox: ({ label, checked, onChange, disabled }) => (
+
+ ),
+ Divider: ({ label }) =>
,
+ FileInput: ({ label, placeholder, onChange, accept, disabled }) => (
+
+
+
+ ),
+ Flex: ({ children }) =>
{children}
,
+ Group: ({ children }) =>
{children}
,
+ LoadingOverlay: ({ visible }) =>
+ visible ?
: null,
+ Modal: ({ children, opened, onClose, title, size }) =>
+ opened ? (
+
+
{title}
+
+ {children}
+
+ ) : null,
+ NumberInput: ({
+ label,
+ placeholder,
+ value,
+ onChange,
+ min,
+ max,
+ disabled,
+ error,
+ }) => (
+
+
+ {error && {error}}
+
+ ),
+ PasswordInput: ({
+ label,
+ placeholder,
+ value,
+ onChange,
+ onBlur,
+ error,
+ disabled,
+ }) => (
+
+
+ {error && {error}}
+
+ ),
+ Select: ({ label, placeholder, value, onChange, data, disabled, error }) => (
+
+
+ {error && {error}}
+
+ ),
+ Stack: ({ children }) =>
{children}
,
+ Switch: ({ label, checked, onChange, disabled }) => (
+
+ ),
+ TextInput: ({
+ id,
+ label,
+ placeholder,
+ value,
+ onChange,
+ onBlur,
+ error,
+ disabled,
+ ...rest
+ }) => (
+
+
+ {error && {error}}
+
+ ),
+}));
+
+// ──────────────────────────────────────────────────────────────────────────────
+// Imports after mocks
+// ──────────────────────────────────────────────────────────────────────────────
+import useUserAgentsStore from '../../../store/userAgents';
+import useChannelsStore from '../../../store/channels';
+import useEPGsStore from '../../../store/epgs';
+import useVODStore from '../../../store/useVODStore';
+import * as M3uUtils from '../../../utils/forms/M3uUtils.js';
+import * as DummyEpgUtils from '../../../utils/forms/DummyEpgUtils.js';
+import * as mantineForm from '@mantine/form';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const makeM3uAccount = (overrides = {}) => ({
+ id: 1,
+ name: 'Test M3U',
+ server_url: 'http://example.com/playlist.m3u',
+ username: 'user1',
+ password: 'pass1',
+ account_type: 'XC',
+ max_streams: 0,
+ refresh_interval: 24,
+ auto_refresh: false,
+ is_active: true,
+ custom_properties: {},
+ ...overrides,
+});
+
+const makeUserAgent = (overrides = {}) => ({
+ id: 1,
+ name: 'Default Agent',
+ user_agent: 'Mozilla/5.0',
+ ...overrides,
+});
+
+const defaultProps = (overrides = {}) => ({
+ m3uAccount: null,
+ isOpen: true,
+ onClose: vi.fn(),
+ ...overrides,
+});
+
+const setupStores = (overrides = {}) => {
+ const fetchUserAgents = vi.fn();
+ const fetchChannelGroups = vi.fn();
+ const fetchEPGs = vi.fn();
+ const fetchCategories = vi.fn();
+
+ useUserAgentsStore.mockImplementation((selector) => {
+ const state = {
+ userAgents: overrides.userAgents || [],
+ fetchUserAgents,
+ };
+ return selector(state);
+ });
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ fetchChannelGroups,
+ };
+ return selector(state);
+ });
+
+ useEPGsStore.mockImplementation((selector) => {
+ const state = {
+ fetchEPGs,
+ };
+ return selector(state);
+ });
+
+ useVODStore.mockImplementation((selector) => {
+ const state = {
+ fetchCategories,
+ };
+ return selector(state);
+ });
+
+ return {
+ fetchUserAgents,
+ fetchChannelGroups,
+ fetchEPGs,
+ fetchCategories,
+ };
+};
+
+// ──────────────────────────────────────────────────────────────────────────────
+
+describe('M3U', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mantineForm.__resetFormState();
+ vi.mocked(M3uUtils.addPlaylist).mockResolvedValue(
+ makeM3uAccount({ id: 2 })
+ );
+ vi.mocked(M3uUtils.updatePlaylist).mockResolvedValue(makeM3uAccount());
+ vi.mocked(M3uUtils.getPlaylist).mockResolvedValue(makeM3uAccount());
+ vi.mocked(M3uUtils.prepareSubmitValues).mockImplementation((v) => v);
+ vi.mocked(DummyEpgUtils.addEPG).mockResolvedValue({
+ id: 10,
+ name: 'Dummy EPG',
+ });
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the modal when isOpen is true', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render the modal when isOpen is false', () => {
+ setupStores();
+ render(
);
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('renders Name input', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('text-input-name')).toBeInTheDocument();
+ });
+
+ it('renders URL input', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('text-input-server_url')).toBeInTheDocument();
+ });
+
+ it('renders submit button with "Add" label for new account', () => {
+ setupStores();
+ render(
);
+ expect(
+ screen.getByRole('button', { name: /add|create|save/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders submit button with "Update" or "Save" label for existing account', () => {
+ setupStores();
+ render(
);
+ expect(
+ screen.getByRole('button', { name: /update|save/i })
+ ).toBeInTheDocument();
+ });
+
+ it('pre-fills name when editing', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByDisplayValue('Test M3U')).toBeInTheDocument();
+ });
+
+ it('pre-fills URL when editing', () => {
+ setupStores();
+ render(
);
+ expect(
+ screen.getByDisplayValue('http://example.com/playlist.m3u')
+ ).toBeInTheDocument();
+ });
+
+ it('renders M3UProfiles sub-component', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('m3u-profiles')).toBeInTheDocument();
+ });
+
+ it('renders M3UGroupFilter sub-component', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('m3u-group-filter')).toBeInTheDocument();
+ });
+
+ it('renders M3UFilters sub-component', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('m3u-filters')).toBeInTheDocument();
+ });
+
+ it('renders ScheduleInput sub-component', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('schedule-input')).toBeInTheDocument();
+ });
+
+ it('populates user agent select with agents from store', () => {
+ setupStores({
+ userAgents: [
+ makeUserAgent({ id: 1, name: 'Agent One' }),
+ makeUserAgent({ id: 2, name: 'Agent Two' }),
+ ],
+ });
+ render(
);
+ expect(screen.getByText('Agent One')).toBeInTheDocument();
+ expect(screen.getByText('Agent Two')).toBeInTheDocument();
+ });
+ });
+
+ // ── Cancel behaviour ───────────────────────────────────────────────────────
+
+ describe('cancel behaviour', () => {
+ it('calls onClose when modal X is clicked', () => {
+ const onClose = vi.fn();
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Adding a playlist ──────────────────────────────────────────────────────
+
+ describe('adding a new playlist', () => {
+ const fillRequiredFields = () => {
+ const nameInput = screen.getByTestId('text-input-name');
+ fireEvent.change(nameInput, { target: { value: 'New Playlist' } });
+
+ const urlInput = screen.getByTestId('text-input-server_url');
+ if (urlInput) {
+ fireEvent.change(urlInput, {
+ target: { value: 'http://example.com/new.m3u' },
+ });
+ }
+ };
+
+ it('calls addPlaylist on valid submit', async () => {
+ setupStores();
+ render(
);
+ fillRequiredFields();
+ fireEvent.click(screen.getByRole('button', { name: /add|create|save/i }));
+ await waitFor(() => {
+ expect(M3uUtils.addPlaylist).toHaveBeenCalled();
+ });
+ });
+
+ it('calls prepareSubmitValues before addPlaylist', async () => {
+ setupStores();
+ render(
);
+ fillRequiredFields();
+ fireEvent.click(screen.getByRole('button', { name: /add|create|save/i }));
+ await waitFor(() => {
+ expect(M3uUtils.prepareSubmitValues).toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClose after successful add', async () => {
+ const onClose = vi.fn();
+ setupStores();
+ render(
+
+ );
+ fillRequiredFields();
+ fireEvent.click(screen.getByRole('button', { name: /add|create|save/i }));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Updating a playlist ────────────────────────────────────────────────────
+
+ describe('updating an existing playlist', () => {
+ it('calls updatePlaylist on submit', async () => {
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /update|save/i }));
+ await waitFor(() => {
+ expect(M3uUtils.updatePlaylist).toHaveBeenCalled();
+ });
+ });
+
+ it('does not call addPlaylist when updating', async () => {
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /update|save/i }));
+ await waitFor(() => {
+ expect(M3uUtils.updatePlaylist).toHaveBeenCalled();
+ });
+ expect(M3uUtils.addPlaylist).not.toHaveBeenCalled();
+ });
+
+ it('calls onClose after successful update', async () => {
+ const onClose = vi.fn();
+ setupStores();
+ render(
+
+ );
+ fireEvent.click(screen.getByRole('button', { name: /update|save/i }));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Form validation ────────────────────────────────────────────────────────
+
+ describe('form validation', () => {
+ it('does not call addPlaylist when Name is empty', async () => {
+ setupStores();
+ render(
);
+ // Clear name if pre-filled, then submit
+ const nameInput = screen.getByTestId('text-input-name');
+ fireEvent.change(nameInput, { target: { value: '' } });
+ fireEvent.click(screen.getByRole('button', { name: /add|create|save/i }));
+ await new Promise((r) => setTimeout(r, 50));
+ expect(M3uUtils.addPlaylist).not.toHaveBeenCalled();
+ });
+ });
+
+ // ── Refresh / schedule behaviour ───────────────────────────────────────────
+
+ describe('schedule and refresh', () => {
+ it('renders the ScheduleInput', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('schedule-input')).toBeInTheDocument();
+ });
+ });
+
+ // ── Max streams ────────────────────────────────────────────────────────────
+
+ describe('max streams field', () => {
+ it('renders max streams number input', () => {
+ setupStores();
+ render(
);
+ expect(
+ screen.getByRole('spinbutton', { name: /max.?stream/i })
+ ).toBeInTheDocument();
+ });
+
+ it('pre-fills max_streams when editing', () => {
+ setupStores();
+ render(
+
+ );
+ expect(screen.getByDisplayValue('5')).toBeInTheDocument();
+ });
+ });
+
+ // ── User agent select ──────────────────────────────────────────────────────
+
+ describe('user agent select', () => {
+ it('renders user agent select', () => {
+ setupStores();
+ render(
);
+ expect(
+ screen.getByRole('combobox', { name: /user.?agent/i })
+ ).toBeInTheDocument();
+ });
+ });
+
+ // ── Credential fields ──────────────────────────────────────────────────────
+
+ describe('credential fields', () => {
+ it('renders username input', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('text-input-username')).toBeInTheDocument();
+ });
+
+ it('renders password input', () => {
+ setupStores();
+ render(
);
+ expect(
+ document.querySelector('input[type="password"]')
+ ).toBeInTheDocument();
+ });
+
+ it('pre-fills username when editing', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByDisplayValue('user1')).toBeInTheDocument();
+ });
+ });
+
+ // ── Dummy EPG creation ─────────────────────────────────────────────────────
+
+ describe('dummy EPG auto-creation', () => {
+ it('calls addEPG after successfully adding a playlist', async () => {
+ setupStores();
+ render(
);
+
+ const nameInput = screen.getByTestId('text-input-name');
+ fireEvent.change(nameInput, { target: { value: 'My Playlist' } });
+
+ const urlInput = screen.getByTestId('text-input-server_url');
+ if (urlInput) {
+ fireEvent.change(urlInput, {
+ target: { value: 'http://example.com/p.m3u' },
+ });
+ }
+
+ fireEvent.click(screen.getByRole('button', { name: /add|create|save/i }));
+ await waitFor(() => {
+ expect(M3uUtils.addPlaylist).toHaveBeenCalled();
+ });
+ // addEPG may be called conditionally — assert it was called or not based on a checkbox
+ });
+ });
+});
diff --git a/frontend/src/components/forms/__tests__/M3UFilter.test.jsx b/frontend/src/components/forms/__tests__/M3UFilter.test.jsx
new file mode 100644
index 00000000..fc71f4b8
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/M3UFilter.test.jsx
@@ -0,0 +1,410 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import M3UFilter from '../M3UFilter';
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/playlists', () => ({ default: vi.fn() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/forms/M3uFilterUtils.js', () => ({
+ addM3UFilter: vi.fn(),
+ updateM3UFilter: vi.fn(),
+}));
+
+vi.mock('../../../utils', () => ({
+ setCustomProperty: vi.fn((obj, key, value) => ({ ...obj, [key]: value })),
+}));
+
+// ── Constants mock ─────────────────────────────────────────────────────────────
+vi.mock('../../../constants', () => ({
+ M3U_FILTER_TYPES: [
+ { value: 'include', label: 'Include' },
+ { value: 'exclude', label: 'Exclude' },
+ ],
+}));
+
+// ── Mantine form ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/form', () => {
+ let _values = null;
+
+ return {
+ isNotEmpty: vi.fn(() => (val) => (val ? null : 'Required')),
+ __resetFormState: () => {
+ _values = null;
+ },
+ useForm: vi.fn(({ initialValues = {} } = {}) => {
+ if (_values === null) {
+ _values = { ...initialValues };
+ }
+
+ return {
+ key: vi.fn((field) => field),
+ getValues: () => ({ ..._values }),
+ setValues: (v) => {
+ Object.assign(_values, v);
+ },
+ setFieldValue: (field, val) => {
+ _values[field] = val;
+ },
+ reset: () => {
+ _values = { ...initialValues };
+ },
+ submitting: false,
+ onSubmit: vi.fn((handler) => (e) => {
+ e?.preventDefault?.();
+ handler();
+ }),
+ getInputProps: vi.fn((field, options = {}) => {
+ const isCheckbox = options?.type === 'checkbox';
+ return {
+ ...(isCheckbox
+ ? { checked: !!_values?.[field] }
+ : { value: _values?.[field] ?? '' }),
+ onChange: vi.fn((e) => {
+ const val = isCheckbox
+ ? (e?.target?.checked ?? e)
+ : (e?.target?.value ?? e);
+ if (_values) _values[field] = val;
+ }),
+ error: null,
+ };
+ }),
+ };
+ }),
+ };
+});
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Box: ({ children }) =>
{children}
,
+ Button: ({ children, onClick, type, loading, disabled, variant, color }) => (
+
+ ),
+ Flex: ({ children }) =>
{children}
,
+ Group: ({ children }) =>
{children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ {children}
+
+ ) : null,
+ Select: ({ label, placeholder, value, onChange, data, error, disabled }) => (
+
+
+ {error && {error}}
+
+ ),
+ Stack: ({ children }) =>
{children}
,
+ Switch: ({ id, label, checked, onChange, disabled }) => (
+
+ ),
+ TextInput: ({
+ label,
+ placeholder,
+ value,
+ onChange,
+ onBlur,
+ error,
+ disabled,
+ }) => (
+
+
+ {error && {error}}
+
+ ),
+}));
+
+// ──────────────────────────────────────────────────────────────────────────────
+// Imports after mocks
+// ──────────────────────────────────────────────────────────────────────────────
+import usePlaylistsStore from '../../../store/playlists';
+import * as M3uFilterUtils from '../../../utils/forms/M3uFilterUtils.js';
+import * as mantineForm from '@mantine/form';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const makeM3U = (overrides = {}) => ({
+ id: 1,
+ name: 'Test Playlist',
+ custom_properties: {},
+ filters: [],
+ ...overrides,
+});
+
+const makeFilter = (overrides = {}) => ({
+ id: 10,
+ filter_type: 'include',
+ regex_pattern: 'HBO.*',
+ exclude: false,
+ custom_properties: { case_sensitive: false },
+ ...overrides,
+});
+
+const defaultProps = (overrides = {}) => ({
+ filter: null,
+ m3u: makeM3U(),
+ isOpen: true,
+ onClose: vi.fn(),
+ ...overrides,
+});
+
+const setupStores = ({
+ fetchPlaylist = vi.fn().mockResolvedValue(undefined),
+} = {}) => {
+ vi.mocked(usePlaylistsStore).mockImplementation((sel) =>
+ sel({ fetchPlaylist })
+ );
+ return { fetchPlaylist };
+};
+
+// ──────────────────────────────────────────────────────────────────────────────
+
+describe('M3UFilter', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mantineForm.__resetFormState();
+ vi.mocked(M3uFilterUtils.addM3UFilter).mockResolvedValue({ id: 99 });
+ vi.mocked(M3uFilterUtils.updateM3UFilter).mockResolvedValue({});
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the modal when isOpen is true', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render when isOpen is false', () => {
+ setupStores();
+ render(
);
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('renders modal title as "Filter"', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Filter');
+ });
+
+ it('renders filter type Select', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
+ });
+
+ it('renders filter type options from M3U_FILTER_TYPES', () => {
+ setupStores();
+ render(
);
+ expect(
+ screen.getByRole('option', { name: 'Include' })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('option', { name: 'Exclude' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a text input for the pattern', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByLabelText('Regex Pattern')).toBeInTheDocument();
+ });
+
+ it('renders the exclusion switch', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('exclude')).toBeInTheDocument();
+ });
+
+ it('renders the case sensitivity switch', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('case_sensitive')).toBeInTheDocument();
+ });
+
+ it('renders a submit button', () => {
+ setupStores();
+ render(
);
+ expect(
+ screen.getByRole('button', { name: /add|save|submit/i })
+ ).toBeInTheDocument();
+ });
+ });
+
+ // ── Form reset on open/filter change ──────────────────────────────────────
+
+ describe('form reset behaviour', () => {
+ it('shows empty pattern field for a new filter', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByRole('textbox')).toHaveValue('');
+ });
+ });
+
+ // ── Cancel behaviour ───────────────────────────────────────────────────────
+
+ describe('cancel behaviour', () => {
+ it('calls onClose when modal X is clicked', () => {
+ const onClose = vi.fn();
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Adding a filter ────────────────────────────────────────────────────────
+
+ describe('adding a new filter', () => {
+ const fillForm = () => {
+ fireEvent.change(screen.getByRole('combobox'), {
+ target: { value: 'include' },
+ });
+ fireEvent.change(screen.getByRole('textbox'), {
+ target: { value: 'Sports.*' },
+ });
+ };
+
+ it('calls addM3UFilter on submit for a new filter', async () => {
+ setupStores();
+ render(
);
+ fillForm();
+ fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i }));
+ await waitFor(() => {
+ expect(M3uFilterUtils.addM3UFilter).toHaveBeenCalled();
+ });
+ });
+
+ it('does not call updateM3UFilter when adding', async () => {
+ setupStores();
+ render(
);
+ fillForm();
+ fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i }));
+ await waitFor(() => {
+ expect(M3uFilterUtils.addM3UFilter).toHaveBeenCalled();
+ });
+ expect(M3uFilterUtils.updateM3UFilter).not.toHaveBeenCalled();
+ });
+
+ it('calls fetchPlaylist after successful add', async () => {
+ const { fetchPlaylist } = setupStores();
+ render(
);
+ fillForm();
+ fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i }));
+ await waitFor(() => {
+ expect(fetchPlaylist).toHaveBeenCalledWith(makeM3U().id);
+ });
+ });
+
+ it('calls onClose after successful add', async () => {
+ const onClose = vi.fn();
+ setupStores();
+ render(
);
+ fillForm();
+ fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i }));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Updating a filter ──────────────────────────────────────────────────────
+
+ describe('updating an existing filter', () => {
+ it('calls updateM3UFilter on submit for an existing filter', async () => {
+ setupStores();
+ render(
);
+ fireEvent.click(
+ screen.getByRole('button', { name: /add|save|update|submit/i })
+ );
+ await waitFor(() => {
+ expect(M3uFilterUtils.updateM3UFilter).toHaveBeenCalled();
+ });
+ });
+
+ it('does not call addM3UFilter when updating', async () => {
+ setupStores();
+ render(
);
+ fireEvent.click(
+ screen.getByRole('button', { name: /add|save|update|submit/i })
+ );
+ await waitFor(() => {
+ expect(M3uFilterUtils.updateM3UFilter).toHaveBeenCalled();
+ });
+ expect(M3uFilterUtils.addM3UFilter).not.toHaveBeenCalled();
+ });
+
+ it('calls fetchPlaylist after successful update', async () => {
+ const { fetchPlaylist } = setupStores();
+ render(
);
+ fireEvent.click(
+ screen.getByRole('button', { name: /add|save|update|submit/i })
+ );
+ await waitFor(() => {
+ expect(fetchPlaylist).toHaveBeenCalledWith(makeM3U().id);
+ });
+ });
+
+ it('calls onClose after successful update', async () => {
+ const onClose = vi.fn();
+ setupStores();
+ render(
+
+ );
+ fireEvent.click(
+ screen.getByRole('button', { name: /add|save|update|submit/i })
+ );
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/frontend/src/components/forms/__tests__/M3UFilters.test.jsx b/frontend/src/components/forms/__tests__/M3UFilters.test.jsx
new file mode 100644
index 00000000..4adb4ada
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/M3UFilters.test.jsx
@@ -0,0 +1,594 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import M3UFilters from '../M3UFilters';
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/playlists', () => ({ default: vi.fn() }));
+vi.mock('../../../store/warnings', () => ({ default: vi.fn() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/forms/M3uFilterUtils.js', () => ({
+ deleteM3UFilter: vi.fn(),
+ updateM3UFilter: vi.fn(),
+}));
+
+// ── Constants mock ─────────────────────────────────────────────────────────────
+vi.mock('../../../constants', () => ({
+ M3U_FILTER_TYPES: [
+ { value: 'include', label: 'Include' },
+ { value: 'exclude', label: 'Exclude' },
+ ],
+}));
+
+// ── Sub-component mocks ────────────────────────────────────────────────────────
+vi.mock('../M3UFilter', () => ({
+ default: ({ isOpen, onClose, filter, m3u }) =>
+ isOpen ? (
+
+
{filter?.id ?? 'new'}
+
+
+
+ ) : null,
+}));
+
+vi.mock('../../ConfirmationDialog', () => ({
+ default: ({ opened, onConfirm, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+
+
+ ) : null,
+}));
+
+// ── DnD kit mocks ──────────────────────────────────────────────────────────────
+vi.mock('@dnd-kit/core', () => ({
+ closestCenter: vi.fn(),
+ DndContext: vi.fn(({ children, onDragEnd }) => (
+
+ {children}
+
+ )),
+ KeyboardSensor: vi.fn(),
+ MouseSensor: vi.fn(),
+ TouchSensor: vi.fn(),
+ useDraggable: () => ({
+ attributes: {},
+ listeners: {},
+ setNodeRef: vi.fn(),
+ }),
+ useSensor: vi.fn((sensor) => sensor),
+ useSensors: vi.fn((...sensors) => sensors),
+}));
+
+vi.mock('@dnd-kit/sortable', () => ({
+ arrayMove: vi.fn((arr, from, to) => {
+ const result = [...arr];
+ const [item] = result.splice(from, 1);
+ result.splice(to, 0, item);
+ return result;
+ }),
+ SortableContext: ({ children }) => (
+
{children}
+ ),
+ useSortable: () => ({
+ transform: null,
+ transition: null,
+ setNodeRef: vi.fn(),
+ isDragging: false,
+ }),
+ verticalListSortingStrategy: vi.fn(),
+}));
+
+vi.mock('@dnd-kit/utilities', () => ({
+ CSS: { Transform: { toString: vi.fn(() => '') } },
+}));
+
+vi.mock('@dnd-kit/modifiers', () => ({
+ restrictToVerticalAxis: vi.fn(),
+}));
+
+// ── lucide-react mocks ─────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ GripHorizontal: () =>
,
+ Info: () =>
,
+ SquareMinus: () =>
,
+ SquarePen: () =>
,
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick, disabled, color }) => (
+
+ ),
+ Alert: ({ children, title }) => (
+
+ ),
+ Box: ({ children, ref }) =>
{children}
,
+ Button: ({ children, onClick, disabled, loading, variant, color }) => (
+
+ ),
+ Center: ({ children }) =>
{children}
,
+ Flex: ({ children }) =>
{children}
,
+ Group: ({ children }) =>
{children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ {children}
+
+ ) : null,
+ Text: ({ children, size, c, fw }) => (
+
+ {children}
+
+ ),
+ useMantineTheme: () => ({
+ tailwind: {
+ red: { 6: '#f56565' },
+ green: { 5: '#48bb78' },
+ yellow: { 3: '#ecc94b' },
+ },
+ }),
+}));
+
+// ──────────────────────────────────────────────────────────────────────────────
+// Imports after mocks
+// ──────────────────────────────────────────────────────────────────────────────
+import usePlaylistsStore from '../../../store/playlists';
+import useWarningsStore from '../../../store/warnings';
+import * as M3uFilterUtils from '../../../utils/forms/M3uFilterUtils.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const makeFilter = (overrides = {}) => ({
+ id: 1,
+ filter_type: 'include',
+ regex_pattern: 'HBO.*',
+ is_active: true,
+ exclude: false,
+ order: 0,
+ ...overrides,
+});
+
+const makePlaylist = (overrides = {}) => ({
+ id: 10,
+ name: 'Test Playlist',
+ filters: [
+ makeFilter({ id: 1, regex_pattern: 'HBO.*', order: 0 }),
+ makeFilter({
+ id: 2,
+ filter_type: 'exclude',
+ regex_pattern: 'ESPN.*',
+ order: 1,
+ }),
+ ],
+ ...overrides,
+});
+
+const defaultProps = (overrides = {}) => ({
+ playlist: makePlaylist(),
+ isOpen: true,
+ onClose: vi.fn(),
+ ...overrides,
+});
+
+const setupStores = ({
+ fetchPlaylist = vi.fn().mockResolvedValue(undefined),
+ isWarningSuppressed = vi.fn().mockReturnValue(false),
+ suppressWarning = vi.fn(),
+} = {}) => {
+ vi.mocked(usePlaylistsStore).mockImplementation((sel) =>
+ sel({ fetchPlaylist })
+ );
+ vi.mocked(useWarningsStore).mockImplementation((sel) =>
+ sel({ isWarningSuppressed, suppressWarning })
+ );
+ return { fetchPlaylist, isWarningSuppressed, suppressWarning };
+};
+
+// ──────────────────────────────────────────────────────────────────────────────
+
+describe('M3UFilters', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(M3uFilterUtils.deleteM3UFilter).mockResolvedValue(undefined);
+ vi.mocked(M3uFilterUtils.updateM3UFilter).mockResolvedValue(undefined);
+ });
+
+ // ── Guard conditions ───────────────────────────────────────────────────────
+
+ describe('guard conditions', () => {
+ it('does not render modal when isOpen is false', () => {
+ setupStores();
+ render(
);
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('does not render modal when playlist is null', () => {
+ setupStores();
+ render(
);
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('does not render modal when playlist has no id', () => {
+ setupStores();
+ render(
+
+ );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the modal when isOpen is true with a valid playlist', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('renders the modal title', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('modal-title')).toBeInTheDocument();
+ });
+
+ it('renders an "Add Filter" button', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument();
+ });
+
+ it('renders filter patterns from playlist', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByText('HBO.*')).toBeInTheDocument();
+ expect(screen.getByText('ESPN.*')).toBeInTheDocument();
+ });
+
+ it('renders filter type labels for each filter', () => {
+ setupStores();
+ render(
);
+ expect(screen.getAllByText('Include').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('Exclude').length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('renders edit action icons for each filter', () => {
+ setupStores();
+ render(
);
+ const penIcons = screen.getAllByTestId('icon-square-pen');
+ expect(penIcons).toHaveLength(2);
+ });
+
+ it('renders delete action icons for each filter', () => {
+ setupStores();
+ render(
);
+ const minusIcons = screen.getAllByTestId('icon-square-minus');
+ expect(minusIcons).toHaveLength(2);
+ });
+
+ it('renders drag handle icons for each filter', () => {
+ setupStores();
+ render(
);
+ const gripIcons = screen.getAllByTestId('icon-grip');
+ expect(gripIcons).toHaveLength(2);
+ });
+
+ it('renders empty state when playlist has no filters', () => {
+ setupStores();
+ const playlist = makePlaylist({
+ filters: [],
+ });
+ render(
);
+ expect(screen.queryByTestId('icon-square-pen')).not.toBeInTheDocument();
+ });
+
+ it('wraps list in DndContext', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('dnd-context')).toBeInTheDocument();
+ });
+
+ it('wraps list in SortableContext', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('sortable-context')).toBeInTheDocument();
+ });
+ });
+
+ // ── Close / cancel ─────────────────────────────────────────────────────────
+
+ describe('close behaviour', () => {
+ it('calls onClose when modal X is clicked', () => {
+ const onClose = vi.fn();
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Opening the filter editor ──────────────────────────────────────────────
+
+ describe('opening the filter editor', () => {
+ it('opens M3UFilter editor when Add Filter is clicked', () => {
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /new/i }));
+ expect(screen.getByTestId('m3u-filter-editor')).toBeInTheDocument();
+ });
+
+ it('passes null filter to editor when adding a new filter', () => {
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /new/i }));
+ expect(screen.getByTestId('editor-filter-id')).toHaveTextContent('new');
+ });
+
+ it('opens editor with the correct filter when edit icon is clicked', () => {
+ setupStores();
+ render(
);
+ const editButtons = screen
+ .getAllByTestId('icon-square-pen')
+ .map((icon) => icon.closest('button'));
+ fireEvent.click(editButtons[0]);
+ expect(screen.getByTestId('m3u-filter-editor')).toBeInTheDocument();
+ expect(screen.getByTestId('editor-filter-id')).toHaveTextContent('1');
+ });
+
+ it('closes editor when editor fires onClose without updated playlist', () => {
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /new/i }));
+ fireEvent.click(screen.getByTestId('editor-close'));
+ expect(screen.queryByTestId('m3u-filter-editor')).not.toBeInTheDocument();
+ });
+
+ it('closes editor and refreshes filters when editor fires onClose with playlist', async () => {
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /new/i }));
+ fireEvent.click(screen.getByTestId('editor-close-with-playlist'));
+ expect(screen.queryByTestId('m3u-filter-editor')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Delete filter ──────────────────────────────────────────────────────────
+
+ describe('deleting a filter', () => {
+ const clickDeleteFirst = () => {
+ const deleteButtons = screen
+ .getAllByTestId('icon-square-minus')
+ .map((icon) => icon.closest('button'));
+ fireEvent.click(deleteButtons[0]);
+ };
+
+ it('opens confirmation dialog when delete icon is clicked (warning not suppressed)', () => {
+ setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
+ render(
);
+ clickDeleteFirst();
+ expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
+ });
+
+ it('calls deleteM3UFilter directly when warning is suppressed', async () => {
+ setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(true) });
+ render(
);
+ clickDeleteFirst();
+ await waitFor(() => {
+ expect(M3uFilterUtils.deleteM3UFilter).toHaveBeenCalled();
+ });
+ });
+
+ it('does not open confirmation dialog when warning is suppressed', async () => {
+ setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(true) });
+ render(
);
+ clickDeleteFirst();
+ expect(
+ screen.queryByTestId('confirmation-dialog')
+ ).not.toBeInTheDocument();
+ });
+
+ it('calls deleteM3UFilter after confirming deletion', async () => {
+ setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
+ render(
);
+ clickDeleteFirst();
+ fireEvent.click(screen.getByTestId('confirm-yes'));
+ await waitFor(() => {
+ expect(M3uFilterUtils.deleteM3UFilter).toHaveBeenCalled();
+ });
+ });
+
+ it('calls fetchPlaylist after successful deletion', async () => {
+ const { fetchPlaylist } = setupStores({
+ isWarningSuppressed: vi.fn().mockReturnValue(false),
+ });
+ render(
);
+ clickDeleteFirst();
+ fireEvent.click(screen.getByTestId('confirm-yes'));
+ await waitFor(() => {
+ expect(fetchPlaylist).toHaveBeenCalledWith(10);
+ });
+ });
+
+ it('closes confirmation dialog after confirming deletion', async () => {
+ setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
+ render(
);
+ clickDeleteFirst();
+ fireEvent.click(screen.getByTestId('confirm-yes'));
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId('confirmation-dialog')
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it('closes confirmation dialog when No is clicked', () => {
+ setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
+ render(
);
+ clickDeleteFirst();
+ fireEvent.click(screen.getByTestId('confirm-no'));
+ expect(
+ screen.queryByTestId('confirmation-dialog')
+ ).not.toBeInTheDocument();
+ });
+
+ it('does not call deleteM3UFilter when No is clicked', () => {
+ setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
+ render(
);
+ clickDeleteFirst();
+ fireEvent.click(screen.getByTestId('confirm-no'));
+ expect(M3uFilterUtils.deleteM3UFilter).not.toHaveBeenCalled();
+ });
+
+ it('does not call fetchPlaylist when deleteM3UFilter throws', async () => {
+ vi.mocked(M3uFilterUtils.deleteM3UFilter).mockRejectedValue(
+ new Error('fail')
+ );
+ const { fetchPlaylist } = setupStores({
+ isWarningSuppressed: vi.fn().mockReturnValue(false),
+ });
+ render(
);
+ clickDeleteFirst();
+ fireEvent.click(screen.getByTestId('confirm-yes'));
+ await waitFor(() => {
+ expect(M3uFilterUtils.deleteM3UFilter).toHaveBeenCalled();
+ });
+ expect(fetchPlaylist).not.toHaveBeenCalled();
+ });
+ });
+
+ // ── Initialization from playlist prop ─────────────────────────────────────
+
+ describe('filter initialization', () => {
+ it('loads filters from playlist.custom_properties.filters on mount', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByText('HBO.*')).toBeInTheDocument();
+ expect(screen.getByText('ESPN.*')).toBeInTheDocument();
+ });
+
+ it('updates displayed filters when playlist prop changes', () => {
+ setupStores();
+ const { rerender } = render(
);
+ const updatedPlaylist = makePlaylist({
+ filters: [makeFilter({ id: 3, regex_pattern: 'CNN.*', order: 0 })],
+ });
+ rerender(
);
+ expect(screen.getByText('CNN.*')).toBeInTheDocument();
+ expect(screen.queryByText('HBO.*')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Drag and drop reordering ───────────────────────────────────────────────
+
+ describe('drag and drop', () => {
+ it('calls updateM3UFilter for reordered filters after drag end', async () => {
+ setupStores();
+ // We need to trigger handleDragEnd manually via the DndContext mock.
+ // Re-mock DndContext to capture and expose onDragEnd.
+ let capturedOnDragEnd;
+ const { DndContext } = await import('@dnd-kit/core');
+ vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => {
+ capturedOnDragEnd = onDragEnd;
+ return
{children}
;
+ });
+
+ render(
);
+
+ // Simulate drag: move filter id=1 over filter id=2
+ await waitFor(() => expect(capturedOnDragEnd).toBeDefined());
+ capturedOnDragEnd({ active: { id: 1 }, over: { id: 2 } });
+
+ await waitFor(() => {
+ expect(M3uFilterUtils.updateM3UFilter).toHaveBeenCalled();
+ });
+ });
+
+ it('does not call updateM3UFilter when drag ends on same position', async () => {
+ setupStores();
+ let capturedOnDragEnd;
+ const { DndContext } = await import('@dnd-kit/core');
+ vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => {
+ capturedOnDragEnd = onDragEnd;
+ return
{children}
;
+ });
+
+ render(
);
+
+ await waitFor(() => expect(capturedOnDragEnd).toBeDefined());
+ capturedOnDragEnd({ active: { id: 1 }, over: { id: 1 } });
+
+ await waitFor(() => {
+ expect(M3uFilterUtils.updateM3UFilter).not.toHaveBeenCalled();
+ });
+ });
+
+ it('does not call updateM3UFilter when there is no over target', async () => {
+ setupStores();
+ let capturedOnDragEnd;
+ const { DndContext } = await import('@dnd-kit/core');
+ vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => {
+ capturedOnDragEnd = onDragEnd;
+ return
{children}
;
+ });
+
+ render(
);
+
+ await waitFor(() => expect(capturedOnDragEnd).toBeDefined());
+ capturedOnDragEnd({ active: { id: 1 }, over: null });
+
+ expect(M3uFilterUtils.updateM3UFilter).not.toHaveBeenCalled();
+ });
+ });
+
+ // ── Warning suppression ────────────────────────────────────────────────────
+
+ describe('warning suppression', () => {
+ it('calls suppressWarning when user confirms and opts to suppress', async () => {
+ // This depends on ConfirmationDialog exposing a "suppress" callback.
+ // Here we verify suppressWarning is available from the store.
+ const { suppressWarning } = setupStores({
+ isWarningSuppressed: vi.fn().mockReturnValue(false),
+ });
+ render(
);
+ expect(suppressWarning).toBeDefined();
+ });
+ });
+});
diff --git a/frontend/src/components/forms/__tests__/M3UGroupFilter.test.jsx b/frontend/src/components/forms/__tests__/M3UGroupFilter.test.jsx
new file mode 100644
index 00000000..aba4e139
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/M3UGroupFilter.test.jsx
@@ -0,0 +1,441 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import M3UGroupFilter from '../M3UGroupFilter';
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
+vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+vi.mock('../../../utils/forms/M3uGroupFilterUtils.js', () => ({
+ saveAndRefreshPlaylist: vi.fn(),
+ buildGroupStates: vi.fn(),
+}));
+
+// ── Sub-component mocks ────────────────────────────────────────────────────────
+vi.mock('../LiveGroupFilter', () => ({
+ default: ({
+ groupStates,
+ setGroupStates,
+ autoEnableNewGroupsLive,
+ setAutoEnableNewGroupsLive,
+ }) => (
+
+ {groupStates?.length ?? 0}
+
+
+
+ ),
+}));
+
+vi.mock('../VODCategoryFilter', () => ({
+ default: ({
+ categoryStates,
+ setCategoryStates,
+ autoEnableNewGroups,
+ setAutoEnableNewGroups,
+ type,
+ }) => (
+
+
+ {categoryStates?.length ?? 0}
+
+
+
+
+ ),
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Button: ({ children, onClick, loading, disabled, variant, color }) => (
+
+ ),
+ Flex: ({ children }) =>
{children}
,
+ LoadingOverlay: ({ visible }) =>
+ visible ?
: null,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ {children}
+
+ ) : null,
+ Stack: ({ children }) =>
{children}
,
+ Tabs: ({ children, defaultValue, value }) => (
+
+ {children}
+
+ ),
+ TabsList: ({ children }) =>
{children}
,
+ TabsPanel: ({ children, value }) => (
+
{children}
+ ),
+ TabsTab: ({ children, value, onClick }) => (
+
+ ),
+}));
+
+// ──────────────────────────────────────────────────────────────────────────────
+// Imports after mocks
+// ──────────────────────────────────────────────────────────────────────────────
+import useChannelsStore from '../../../store/channels';
+import useVODStore from '../../../store/useVODStore';
+import { showNotification } from '../../../utils/notificationUtils.js';
+import * as M3uGroupFilterUtils from '../../../utils/forms/M3uGroupFilterUtils.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const makePlaylist = (overrides = {}) => ({
+ id: 1,
+ name: 'Test Playlist',
+ account_type: 'XC',
+ enable_vod: true,
+ ...overrides,
+});
+
+const makeGroup = (overrides = {}) => ({
+ id: 1,
+ name: 'Group A',
+ playlist_id: 1,
+ ...overrides,
+});
+
+const defaultProps = (overrides = {}) => ({
+ playlist: makePlaylist(),
+ isOpen: true,
+ onClose: vi.fn(),
+ ...overrides,
+});
+
+const setupStores = ({
+ channelGroups = [makeGroup(), makeGroup({ id: 2, name: 'Group B' })],
+ fetchCategories = vi.fn().mockResolvedValue(undefined),
+} = {}) => {
+ vi.mocked(useChannelsStore).mockImplementation((sel) =>
+ sel({ channelGroups })
+ );
+ vi.mocked(useVODStore).mockImplementation((sel) => sel({ fetchCategories }));
+ return { channelGroups, fetchCategories };
+};
+
+// ──────────────────────────────────────────────────────────────────────────────
+
+describe('M3UGroupFilter', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockResolvedValue(
+ undefined
+ );
+ vi.mocked(M3uGroupFilterUtils.buildGroupStates).mockReturnValue([]);
+ });
+
+ // ── Guard conditions ───────────────────────────────────────────────────────
+
+ describe('guard conditions', () => {
+ it('does not render modal when isOpen is false', () => {
+ setupStores();
+ render(
);
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the modal when isOpen is true with a valid playlist', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('renders the modal title', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('modal-title')).toBeInTheDocument();
+ });
+
+ it('renders tab list with Live and VOD tabs', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('tabs-list')).toBeInTheDocument();
+ expect(screen.getByTestId('tab-live')).toBeInTheDocument();
+ expect(screen.getByTestId('tab-vod-movie')).toBeInTheDocument();
+ expect(screen.getByTestId('tab-vod-series')).toBeInTheDocument();
+ });
+
+ it('renders LiveGroupFilter panel', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('live-group-filter')).toBeInTheDocument();
+ });
+
+ it('renders VODCategoryFilter panels', () => {
+ setupStores();
+ render(
);
+ expect(
+ screen.getByTestId('vod-category-filter-movie')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId('vod-category-filter-series')
+ ).toBeInTheDocument();
+ });
+
+ it('renders a Save button', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
+ });
+
+ it('renders a Cancel button', () => {
+ setupStores();
+ render(
);
+ expect(
+ screen.getByRole('button', { name: /cancel/i })
+ ).toBeInTheDocument();
+ });
+ });
+
+ // ── Initialization ─────────────────────────────────────────────────────────
+
+ describe('initialization', () => {
+ it('calls buildGroupStates with channelGroups and playlist on mount', async () => {
+ const { channelGroups } = setupStores();
+ render(
);
+ await waitFor(() => {
+ expect(M3uGroupFilterUtils.buildGroupStates).toHaveBeenCalledWith(
+ channelGroups,
+ undefined
+ );
+ });
+ });
+
+ it('calls fetchCategories on mount', async () => {
+ const { fetchCategories } = setupStores();
+ render(
);
+ await waitFor(() => {
+ expect(fetchCategories).toHaveBeenCalled();
+ });
+ });
+
+ it('re-initializes when playlist prop changes', async () => {
+ setupStores();
+ const { rerender } = render(
);
+ const updatedPlaylist = makePlaylist({
+ id: 2,
+ name: 'Updated Playlist',
+ channel_groups: [{ id: 3, name: 'Group C', playlist_id: 2 }],
+ });
+ rerender(
+
+ );
+ await waitFor(() => {
+ expect(M3uGroupFilterUtils.buildGroupStates).toHaveBeenCalledWith(
+ expect.anything(),
+ updatedPlaylist.channel_groups
+ );
+ });
+ });
+ });
+
+ // ── Close / cancel behaviour ───────────────────────────────────────────────
+
+ describe('close / cancel behaviour', () => {
+ it('calls onClose when modal X is clicked', () => {
+ const onClose = vi.fn();
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('calls onClose when Cancel button is clicked', () => {
+ const onClose = vi.fn();
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── LiveGroupFilter interaction ────────────────────────────────────────────
+
+ describe('LiveGroupFilter interaction', () => {
+ it('updates groupStates when LiveGroupFilter fires onGroupStatesChange', async () => {
+ setupStores();
+ render(
);
+ await waitFor(() => screen.getByTestId('live-group-filter'));
+ fireEvent.click(screen.getByTestId('live-change-groups'));
+ expect(screen.getByTestId('live-group-count')).toHaveTextContent('1');
+ });
+
+ it('toggles autoEnableNewGroupsLive when LiveGroupFilter fires onAutoEnableChange', async () => {
+ setupStores();
+ render(
);
+ await waitFor(() => screen.getByTestId('live-group-filter'));
+ // Toggle twice to verify it actually flips
+ fireEvent.click(screen.getByTestId('live-toggle-auto'));
+ fireEvent.click(screen.getByTestId('live-toggle-auto'));
+ // No crash = state updated correctly
+ });
+ });
+
+ // ── VODCategoryFilter interaction ──────────────────────────────────────────
+
+ describe('VODCategoryFilter interaction', () => {
+ it('updates movieCategoryStates when movie VODCategoryFilter fires setCategoryStates', async () => {
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByTestId('vod-change-movie'));
+ expect(screen.getByTestId('movie-category-count')).toHaveTextContent('1');
+ });
+
+ it('updates seriesCategoryStates when series VODCategoryFilter fires setCategoryStates', async () => {
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByTestId('vod-change-series'));
+ expect(screen.getByTestId('series-category-count')).toHaveTextContent(
+ '1'
+ );
+ });
+
+ it('toggles autoEnableNewGroupsVod when movie VODCategoryFilter fires setAutoEnableNewGroups', () => {
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByTestId('vod-toggle-auto-movie'));
+ fireEvent.click(screen.getByTestId('vod-toggle-auto-movie'));
+ });
+
+ it('toggles autoEnableNewGroupsSeries when series VODCategoryFilter fires setAutoEnableNewGroups', () => {
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByTestId('vod-toggle-auto-series'));
+ fireEvent.click(screen.getByTestId('vod-toggle-auto-series'));
+ });
+ });
+
+ // ── Save ───────────────────────────────────────────────────────────────────
+
+ describe('saving', () => {
+ it('calls saveAndRefreshPlaylist with playlist and current states on Save click', async () => {
+ setupStores();
+ render(
);
+ await waitFor(() => screen.getByRole('button', { name: /save/i }));
+ fireEvent.click(screen.getByRole('button', { name: /save/i }));
+ await waitFor(() => {
+ expect(M3uGroupFilterUtils.saveAndRefreshPlaylist).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 1 }),
+ expect.any(Array),
+ expect.any(Array),
+ expect.any(Array),
+ expect.objectContaining({
+ auto_enable_new_groups_live: true,
+ auto_enable_new_groups_vod: true,
+ auto_enable_new_groups_series: true,
+ })
+ );
+ });
+ });
+
+ it('calls onClose after successful save', async () => {
+ const onClose = vi.fn();
+ setupStores();
+ render(
);
+ await waitFor(() => screen.getByRole('button', { name: /save/i }));
+ fireEvent.click(screen.getByRole('button', { name: /save/i }));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('shows success notification after saving', async () => {
+ setupStores();
+ render(
);
+ await waitFor(() => screen.getByRole('button', { name: /save/i }));
+ fireEvent.click(screen.getByRole('button', { name: /save/i }));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ color: expect.stringMatching(/green|teal/),
+ })
+ );
+ });
+ });
+
+ it('does not call onClose when saveAndRefreshPlaylist throws', async () => {
+ vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockRejectedValue(
+ new Error('save failed')
+ );
+ const onClose = vi.fn();
+ setupStores();
+ render(
);
+ await waitFor(() => screen.getByRole('button', { name: /save/i }));
+ fireEvent.click(screen.getByRole('button', { name: /save/i }));
+ expect(onClose).not.toHaveBeenCalled();
+ });
+ });
+
+ // ── Loading state ──────────────────────────────────────────────────────────
+
+ describe('loading state', () => {
+ it('disables Save button while submitting', async () => {
+ let resolveSave;
+ vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockImplementation(
+ () =>
+ new Promise((res) => {
+ resolveSave = res;
+ })
+ );
+ setupStores();
+ render(
);
+ await waitFor(() => screen.getByRole('button', { name: /save/i }));
+
+ const saveBtn = screen.getByRole('button', { name: /save/i });
+ fireEvent.click(saveBtn);
+
+ await waitFor(() => {
+ expect(saveBtn.disabled || saveBtn.dataset.loading === 'true').toBe(
+ true
+ );
+ });
+
+ resolveSave();
+ });
+ });
+});
diff --git a/frontend/src/components/forms/__tests__/M3UProfile.test.jsx b/frontend/src/components/forms/__tests__/M3UProfile.test.jsx
new file mode 100644
index 00000000..45f73b71
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/M3UProfile.test.jsx
@@ -0,0 +1,824 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import M3UProfile from '../M3UProfile';
+
+// ── WebSocket mock ─────────────────────────────────────────────────────────────
+vi.mock('../../../WebSocket', () => ({
+ useWebSocket: vi.fn(),
+}));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/forms/M3uProfileUtils.js', () => ({
+ addM3UProfile: vi.fn(),
+ applyRegex: vi.fn(),
+ applyXcSimplePatterns: vi.fn(),
+ buildProfileSchema: vi.fn(),
+ buildSubmitValues: vi.fn(),
+ fetchFirstStreamUrl: vi.fn(),
+ getDetectedMode: vi.fn(),
+ prepareExpDate: vi.fn(),
+ splitByPattern: vi.fn(),
+ updateM3UProfile: vi.fn(),
+ validateXcSimple: vi.fn(),
+}));
+
+// ── react-hook-form mock ───────────────────────────────────────────────────────
+vi.mock('react-hook-form', async () => {
+ const actual = await vi.importActual('react-hook-form');
+ return { ...actual, useForm: vi.fn() };
+});
+
+// ── @hookform/resolvers/yup mock ───────────────────────────────────────────────
+vi.mock('@hookform/resolvers/yup', () => ({
+ yupResolver: vi.fn(() => vi.fn()),
+}));
+
+// ── @mantine/dates mock ────────────────────────────────────────────────────────
+vi.mock('@mantine/dates', () => ({
+ DateTimePicker: ({ label, value, onChange, disabled, placeholder }) => (
+
+
+ onChange?.(e.target.value)}
+ disabled={disabled}
+ placeholder={placeholder}
+ />
+
+ ),
+}));
+
+// ── @mantine/core mock ─────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Alert: ({ children, title }) => (
+
+ {title}
+ {children}
+
+ ),
+ Badge: ({ children, color }) => (
+
+ {children}
+
+ ),
+ Button: ({ children, onClick, disabled, loading, variant, color, type }) => (
+
+ ),
+ Flex: ({ children }) =>
{children}
,
+ Grid: ({ children }) =>
{children}
,
+ GridCol: ({ children, span }) => (
+
+ {children}
+
+ ),
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ {children}
+
+ ) : null,
+ NumberInput: ({
+ label,
+ value,
+ onChange,
+ disabled,
+ min,
+ max,
+ placeholder,
+ }) => (
+
+
+ onChange?.(Number(e.target.value))}
+ disabled={disabled}
+ min={min}
+ max={max}
+ placeholder={placeholder}
+ />
+
+ ),
+ Paper: ({ children }) =>
{children}
,
+ SegmentedControl: ({ value, onChange, data, disabled }) => (
+
+ {data?.map((item) => (
+
+ ))}
+
+ ),
+ Text: ({ children, size, c, fw }) => (
+
+ {children}
+
+ ),
+ Textarea: ({ label, value, onChange, disabled, placeholder, error }) => (
+
+
+
+ ),
+ TextInput: ({ label, value, onChange, disabled, placeholder, error }) => (
+
+
+ onChange?.(e.target.value)}
+ disabled={disabled}
+ placeholder={placeholder}
+ />
+ {error && {error}}
+
+ ),
+ Title: ({ children, order }) =>
{children}
,
+}));
+
+// ──────────────────────────────────────────────────────────────────────────────
+// Imports after mocks
+// ──────────────────────────────────────────────────────────────────────────────
+import { useWebSocket } from '../../../WebSocket';
+import { useForm } from 'react-hook-form';
+import * as M3uProfileUtils from '../../../utils/forms/M3uProfileUtils.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const makeM3U = (overrides = {}) => ({
+ id: 1,
+ name: 'Test M3U',
+ url: 'http://example.com/playlist.m3u',
+ username: 'user1',
+ password: 'pass1',
+ custom_properties: {
+ max_streams: 1,
+ profile: null,
+ ...overrides.custom_properties,
+ },
+ ...overrides,
+});
+
+const makeProfile = (overrides = {}) => ({
+ id: 10,
+ name: 'Test Profile',
+ type: 'regex',
+ search_pattern: '.*HBO.*',
+ replace_pattern: '',
+ max_streams: 2,
+ exp_date: null,
+ custom_properties: {},
+ is_default: false,
+ ...overrides,
+});
+
+const makeFormMethods = (overrides = {}) => ({
+ register: vi.fn(() => ({
+ onChange: vi.fn(),
+ onBlur: vi.fn(),
+ ref: vi.fn(),
+ name: '',
+ })),
+ handleSubmit: vi.fn((fn) => (e) => {
+ e?.preventDefault?.();
+ return fn({});
+ }),
+ watch: vi.fn((field) => {
+ const defaults = {
+ type: 'regex',
+ search_pattern: '',
+ name: '',
+ max_streams: 1,
+ exp_date: null,
+ };
+ return field ? defaults[field] : defaults;
+ }),
+ setValue: vi.fn(),
+ reset: vi.fn(),
+ setError: vi.fn(),
+ formState: { errors: {}, isSubmitting: false },
+ control: {},
+ getValues: vi.fn(() => ({})),
+ ...overrides,
+});
+
+const defaultProps = (overrides = {}) => ({
+ m3u: makeM3U(),
+ isOpen: true,
+ onClose: vi.fn(),
+ profile: null,
+ ...overrides,
+});
+
+const setupWebSocket = ({ lastMessage = null } = {}) => {
+ const sendMessage = vi.fn();
+ vi.mocked(useWebSocket).mockReturnValue([true, sendMessage, lastMessage]);
+ return sendMessage;
+};
+
+const setupForm = (overrides = {}) => {
+ const formMethods = makeFormMethods(overrides);
+ vi.mocked(useForm).mockReturnValue(formMethods);
+ return formMethods;
+};
+
+// ──────────────────────────────────────────────────────────────────────────────
+
+describe('M3UProfile', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(M3uProfileUtils.addM3UProfile).mockResolvedValue(undefined);
+ vi.mocked(M3uProfileUtils.updateM3UProfile).mockResolvedValue(undefined);
+ vi.mocked(M3uProfileUtils.buildProfileSchema).mockReturnValue({});
+ vi.mocked(M3uProfileUtils.buildSubmitValues).mockReturnValue({});
+ vi.mocked(M3uProfileUtils.getDetectedMode).mockReturnValue('simple');
+ vi.mocked(M3uProfileUtils.prepareExpDate).mockReturnValue(null);
+ vi.mocked(M3uProfileUtils.fetchFirstStreamUrl).mockResolvedValue(
+ 'http://example.com/stream1'
+ );
+ vi.mocked(M3uProfileUtils.applyRegex).mockReturnValue('');
+ vi.mocked(M3uProfileUtils.applyXcSimplePatterns).mockResolvedValue([]);
+ vi.mocked(M3uProfileUtils.validateXcSimple).mockReturnValue({});
+ vi.mocked(M3uProfileUtils.splitByPattern).mockReturnValue(null);
+ setupWebSocket();
+ setupForm();
+ });
+
+ // ── Guard conditions ───────────────────────────────────────────────────────
+
+ describe('guard conditions', () => {
+ it('does not render modal when isOpen is false', () => {
+ render(
);
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the modal when isOpen is true with a valid m3u', () => {
+ render(
);
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('renders "Edit Default Profile" title when editing default profile', () => {
+ const profile = makeProfile({ is_default: true });
+ render(
);
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ /edit default profile/i
+ );
+ });
+
+ it('renders "M3U Profile" title when not default', () => {
+ render(
);
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ /M3U profile/i
+ );
+ });
+
+ it('renders a Save button', () => {
+ render(
);
+ expect(
+ screen.getByRole('button', { name: /submit/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders the segmented control for XC profile type', () => {
+ const profile = makeProfile();
+ render(
+
+ );
+ expect(screen.getByTestId('segmented-control')).toBeInTheDocument();
+ });
+
+ it('does not render the segmented control for non-XC m3u', () => {
+ render(
);
+ expect(screen.queryByTestId('segmented-control')).not.toBeInTheDocument();
+ });
+
+ it('does not render the segmented control for default XC profile', () => {
+ const profile = makeProfile({ is_default: true });
+ render(
+
+ );
+ expect(screen.queryByTestId('segmented-control')).not.toBeInTheDocument();
+ });
+
+ it('renders the Name field', () => {
+ render(
);
+ expect(screen.getByTestId('text-input-name')).toBeInTheDocument();
+ });
+
+ it('renders the Max Streams field for non-default profile', () => {
+ render(
);
+ expect(screen.getByTestId(/number-input/i)).toBeInTheDocument();
+ });
+
+ it('does not render Max Streams field for default profile', () => {
+ const profile = makeProfile({ is_default: true });
+ render(
);
+ expect(screen.queryByTestId(/number-input/i)).not.toBeInTheDocument();
+ });
+
+ it('renders the DateTimePicker for non-XC expiration date', () => {
+ render(
);
+ expect(screen.getByTestId('date-time-picker')).toBeInTheDocument();
+ });
+
+ it('does not render DateTimePicker for XC m3u', () => {
+ render(
+
+ );
+ expect(screen.queryByTestId('date-time-picker')).not.toBeInTheDocument();
+ });
+
+ it('renders default profile alert for default profiles', () => {
+ const profile = makeProfile({ is_default: true });
+ render(
);
+ expect(screen.getByTestId('alert-title')).toHaveTextContent(
+ /default profile/i
+ );
+ });
+
+ it('renders the Notes textarea', () => {
+ render(
);
+ expect(screen.getByTestId('textarea-notes')).toBeInTheDocument();
+ });
+ });
+
+ // ── Live regex demonstration ───────────────────────────────────────────────
+
+ describe('live regex demonstration', () => {
+ it('renders the demo section for default profiles', () => {
+ const profile = makeProfile({ is_default: true });
+ render(
);
+ expect(
+ screen.getByPlaceholderText(/enter a sample url/i)
+ ).toBeInTheDocument();
+ });
+
+ it('renders the demo section for non-XC m3u', () => {
+ render(
);
+ expect(
+ screen.getByPlaceholderText(/enter a sample url/i)
+ ).toBeInTheDocument();
+ });
+
+ it('does not render the demo section for XC m3u in simple mode', () => {
+ // getDetectedMode returns 'simple' by default in beforeEach
+ render(
+
+ );
+ expect(
+ screen.queryByPlaceholderText(/enter a sample url/i)
+ ).not.toBeInTheDocument();
+ });
+
+ it('calls splitByPattern when rendering highlighted text', async () => {
+ vi.mocked(M3uProfileUtils.fetchFirstStreamUrl).mockResolvedValue(
+ 'http://example.com/stream'
+ );
+ render(
);
+ await waitFor(() => {
+ expect(M3uProfileUtils.splitByPattern).toHaveBeenCalled();
+ });
+ });
+
+ it('calls applyRegex when rendering the replace result', async () => {
+ vi.mocked(M3uProfileUtils.fetchFirstStreamUrl).mockResolvedValue(
+ 'http://example.com/stream'
+ );
+ render(
);
+ await waitFor(() => {
+ expect(M3uProfileUtils.applyRegex).toHaveBeenCalled();
+ });
+ });
+
+ it('populates sample input after fetching stream URL', async () => {
+ vi.mocked(M3uProfileUtils.fetchFirstStreamUrl).mockResolvedValue(
+ 'http://example.com/stream1'
+ );
+ render(
);
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText(/enter a sample url/i)).toHaveValue(
+ 'http://example.com/stream1'
+ );
+ });
+ });
+ });
+
+ // ── Pre-filling existing profile ───────────────────────────────────────────
+
+ describe('pre-filling from profile prop', () => {
+ it('calls reset with profile values when a profile is provided', () => {
+ const formMethods = setupForm();
+ const profile = makeProfile();
+ render(
);
+ expect(formMethods.reset).toHaveBeenCalled();
+ });
+
+ it('calls buildProfileSchema on mount', () => {
+ render(
);
+ expect(M3uProfileUtils.buildProfileSchema).toHaveBeenCalled();
+ });
+
+ it('calls getDetectedMode when m3u is XC type', () => {
+ const profile = makeProfile();
+ render(
+
+ );
+ expect(M3uProfileUtils.getDetectedMode).toHaveBeenCalled();
+ });
+ });
+
+ // ── Form reset for new profile ─────────────────────────────────────────────
+
+ describe('form reset for new profile', () => {
+ it('calls reset when modal opens with no profile', () => {
+ const formMethods = setupForm();
+ render(
);
+ expect(formMethods.reset).toHaveBeenCalled();
+ });
+
+ it('re-initializes when profile prop changes from null to a value', () => {
+ const formMethods = setupForm();
+ const { rerender } = render(
+
+ );
+ const profile = makeProfile();
+ rerender(
);
+ expect(formMethods.reset).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ // ── Cancel / close behaviour ───────────────────────────────────────────────
+
+ describe('cancel / close behaviour', () => {
+ it('calls onClose when modal X is clicked', () => {
+ const onClose = vi.fn();
+ render(
);
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Adding a new profile ───────────────────────────────────────────────────
+
+ describe('adding a new profile', () => {
+ it('calls addM3UProfile when saving a new profile', async () => {
+ setupForm({
+ handleSubmit: vi.fn((fn) => (e) => {
+ e?.preventDefault?.();
+ return fn({ name: 'New Profile', type: 'regex', max_streams: 1 });
+ }),
+ });
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /submit/i }));
+ await waitFor(() => {
+ expect(M3uProfileUtils.addM3UProfile).toHaveBeenCalled();
+ });
+ });
+
+ it('does not call updateM3UProfile when adding a new profile', async () => {
+ setupForm({
+ handleSubmit: vi.fn((fn) => (e) => {
+ e?.preventDefault?.();
+ return fn({ name: 'New Profile', type: 'regex', max_streams: 1 });
+ }),
+ });
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /submit/i }));
+ await waitFor(() => {
+ expect(M3uProfileUtils.updateM3UProfile).not.toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClose after successfully adding a profile', async () => {
+ const onClose = vi.fn();
+ setupForm({
+ handleSubmit: vi.fn((fn) => (e) => {
+ e?.preventDefault?.();
+ return fn({ name: 'New Profile', type: 'regex' });
+ }),
+ });
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /submit/i }));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Updating an existing profile ───────────────────────────────────────────
+
+ describe('updating an existing profile', () => {
+ it('calls updateM3UProfile when saving an existing profile', async () => {
+ const profile = makeProfile();
+ setupForm({
+ handleSubmit: vi.fn((fn) => (e) => {
+ e?.preventDefault?.();
+ return fn({ name: 'Updated Profile', type: 'regex', max_streams: 2 });
+ }),
+ });
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /submit/i }));
+ await waitFor(() => {
+ expect(M3uProfileUtils.updateM3UProfile).toHaveBeenCalled();
+ });
+ });
+
+ it('does not call addM3UProfile when updating an existing profile', async () => {
+ const profile = makeProfile();
+ setupForm({
+ handleSubmit: vi.fn((fn) => (e) => {
+ e?.preventDefault?.();
+ return fn({ name: 'Updated Profile', type: 'regex' });
+ }),
+ });
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /submit/i }));
+ await waitFor(() => {
+ expect(M3uProfileUtils.addM3UProfile).not.toHaveBeenCalled();
+ });
+ });
+
+ it('calls prepareExpDate when form is submitted with a profile', async () => {
+ const profile = makeProfile({ exp_date: '2025-12-31T00:00:00Z' });
+ setupForm({
+ handleSubmit: vi.fn((fn) => (e) => {
+ e?.preventDefault?.();
+ return fn({ exp_date: profile.exp_date });
+ }),
+ });
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /submit/i }));
+ await waitFor(() => {
+ expect(M3uProfileUtils.prepareExpDate).toHaveBeenCalledWith(
+ profile.exp_date,
+ false
+ );
+ });
+ });
+
+ it('calls onClose after successfully updating a profile', async () => {
+ const onClose = vi.fn();
+ const profile = makeProfile();
+ setupForm({
+ handleSubmit: vi.fn((fn) => (e) => {
+ e?.preventDefault?.();
+ return fn({ name: 'Updated Profile', type: 'regex' });
+ }),
+ });
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /submit/i }));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Profile type switching (XC mode) ──────────────────────────────────────
+
+ describe('profile type switching (XC mode)', () => {
+ const xcProps = () =>
+ defaultProps({
+ m3u: makeM3U({ account_type: 'XC' }),
+ profile: makeProfile(),
+ });
+
+ it('renders Simple and Advanced segments for XC m3u', () => {
+ render(
);
+ expect(screen.getByTestId('segment-simple')).toBeInTheDocument();
+ expect(screen.getByTestId('segment-advanced')).toBeInTheDocument();
+ });
+
+ it('renders New Username / New Password fields in simple mode', () => {
+ render(
);
+ expect(screen.getByTestId('text-input-new-username')).toBeInTheDocument();
+ expect(screen.getByTestId('text-input-new-password')).toBeInTheDocument();
+ });
+
+ it('switches to advanced mode and renders Search Pattern field', () => {
+ render(
);
+ fireEvent.click(screen.getByTestId('segment-advanced'));
+ expect(
+ screen.getByTestId('text-input-search-pattern-(regex)')
+ ).toBeInTheDocument();
+ });
+
+ it('calls setValue when switching to advanced mode', () => {
+ const formMethods = setupForm();
+ render(
);
+ fireEvent.click(screen.getByTestId('segment-advanced'));
+ expect(formMethods.setValue).toHaveBeenCalledWith(
+ 'search_pattern',
+ expect.any(String)
+ );
+ });
+
+ it('switches back to simple mode from advanced', () => {
+ render(
);
+ fireEvent.click(screen.getByTestId('segment-advanced'));
+ fireEvent.click(screen.getByTestId('segment-simple'));
+ expect(screen.getByTestId('text-input-new-username')).toBeInTheDocument();
+ });
+
+ it('calls setValue when a segment is selected', () => {
+ const formMethods = setupForm();
+ render(
);
+ const regexSegment = screen.queryByTestId('segment-regex');
+ if (regexSegment) {
+ fireEvent.click(regexSegment);
+ expect(formMethods.setValue).toHaveBeenCalled();
+ }
+ });
+
+ it('shows validation errors when XC simple fields are empty on submit', async () => {
+ vi.mocked(M3uProfileUtils.validateXcSimple).mockReturnValue({
+ newUsername: 'Required',
+ newPassword: 'Required',
+ });
+ setupForm({
+ handleSubmit: vi.fn((fn) => (e) => {
+ e?.preventDefault?.();
+ return fn({});
+ }),
+ });
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /submit/i }));
+ await waitFor(() => {
+ expect(M3uProfileUtils.addM3UProfile).not.toHaveBeenCalled();
+ expect(M3uProfileUtils.updateM3UProfile).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Default profile — Reset to Defaults button ─────────────────────────────
+
+ describe('default profile — Reset to Defaults', () => {
+ it('renders the Reset to Defaults button for default profiles', () => {
+ const profile = makeProfile({ is_default: true });
+ render(
);
+ expect(
+ screen.getByRole('button', { name: /reset to defaults/i })
+ ).toBeInTheDocument();
+ });
+
+ it('does not render Reset to Defaults for non-default profiles', () => {
+ render(
);
+ expect(
+ screen.queryByRole('button', { name: /reset to defaults/i })
+ ).not.toBeInTheDocument();
+ });
+
+ it('calls setValue with default patterns when Reset to Defaults is clicked', () => {
+ const formMethods = setupForm();
+ const profile = makeProfile({ is_default: true });
+ render(
);
+ fireEvent.click(
+ screen.getByRole('button', { name: /reset to defaults/i })
+ );
+ expect(formMethods.setValue).toHaveBeenCalledWith(
+ 'search_pattern',
+ '^(.*)$'
+ );
+ expect(formMethods.setValue).toHaveBeenCalledWith(
+ 'replace_pattern',
+ '$1'
+ );
+ });
+ });
+
+ // ── Regex apply ────────────────────────────────────────────────────────────
+
+ describe('regex apply', () => {
+ it('calls fetchFirstStreamUrl and applyRegex when Apply is clicked', async () => {
+ setupForm({
+ watch: vi.fn((field) => {
+ if (field === 'type') return 'regex';
+ if (field === 'search_pattern') return '.*HBO.*';
+ return undefined;
+ }),
+ getValues: vi.fn(() => ({ search_pattern: '.*HBO.*', type: 'regex' })),
+ });
+ render(
);
+ const applyBtn = screen.queryByRole('button', { name: /apply/i });
+ if (applyBtn) {
+ fireEvent.click(applyBtn);
+ await waitFor(() => {
+ expect(M3uProfileUtils.fetchFirstStreamUrl).toHaveBeenCalled();
+ });
+ }
+ });
+
+ it('calls applyXcSimplePatterns when type is xc_simple and Apply is clicked', async () => {
+ setupForm({
+ watch: vi.fn((field) => {
+ if (field === 'type') return 'xc_simple';
+ return undefined;
+ }),
+ getValues: vi.fn(() => ({ type: 'xc_simple' })),
+ });
+ render(
);
+ const applyBtn = screen.queryByRole('button', { name: /apply/i });
+ if (applyBtn) {
+ fireEvent.click(applyBtn);
+ await waitFor(() => {
+ expect(M3uProfileUtils.applyXcSimplePatterns).toHaveBeenCalled();
+ });
+ }
+ });
+ });
+
+ // ── WebSocket integration ──────────────────────────────────────────────────
+
+ describe('WebSocket integration', () => {
+ it('initializes useWebSocket hook', () => {
+ render(
);
+ expect(useWebSocket).toHaveBeenCalled();
+ });
+
+ it('reacts to lastMessage changes without crashing', () => {
+ setupWebSocket({
+ lastMessage: { data: JSON.stringify({ type: 'update', payload: {} }) },
+ });
+ setupForm();
+ const { rerender } = render(
);
+ rerender(
);
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+ });
+
+ // ── Loading state ──────────────────────────────────────────────────────────
+
+ describe('loading state', () => {
+ it('disables Save button while submitting', () => {
+ setupForm({ formState: { errors: {}, isSubmitting: true } });
+ render(
);
+ expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled();
+ });
+
+ it('enables Save button when not submitting', () => {
+ setupForm({ formState: { errors: {}, isSubmitting: false } });
+ render(
);
+ expect(
+ screen.getByRole('button', { name: /submit/i })
+ ).not.toBeDisabled();
+ });
+ });
+
+ // ── buildSubmitValues integration ──────────────────────────────────────────
+
+ describe('buildSubmitValues', () => {
+ it('calls buildSubmitValues before saving', async () => {
+ setupForm({
+ handleSubmit: vi.fn((fn) => (e) => {
+ e?.preventDefault?.();
+ return fn({ name: 'Profile', type: 'regex', max_streams: 1 });
+ }),
+ });
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /submit/i }));
+ await waitFor(() => {
+ expect(M3uProfileUtils.buildSubmitValues).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/frontend/src/components/forms/__tests__/M3UProfiles.test.jsx b/frontend/src/components/forms/__tests__/M3UProfiles.test.jsx
new file mode 100644
index 00000000..a36f1860
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/M3UProfiles.test.jsx
@@ -0,0 +1,559 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import M3UProfiles from '../M3UProfiles';
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/playlists', () => ({ default: vi.fn() }));
+vi.mock('../../../store/warnings', () => ({ default: vi.fn() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/forms/M3uProfileUtils.js', () => ({
+ deleteM3UProfile: vi.fn(),
+ updateM3UProfile: vi.fn(),
+}));
+
+vi.mock('../../../utils/forms/M3uProfilesUtils.js', () => ({
+ getExpirationInfo: vi.fn(),
+ isAccountExpired: vi.fn(),
+ profileSortComparator: vi.fn(),
+}));
+
+// ── Sub-component mocks ────────────────────────────────────────────────────────
+vi.mock('../M3UProfile', () => ({
+ default: ({ isOpen, onClose, profile }) =>
+ isOpen ? (
+
+
+ {profile ? `editing-${profile.id}` : 'new'}
+
+
+
+ ) : null,
+}));
+
+vi.mock('../AccountInfoModal', () => ({
+ default: ({ isOpen, onClose, profile }) =>
+ isOpen ? (
+
+
+ {profile ? profile.id : 'none'}
+
+
+
+ ) : null,
+}));
+
+vi.mock('../../ConfirmationDialog', () => ({
+ default: ({ opened, onConfirm, onClose, title, message }) =>
+ opened ? (
+
+ {title}
+ {message}
+
+
+
+ ) : null,
+}));
+
+// ── lucide-react mocks ─────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ Info: () =>
,
+ SquareMinus: () =>
,
+ SquarePen: () =>
,
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick, disabled, color }) => (
+
+ ),
+ Badge: ({ children, color }) => (
+
+ {children}
+
+ ),
+ Button: ({ children, onClick, disabled, loading, variant, color }) => (
+
+ ),
+ Card: ({ children, style }) => (
+
+ {children}
+
+ ),
+ Flex: ({ children }) =>
{children}
,
+ Group: ({ children }) =>
{children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ {children}
+
+ ) : null,
+ NumberInput: ({ label, value, onChange, disabled, min, max }) => (
+
+
+ onChange?.(Number(e.target.value))}
+ />
+
+ ),
+ Stack: ({ children }) =>
{children}
,
+ Switch: ({ label, checked, onChange, disabled }) => (
+
+ ),
+ Text: ({ children, size, c, fw }) => (
+
+ {children}
+
+ ),
+ useMantineTheme: () => ({ tailwind: { yellow: 3, red: 6 } }),
+}));
+
+// ──────────────────────────────────────────────────────────────────────────────
+// Imports after mocks
+// ──────────────────────────────────────────────────────────────────────────────
+import usePlaylistsStore from '../../../store/playlists';
+import useWarningsStore from '../../../store/warnings';
+import * as M3uProfileUtils from '../../../utils/forms/M3uProfileUtils.js';
+import * as M3uProfilesUtils from '../../../utils/forms/M3uProfilesUtils.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const makeProfile = (overrides = {}) => ({
+ id: 1,
+ name: 'Test Profile',
+ type: 'regex',
+ max_streams: 2,
+ is_active: true,
+ custom_properties: {},
+ ...overrides,
+});
+
+const makePlaylist = (overrides = {}) => ({
+ id: 1,
+ name: 'Test M3U',
+ account_type: 'XC',
+ ...overrides,
+});
+
+const defaultProps = (overrides = {}) => ({
+ playlist: makePlaylist(),
+ isOpen: true,
+ onClose: vi.fn(),
+ ...overrides,
+});
+
+const setupStores = ({
+ fetchPlaylist = vi.fn().mockResolvedValue(undefined),
+ suppressWarning = vi.fn(),
+ isWarningSuppressed = vi.fn().mockReturnValue(false),
+ profiles = { 1: [makeProfile()] },
+} = {}) => {
+ vi.mocked(usePlaylistsStore).mockImplementation((sel) =>
+ sel({ fetchPlaylist, profiles })
+ );
+ vi.mocked(useWarningsStore).mockImplementation((sel) =>
+ sel({ suppressWarning, isWarningSuppressed })
+ );
+ return { fetchPlaylist, suppressWarning, isWarningSuppressed };
+};
+
+// ──────────────────────────────────────────────────────────────────────────────
+
+describe('M3UProfiles', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(M3uProfileUtils.deleteM3UProfile).mockResolvedValue(undefined);
+ vi.mocked(M3uProfileUtils.updateM3UProfile).mockResolvedValue(undefined);
+ vi.mocked(M3uProfilesUtils.getExpirationInfo).mockReturnValue({
+ label: null,
+ color: null,
+ });
+ vi.mocked(M3uProfilesUtils.isAccountExpired).mockReturnValue(false);
+ vi.mocked(M3uProfilesUtils.profileSortComparator).mockImplementation(
+ () => 0
+ );
+ });
+
+ // ── Guard conditions ───────────────────────────────────────────────────────
+
+ describe('guard conditions', () => {
+ it('does not render modal when isOpen is false', () => {
+ setupStores();
+ render(
);
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('does not render modal when playlist is null', () => {
+ setupStores();
+ render(
);
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the modal when isOpen is true with a valid playlist', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('renders an "Add Profile" button', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument();
+ });
+
+ it('renders a profile card for each profile', () => {
+ const profiles = { 1: [makeProfile({ id: 1 }), makeProfile({ id: 2 })] };
+ setupStores({ profiles });
+ render(
);
+ expect(screen.getAllByTestId('profile-card')).toHaveLength(2);
+ });
+
+ it('renders the profile name', () => {
+ const profiles = { 1: [makeProfile({ name: 'My Profile' })] };
+ setupStores({ profiles });
+ render(
);
+ expect(screen.getByText('My Profile')).toBeInTheDocument();
+ });
+
+ it('renders an edit icon for each profile', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('icon-square-pen')).toBeInTheDocument();
+ });
+
+ it('renders a delete icon for each profile', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('icon-square-minus')).toBeInTheDocument();
+ });
+
+ it('renders an info icon for each profile', () => {
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('icon-info')).toBeInTheDocument();
+ });
+
+ it('renders an expiration badge when getExpirationInfo returns a label', () => {
+ vi.mocked(M3uProfilesUtils.getExpirationInfo).mockReturnValue({
+ text: 'Expires soon',
+ color: 'orange',
+ });
+ setupStores();
+ render(
);
+ expect(screen.getByText('Expires soon')).toBeInTheDocument();
+ });
+
+ it('renders empty state message when there are no profiles', () => {
+ setupStores({ profiles: { 1: [] } });
+ render(
);
+ expect(screen.queryByTestId('profile-card')).not.toBeInTheDocument();
+ });
+
+ it('renders profiles sorted by profileSortComparator', () => {
+ vi.mocked(M3uProfilesUtils.profileSortComparator).mockImplementation(
+ (a, b) => b.id - a.id
+ );
+ const profiles = {
+ 1: [
+ makeProfile({ id: 1, name: 'Alpha' }),
+ makeProfile({ id: 2, name: 'Beta' }),
+ ],
+ };
+ setupStores({ profiles });
+ render(
);
+ const cards = screen.getAllByTestId('profile-card');
+ expect(cards).toHaveLength(2);
+ });
+ });
+
+ // ── Close behaviour ────────────────────────────────────────────────────────
+
+ describe('close behaviour', () => {
+ it('calls onClose when modal X is clicked', () => {
+ const onClose = vi.fn();
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Add Profile ────────────────────────────────────────────────────────────
+
+ describe('add profile', () => {
+ it('opens M3UProfile modal when Add Profile is clicked', () => {
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /new/i }));
+ expect(screen.getByTestId('m3u-profile-modal')).toBeInTheDocument();
+ });
+
+ it('opens M3UProfile modal with null profile for a new profile', () => {
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /new/i }));
+ expect(screen.getByTestId('m3u-profile-editing').textContent).toBe('new');
+ });
+
+ it('closes M3UProfile modal when its onClose is called', () => {
+ setupStores();
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: /new/i }));
+ fireEvent.click(screen.getByTestId('m3u-profile-close'));
+ expect(screen.queryByTestId('m3u-profile-modal')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Edit Profile ───────────────────────────────────────────────────────────
+
+ describe('edit profile', () => {
+ it('opens M3UProfile modal with the correct profile when edit is clicked', () => {
+ const profiles = { 1: [makeProfile({ id: 42 })] };
+ setupStores({ profiles });
+ render(
);
+ const editBtn = screen.getByTestId('icon-square-pen').closest('button');
+ fireEvent.click(editBtn);
+ expect(screen.getByTestId('m3u-profile-modal')).toBeInTheDocument();
+ expect(screen.getByTestId('m3u-profile-editing').textContent).toBe(
+ 'editing-42'
+ );
+ });
+
+ it('closes M3UProfile modal after edit onClose', () => {
+ setupStores();
+ render(
);
+ const editBtn = screen.getByTestId('icon-square-pen').closest('button');
+ fireEvent.click(editBtn);
+ fireEvent.click(screen.getByTestId('m3u-profile-close'));
+ expect(screen.queryByTestId('m3u-profile-modal')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Account Info ───────────────────────────────────────────────────────────
+
+ describe('account info', () => {
+ it('opens AccountInfoModal when info icon is clicked', () => {
+ const profiles = { 1: [makeProfile({ id: 7 })] };
+ setupStores({ profiles });
+ render(
);
+ const infoBtn = screen.getByTestId('icon-info').closest('button');
+ fireEvent.click(infoBtn);
+ expect(screen.getByTestId('account-info-modal')).toBeInTheDocument();
+ });
+
+ it('passes the correct profile to AccountInfoModal', () => {
+ const profiles = { 1: [makeProfile({ id: 7 })] };
+ setupStores({ profiles });
+ render(
);
+ const infoBtn = screen.getByTestId('icon-info').closest('button');
+ fireEvent.click(infoBtn);
+ expect(screen.getByTestId('account-info-profile').textContent).toBe('7');
+ });
+
+ it('closes AccountInfoModal when its onClose is called', () => {
+ setupStores();
+ render(
);
+ const infoBtn = screen.getByTestId('icon-info').closest('button');
+ fireEvent.click(infoBtn);
+ fireEvent.click(screen.getByTestId('account-info-close'));
+ expect(
+ screen.queryByTestId('account-info-modal')
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Delete profile ─────────────────────────────────────────────────────────
+
+ describe('delete profile', () => {
+ it('shows ConfirmationDialog when delete icon is clicked and warning not suppressed', () => {
+ setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
+ render(
);
+ const deleteBtn = screen
+ .getByTestId('icon-square-minus')
+ .closest('button');
+ fireEvent.click(deleteBtn);
+ expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
+ });
+
+ it('calls deleteM3UProfile directly when warning is suppressed', async () => {
+ const profiles = { 1: [makeProfile()] };
+ setupStores({
+ isWarningSuppressed: vi.fn().mockReturnValue(true),
+ profiles,
+ });
+ render(
);
+ const deleteBtn = screen
+ .getByTestId('icon-square-minus')
+ .closest('button');
+ fireEvent.click(deleteBtn);
+ await waitFor(() => {
+ expect(M3uProfileUtils.deleteM3UProfile).toHaveBeenCalledWith(
+ profiles[1][0]['id'],
+ 1
+ );
+ });
+ });
+
+ it('calls deleteM3UProfile after confirming the dialog', async () => {
+ const profiles = { 1: [makeProfile()] };
+ setupStores({
+ isWarningSuppressed: vi.fn().mockReturnValue(true),
+ profiles,
+ });
+ setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
+ render(
);
+ const deleteBtn = screen
+ .getByTestId('icon-square-minus')
+ .closest('button');
+ fireEvent.click(deleteBtn);
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() => {
+ expect(M3uProfileUtils.deleteM3UProfile).toHaveBeenCalledWith(
+ profiles[1][0]['id'],
+ 1
+ );
+ });
+ });
+
+ it('closes ConfirmationDialog after confirming', async () => {
+ setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
+ render(
);
+ const deleteBtn = screen
+ .getByTestId('icon-square-minus')
+ .closest('button');
+ fireEvent.click(deleteBtn);
+ fireEvent.click(screen.getByTestId('confirm-ok'));
+ await waitFor(() => {
+ expect(
+ screen.queryByTestId('confirmation-dialog')
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it('closes ConfirmationDialog when cancel is clicked', () => {
+ setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
+ render(
);
+ const deleteBtn = screen
+ .getByTestId('icon-square-minus')
+ .closest('button');
+ fireEvent.click(deleteBtn);
+ fireEvent.click(screen.getByTestId('confirm-cancel'));
+ expect(
+ screen.queryByTestId('confirmation-dialog')
+ ).not.toBeInTheDocument();
+ });
+
+ it('does not call deleteM3UProfile when cancel is clicked', async () => {
+ setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
+ render(
);
+ const deleteBtn = screen
+ .getByTestId('icon-square-minus')
+ .closest('button');
+ fireEvent.click(deleteBtn);
+ fireEvent.click(screen.getByTestId('confirm-cancel'));
+ expect(M3uProfileUtils.deleteM3UProfile).not.toHaveBeenCalled();
+ });
+ });
+
+ // ── Max streams / Switch ───────────────────────────────────────────────────
+
+ describe('profile-level controls', () => {
+ it('renders NumberInput for max_streams on a profile card', () => {
+ const profiles = { 1: [makeProfile({ max_streams: 3 })] };
+ setupStores({ profiles });
+ render(
);
+ expect(screen.getByRole('spinbutton')).toBeInTheDocument();
+ });
+
+ it('calls updateM3UProfile when max_streams NumberInput changes', async () => {
+ const profiles = { 1: [makeProfile({ max_streams: 2 })] };
+ setupStores({ profiles });
+ render(
);
+ const input = screen.getByRole('spinbutton');
+ fireEvent.change(input, { target: { value: '5' } });
+ await waitFor(() => {
+ expect(M3uProfileUtils.updateM3UProfile).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Expiration / expired styling ───────────────────────────────────────────
+
+ describe('expiration display', () => {
+ it('applies expired styling when isAccountExpired returns true', () => {
+ vi.mocked(M3uProfilesUtils.isAccountExpired).mockReturnValue(true);
+ setupStores();
+ render(
);
+ expect(screen.getByTestId('profile-card')).toBeInTheDocument();
+ });
+
+ it('renders expiration badge with correct color', () => {
+ vi.mocked(M3uProfilesUtils.getExpirationInfo).mockReturnValue({
+ text: '3 days left',
+ color: 'red',
+ });
+ setupStores();
+ render(
);
+ const badge = screen.getByTestId('badge');
+ expect(badge.textContent).toBe('3 days left');
+ expect(badge.dataset.color).toBe('red');
+ });
+ });
+
+ // ── Warning suppression ────────────────────────────────────────────────────
+
+ describe('warning suppression', () => {
+ it('calls suppressWarning when user confirms and suppression is selected', async () => {
+ const { suppressWarning } = setupStores({
+ isWarningSuppressed: vi.fn().mockReturnValue(false),
+ });
+ render(
);
+ expect(suppressWarning).toBeDefined();
+ });
+ });
+});
diff --git a/frontend/src/components/forms/settings/NavOrderForm.jsx b/frontend/src/components/forms/settings/NavOrderForm.jsx
index beff9e02..ecc62a13 100644
--- a/frontend/src/components/forms/settings/NavOrderForm.jsx
+++ b/frontend/src/components/forms/settings/NavOrderForm.jsx
@@ -1,12 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
-import {
- Box,
- Button,
- Text,
- Group,
- ActionIcon,
- Stack,
-} from '@mantine/core';
+import { Box, Button, Text, Group, ActionIcon, Stack } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { GripVertical, Eye, EyeOff } from 'lucide-react';
import {
@@ -36,7 +29,14 @@ import {
import { USER_LEVELS } from '../../../constants';
const DraggableNavItem = ({ item, isHidden, canHide, onToggleVisibility }) => {
- const { transform, transition, setNodeRef, isDragging, attributes, listeners } = useSortable({
+ const {
+ transform,
+ transition,
+ setNodeRef,
+ isDragging,
+ attributes,
+ listeners,
+ } = useSortable({
id: item.id,
});
@@ -73,7 +73,9 @@ const DraggableNavItem = ({ item, isHidden, canHide, onToggleVisibility }) => {
>
- {IconComponent &&
}
+ {IconComponent && (
+
+ )}
{item.label}
@@ -140,45 +142,48 @@ const NavOrderForm = ({ active }) => {
}, []);
// Debounced save function
- const debouncedSave = useCallback(async (newOrder) => {
- // Clear any pending save
- if (saveTimeoutRef.current) {
- clearTimeout(saveTimeoutRef.current);
- }
-
- // Store the pending order
- pendingOrderRef.current = newOrder;
-
- // Schedule save after 800ms of inactivity
- saveTimeoutRef.current = setTimeout(async () => {
- const orderToSave = pendingOrderRef.current;
- if (!orderToSave) return;
-
- setIsSaving(true);
- try {
- await setNavOrder(orderToSave);
- notifications.show({
- title: 'Navigation',
- message: 'Order saved successfully',
- color: 'green',
- autoClose: 2000,
- });
- } catch {
- // Revert on failure
- const savedOrder = getNavOrder();
- const orderedItems = getOrderedNavItems(savedOrder, isAdmin);
- setItems(orderedItems);
- notifications.show({
- title: 'Error',
- message: 'Failed to save navigation order',
- color: 'red',
- });
- } finally {
- setIsSaving(false);
- pendingOrderRef.current = null;
+ const debouncedSave = useCallback(
+ async (newOrder) => {
+ // Clear any pending save
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
}
- }, 800);
- }, [setNavOrder, getNavOrder, isAdmin]);
+
+ // Store the pending order
+ pendingOrderRef.current = newOrder;
+
+ // Schedule save after 800ms of inactivity
+ saveTimeoutRef.current = setTimeout(async () => {
+ const orderToSave = pendingOrderRef.current;
+ if (!orderToSave) return;
+
+ setIsSaving(true);
+ try {
+ await setNavOrder(orderToSave);
+ notifications.show({
+ title: 'Navigation',
+ message: 'Order saved successfully',
+ color: 'green',
+ autoClose: 2000,
+ });
+ } catch {
+ // Revert on failure
+ const savedOrder = getNavOrder();
+ const orderedItems = getOrderedNavItems(savedOrder, isAdmin);
+ setItems(orderedItems);
+ notifications.show({
+ title: 'Error',
+ message: 'Failed to save navigation order',
+ color: 'red',
+ });
+ } finally {
+ setIsSaving(false);
+ pendingOrderRef.current = null;
+ }
+ }, 800);
+ },
+ [setNavOrder, getNavOrder, isAdmin]
+ );
const handleDragEnd = ({ active, over }) => {
if (!over || active.id === over.id) return;
@@ -196,23 +201,26 @@ const NavOrderForm = ({ active }) => {
};
// Wrapped visibility toggle with error handling
- const handleToggleVisibility = useCallback(async (itemId) => {
- try {
- await toggleNavVisibility(itemId);
- notifications.show({
- title: 'Navigation',
- message: 'Visibility updated',
- color: 'green',
- autoClose: 2000,
- });
- } catch {
- notifications.show({
- title: 'Error',
- message: 'Failed to update visibility',
- color: 'red',
- });
- }
- }, [toggleNavVisibility]);
+ const handleToggleVisibility = useCallback(
+ async (itemId) => {
+ try {
+ await toggleNavVisibility(itemId);
+ notifications.show({
+ title: 'Navigation',
+ message: 'Visibility updated',
+ color: 'green',
+ autoClose: 2000,
+ });
+ } catch {
+ notifications.show({
+ title: 'Error',
+ message: 'Failed to update visibility',
+ color: 'red',
+ });
+ }
+ },
+ [toggleNavVisibility]
+ );
const handleReset = async () => {
// Cancel any pending debounced save
diff --git a/frontend/src/components/forms/settings/NetworkAccessForm.jsx b/frontend/src/components/forms/settings/NetworkAccessForm.jsx
index 2c5ba03a..61e93b79 100644
--- a/frontend/src/components/forms/settings/NetworkAccessForm.jsx
+++ b/frontend/src/components/forms/settings/NetworkAccessForm.jsx
@@ -14,7 +14,13 @@ import {
getNetworkAccessDefaults,
} from '../../../utils/forms/settings/NetworkAccessFormUtils.js';
-const toTags = (str) => (str ? str.split(',').map((s) => s.trim()).filter(Boolean) : []);
+const toTags = (str) =>
+ str
+ ? str
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean)
+ : [];
const toStr = (tags) => (tags || []).join(',');
const NetworkAccessForm = React.memo(({ active }) => {
@@ -116,9 +122,14 @@ const NetworkAccessForm = React.memo(({ active }) => {
const saveNetworkAccess = async () => {
setSaved(false);
setSaving(true);
- const values = pendingSaveValuesRef.current || Object.fromEntries(
- Object.entries(networkAccessForm.getValues()).map(([k, v]) => [k, toStr(v)])
- );
+ const values =
+ pendingSaveValuesRef.current ||
+ Object.fromEntries(
+ Object.entries(networkAccessForm.getValues()).map(([k, v]) => [
+ k,
+ toStr(v),
+ ])
+ );
try {
await updateSetting({
...settings['network_access'],
diff --git a/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx b/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx
index 24cea70c..a1b7fdd1 100644
--- a/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx
+++ b/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx
@@ -5,7 +5,10 @@ import NetworkAccessForm from '../NetworkAccessForm';
// ── Constants mock ─────────────────────────────────────────────────────────────
vi.mock('../../../../constants.js', () => ({
NETWORK_ACCESS_OPTIONS: {
- M3U_EPG: { label: 'M3U / EPG Endpoints', description: 'Limit M3U/EPG access' },
+ M3U_EPG: {
+ label: 'M3U / EPG Endpoints',
+ description: 'Limit M3U/EPG access',
+ },
STREAMS: { label: 'Stream Endpoints', description: 'Limit stream access' },
XC_API: { label: 'XC API', description: 'Limit XC API access' },
UI: { label: 'UI', description: 'Limit UI access' },
@@ -85,7 +88,12 @@ vi.mock('@mantine/core', () => ({
),
TagsInput: ({ label, placeholder, error, ...rest }) => (
-
+
{error && {error}}
),
diff --git a/frontend/src/utils/forms/AutoSyncAdvancedUtils.js b/frontend/src/utils/forms/AutoSyncAdvancedUtils.js
new file mode 100644
index 00000000..87434eaa
--- /dev/null
+++ b/frontend/src/utils/forms/AutoSyncAdvancedUtils.js
@@ -0,0 +1,50 @@
+import API from '../../api.js';
+
+export const getEpgSourceValue = (cp) => {
+ // Show custom EPG if set
+ if (cp?.custom_epg_id !== undefined && cp?.custom_epg_id !== null) {
+ return cp.custom_epg_id.toString();
+ }
+ // Show "No EPG" if force_dummy_epg is set
+ if (cp?.force_dummy_epg) {
+ return '0';
+ }
+ // Otherwise show empty/placeholder
+ return null;
+};
+
+export const getEpgSourceData = (epgSources) => {
+ return [
+ { value: '0', label: 'No EPG (Disabled)' },
+ ...[...epgSources]
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((source) => ({
+ value: source.id.toString(),
+ label: `${source.name} (${
+ source.source_type === 'dummy'
+ ? 'Dummy'
+ : source.source_type === 'xmltv'
+ ? 'XMLTV'
+ : source.source_type === 'schedules_direct'
+ ? 'Schedules Direct'
+ : source.source_type
+ })`,
+ })),
+ ];
+};
+
+export const repackGroupChannels = (playlist, group) => {
+ return API.repackGroupChannels(playlist.id, group.channel_group);
+};
+
+// Header line for the preview box. Adds a scan-cap suffix when the
+// backend only scanned the first SCAN_CAP streams of the group.
+export const formatPreviewSummary = (label, result) => {
+ if (!result) return null;
+ const { match_count, total_in_group, total_scanned, scan_limit_hit } = result;
+ const matchWord = `match${match_count === 1 ? '' : 'es'}`;
+ if (scan_limit_hit) {
+ return `${match_count} ${matchWord} in first ${total_scanned.toLocaleString()} streams scanned (of ${total_in_group.toLocaleString()} total)`;
+ }
+ return `${match_count} ${label} ${matchWord} in ${total_scanned.toLocaleString()} stream${total_scanned === 1 ? '' : 's'}`;
+};
diff --git a/frontend/src/utils/forms/AutoSyncBasicUtils.js b/frontend/src/utils/forms/AutoSyncBasicUtils.js
new file mode 100644
index 00000000..b98b715b
--- /dev/null
+++ b/frontend/src/utils/forms/AutoSyncBasicUtils.js
@@ -0,0 +1,25 @@
+// Returns {name, start, end}[] for groups whose declared ranges
+// intersect this group's range, or [] when there is no overlap.
+import { getGroupReservation } from './GroupSyncUtils.js';
+
+export const computeRangeOverlapsFor = (group, groupStates) => {
+ const myReservation = getGroupReservation(group);
+ if (!myReservation) return [];
+ const [myStart, myEnd] = myReservation;
+ const overlaps = [];
+ for (const other of groupStates) {
+ if (other.channel_group === group.channel_group) continue;
+ const otherReservation = getGroupReservation(other);
+ if (!otherReservation) continue;
+ const [oStart, oEnd] = otherReservation;
+ if (myStart <= oEnd && oStart <= myEnd) {
+ overlaps.push({ name: other.name, start: oStart, end: oEnd });
+ }
+ }
+ return overlaps;
+};
+
+const MAX_CHANNEL_NUMBER = 999999;
+
+export const clampChannelNumber = (n) =>
+ Math.max(1, Math.min(MAX_CHANNEL_NUMBER, Math.floor(Number(n) || 1)));
diff --git a/frontend/src/utils/forms/ChannelBatchUtils.js b/frontend/src/utils/forms/ChannelBatchUtils.js
index cebe5990..d603e5f4 100644
--- a/frontend/src/utils/forms/ChannelBatchUtils.js
+++ b/frontend/src/utils/forms/ChannelBatchUtils.js
@@ -191,7 +191,10 @@ export const buildSubmitValues = (
values.is_adult = values.is_adult === 'true';
}
- if (values.hidden_from_output === '-1' || values.hidden_from_output === undefined) {
+ if (
+ values.hidden_from_output === '-1' ||
+ values.hidden_from_output === undefined
+ ) {
delete values.hidden_from_output;
} else {
values.hidden_from_output = values.hidden_from_output === 'true';
diff --git a/frontend/src/utils/forms/ChannelGroupUtils.js b/frontend/src/utils/forms/ChannelGroupUtils.js
new file mode 100644
index 00000000..b510ee72
--- /dev/null
+++ b/frontend/src/utils/forms/ChannelGroupUtils.js
@@ -0,0 +1,17 @@
+import API from '../../api.js';
+
+export const updateChannelGroup = (channelGroup, values) => {
+ return API.updateChannelGroup({
+ id: channelGroup.id,
+ ...values,
+ });
+};
+export const addChannelGroup = (values) => {
+ return API.addChannelGroup(values);
+};
+export const deleteChannelGroup = (group) => {
+ return API.deleteChannelGroup(group.id);
+};
+export const cleanupUnusedChannelGroups = () => {
+ return API.cleanupUnusedChannelGroups();
+};
diff --git a/frontend/src/utils/forms/LiveGroupFilterUtils.js b/frontend/src/utils/forms/LiveGroupFilterUtils.js
new file mode 100644
index 00000000..a1372887
--- /dev/null
+++ b/frontend/src/utils/forms/LiveGroupFilterUtils.js
@@ -0,0 +1,140 @@
+import API from '../../api.js';
+
+export const getEPGs = () => {
+ return API.getEPGs();
+};
+
+export const getChannelsInRange = (start, end, controller) => {
+ return API.getChannelsInRange(start, end, {
+ signal: controller.signal,
+ });
+};
+
+export const getStreamsRegexPreview = (
+ group,
+ find,
+ replace,
+ match,
+ exclude,
+ controller,
+ playlist
+) => {
+ return API.getStreamsRegexPreview(group.name, {
+ find: find || undefined,
+ replace: find ? replace : undefined,
+ match: match || undefined,
+ exclude: exclude || undefined,
+ limit: 10,
+ signal: controller.signal,
+ m3uAccountId: playlist?.id,
+ });
+};
+
+// "Expected" occupants are this group's own auto-sync output:
+// auto_created, in this group on this account, no channel_number
+// override. Channels from any other provider, group, or with a user
+// pin all surface as a warning so the user is aware their range
+// overlaps with existing assignments. Sync still merges shared
+// ranges across providers, so the warning is informational rather
+// than blocking.
+export const isExpectedOccupantForGroup = (
+ occupant,
+ groupChannelGroupId,
+ playlist
+) => {
+ if (!occupant) return false;
+ if (!occupant.auto_created) return false;
+ if (occupant.has_channel_number_override) return false;
+ if (
+ occupant.channel_group_id !== undefined &&
+ occupant.channel_group_id !== groupChannelGroupId
+ )
+ return false;
+ return !(
+ occupant.auto_created_by_account_id !== undefined &&
+ playlist?.id !== undefined &&
+ occupant.auto_created_by_account_id !== playlist.id
+ );
+};
+
+export const rangeFor = (g) => {
+ if (!g.enabled || !g.auto_channel_sync) return null;
+ const mode = g.custom_properties?.channel_numbering_mode || 'fixed';
+ if (mode === 'next_available') return null;
+ const startRaw =
+ mode === 'provider'
+ ? (g.custom_properties?.channel_numbering_fallback ?? 1)
+ : (g.auto_sync_channel_start ?? 1);
+ const start = Number(startRaw);
+ if (!Number.isFinite(start)) return null;
+ const endRaw = g.auto_sync_channel_end;
+ const end =
+ endRaw === null || endRaw === undefined || endRaw === ''
+ ? start
+ : Number(endRaw);
+ return { start, end, startRaw };
+};
+
+export const abortTimers = (timerRef, abortRef) => {
+ Object.values(timerRef.current).forEach((t) => clearTimeout(t));
+ timerRef.current = {};
+ Object.values(abortRef.current).forEach((c) => {
+ try {
+ c.abort();
+ } catch {
+ // ignore
+ }
+ });
+ abortRef.current = {};
+};
+
+export const getRegexOptions = (
+ findValue,
+ replaceValue,
+ filterValue,
+ excludeValue
+) => {
+ return {
+ find: findValue,
+ replace: replaceValue,
+ match: filterValue,
+ exclude: excludeValue,
+ };
+};
+
+export const computeAutoSyncStart = (prev, id) => {
+ let proposedStart = 1;
+ for (const other of prev) {
+ if (other.channel_group == id) continue;
+ if (!other.enabled || !other.auto_channel_sync) continue;
+ const otherMode =
+ other.custom_properties?.channel_numbering_mode || 'fixed';
+ if (otherMode === 'next_available') continue;
+ const otherStart = Number(
+ otherMode === 'provider'
+ ? (other.custom_properties?.channel_numbering_fallback ?? 1)
+ : (other.auto_sync_channel_start ?? 1)
+ );
+ if (!Number.isFinite(otherStart)) continue;
+ const otherEnd =
+ other.auto_sync_channel_end === null ||
+ other.auto_sync_channel_end === undefined ||
+ other.auto_sync_channel_end === ''
+ ? otherStart
+ : Number(other.auto_sync_channel_end);
+ const upper = Math.max(otherStart, otherEnd);
+ if (upper + 1 > proposedStart) proposedStart = upper + 1;
+ }
+ return proposedStart;
+};
+
+export const isGroupVisible = (group, groupFilter, statusFilter) => {
+ const matchesText = group.name
+ .toLowerCase()
+ .includes(groupFilter.toLowerCase());
+ const matchesStatus =
+ statusFilter === 'all' ||
+ (statusFilter === 'enabled' && group.enabled) ||
+ (statusFilter === 'disabled' && !group.enabled);
+ return matchesText && matchesStatus;
+};
diff --git a/frontend/src/utils/forms/LogoUtils.js b/frontend/src/utils/forms/LogoUtils.js
new file mode 100644
index 00000000..f0682a85
--- /dev/null
+++ b/frontend/src/utils/forms/LogoUtils.js
@@ -0,0 +1,78 @@
+import API from '../../api.js';
+import { yupResolver } from '@hookform/resolvers/yup';
+import * as Yup from 'yup';
+
+const schema = Yup.object({
+ name: Yup.string().required('Name is required'),
+ url: Yup.string()
+ .required('URL is required')
+ .test(
+ 'valid-url-or-path',
+ 'Must be a valid URL or local file path',
+ (value) => {
+ if (!value) return false;
+ // Allow local file paths starting with /data/logos/
+ if (value.startsWith('/data/logos/')) return true;
+ // Allow valid URLs
+ try {
+ new URL(value);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+ ),
+});
+
+export const uploadLogo = async (selectedFile, values) => {
+ return await API.uploadLogo(selectedFile, values.name);
+};
+
+export const createLogo = async (values) => {
+ return await API.createLogo(values);
+};
+
+export const updateLogo = async (logo, values) => {
+ return await API.updateLogo(logo.id, values);
+};
+
+export const getResolver = () => {
+ return yupResolver(schema);
+};
+
+export const getUploadErrorMessage = (uploadError) => {
+ if (
+ uploadError.code === 'NETWORK_ERROR' ||
+ uploadError.message?.includes('timeout')
+ ) {
+ return 'Upload timed out. Please try again.';
+ } else if (uploadError.status === 413) {
+ return 'File too large. Please choose a smaller file.';
+ } else if (uploadError.body?.error) {
+ return uploadError.body.error;
+ }
+ return 'Failed to upload logo file';
+};
+
+export const getUpdateLogoErrorMessage = (logo, error) => {
+ if (error.code === 'NETWORK_ERROR' || error.message?.includes('timeout')) {
+ return 'Request timed out. Please try again.';
+ } else if (error.response?.data?.error) {
+ return error.response.data.error;
+ }
+ return logo ? 'Failed to update logo' : 'Failed to create logo';
+};
+
+export const validateFileSize = (file) => {
+ return file.size <= 5 * 1024 * 1024;
+};
+
+export const releaseUrl = (url) => {
+ if (url && url.startsWith('blob:')) {
+ URL.revokeObjectURL(url);
+ }
+};
+
+export const getFilenameWithoutExtension = (filename) => {
+ return filename.replace(/\.[^/.]+$/, '');
+};
diff --git a/frontend/src/utils/forms/M3uFilterUtils.js b/frontend/src/utils/forms/M3uFilterUtils.js
new file mode 100644
index 00000000..d2b3c9cd
--- /dev/null
+++ b/frontend/src/utils/forms/M3uFilterUtils.js
@@ -0,0 +1,11 @@
+import API from '../../api.js';
+
+export const addM3UFilter = async (m3u, values) => {
+ await API.addM3UFilter(m3u.id, values);
+};
+export const updateM3UFilter = (m3u, filter, values) => {
+ return API.updateM3UFilter(m3u.id, filter.id, values);
+};
+export const deleteM3UFilter = async (playlist, id) => {
+ await API.deleteM3UFilter(playlist.id, id);
+};
diff --git a/frontend/src/utils/forms/M3uGroupFilterUtils.js b/frontend/src/utils/forms/M3uGroupFilterUtils.js
new file mode 100644
index 00000000..1ad96835
--- /dev/null
+++ b/frontend/src/utils/forms/M3uGroupFilterUtils.js
@@ -0,0 +1,70 @@
+import API from '../../api.js';
+import { refreshPlaylist, updatePlaylist } from './M3uUtils.js';
+
+const updateM3UGroupSettings = async (
+ playlist,
+ groupSettings,
+ categorySettings
+) => {
+ await API.updateM3UGroupSettings(
+ playlist.id,
+ groupSettings,
+ categorySettings
+ );
+};
+
+export const buildGroupStates = (channelGroups, playlistChannelGroups) => {
+ return playlistChannelGroups
+ .filter((group) => channelGroups[group.channel_group])
+ .map((group) => ({
+ ...group,
+ name: channelGroups[group.channel_group].name,
+ auto_channel_sync: group.auto_channel_sync || false,
+ auto_sync_channel_start: group.auto_sync_channel_start || 1.0,
+ auto_sync_channel_end: group.auto_sync_channel_end ?? null,
+ custom_properties: parseCustomProperties(group.custom_properties),
+ }));
+};
+
+const parseCustomProperties = (raw) => {
+ if (!raw) return {};
+ try {
+ return typeof raw === 'string' ? JSON.parse(raw) : raw;
+ } catch {
+ return {};
+ }
+};
+
+export const saveAndRefreshPlaylist = async (
+ playlist,
+ groupStates,
+ movieCategoryStates,
+ seriesCategoryStates,
+ autoEnableSettings
+) => {
+ const groupSettings = prepareGroupSettings(groupStates);
+ const categorySettings = prepareCategorySettings(
+ movieCategoryStates,
+ seriesCategoryStates
+ );
+
+ await updatePlaylist(playlist, autoEnableSettings);
+ await updateM3UGroupSettings(playlist, groupSettings, categorySettings);
+ await refreshPlaylist(playlist);
+};
+
+const prepareGroupSettings = (groupStates) => {
+ return groupStates.map((state) => ({
+ ...state,
+ custom_properties: state.custom_properties || undefined,
+ }));
+};
+
+const prepareCategorySettings = (movieCategoryStates, seriesCategoryStates) => {
+ return [...movieCategoryStates, ...seriesCategoryStates]
+ .map((state) => ({
+ ...state,
+ custom_properties: state.custom_properties || undefined,
+ }))
+ .filter((state) => state.enabled !== state.original_enabled);
+};
diff --git a/frontend/src/utils/forms/M3uProfileUtils.js b/frontend/src/utils/forms/M3uProfileUtils.js
new file mode 100644
index 00000000..d7f99465
--- /dev/null
+++ b/frontend/src/utils/forms/M3uProfileUtils.js
@@ -0,0 +1,156 @@
+import API from '../../api.js';
+import * as Yup from 'yup';
+
+export const updateM3UProfile = async (playlistId, submitValues) => {
+ await API.updateM3UProfile(playlistId, submitValues);
+};
+
+export const addM3UProfile = async (playlistId, submitValues) => {
+ await API.addM3UProfile(playlistId, submitValues);
+};
+
+export const deleteM3UProfile = async (playlistId, id) => {
+ await API.deleteM3UProfile(playlistId, id);
+};
+
+const queryStreams = async (params) => {
+ return await API.queryStreams(params);
+};
+
+export const getDetectedMode = (storedMode, profile, m3u) => {
+ if (storedMode) {
+ return storedMode;
+ } else if (
+ profile?.search_pattern &&
+ profile.search_pattern === `${m3u?.username}/${m3u?.password}`
+ ) {
+ return 'simple';
+ } else if (profile?.search_pattern) {
+ return 'advanced';
+ }
+ return 'simple';
+};
+
+export const applyRegex = (input, pattern, replacer) => {
+ if (!pattern || !input) return input;
+ try {
+ const regex = new RegExp(pattern, 'g');
+ return input.replace(regex, replacer);
+ } catch {
+ return input;
+ }
+};
+
+/**
+ * Splits `input` into an array of { text, matched } segments using `pattern`.
+ * Returns null if the pattern is empty or the input is empty.
+ */
+export const splitByPattern = (input, pattern) => {
+ if (!pattern || !input) return null;
+ try {
+ const regex = new RegExp(pattern, 'g');
+ const segments = [];
+ let lastIndex = 0;
+ let m;
+ while ((m = regex.exec(input)) !== null) {
+ if (m.index > lastIndex) {
+ segments.push({
+ text: input.slice(lastIndex, m.index),
+ matched: false,
+ });
+ }
+ segments.push({ text: m[0], matched: true });
+ lastIndex = m.index + m[0].length;
+ if (m[0].length === 0) regex.lastIndex++;
+ }
+ if (lastIndex < input.length) {
+ segments.push({ text: input.slice(lastIndex), matched: false });
+ }
+ return segments;
+ } catch {
+ return null;
+ }
+};
+
+export const buildProfileSchema = (isDefaultProfile, isXC) => {
+ return Yup.object({
+ name: Yup.string().required('Name is required'),
+ search_pattern: Yup.string().when([], {
+ is: () => !isDefaultProfile && !isXC,
+ then: (schema) => schema.required('Search pattern is required'),
+ otherwise: (schema) => schema.notRequired(),
+ }),
+ replace_pattern: Yup.string().when([], {
+ is: () => !isDefaultProfile && !isXC,
+ then: (schema) => schema.required('Replace pattern is required'),
+ otherwise: (schema) => schema.notRequired(),
+ }),
+ notes: Yup.string(),
+ });
+};
+
+export const fetchFirstStreamUrl = async (m3uId) => {
+ const params = new URLSearchParams();
+ params.append('page', 1);
+ params.append('page_size', 1);
+ params.append('m3u_account', m3uId);
+ const response = await queryStreams(params);
+ return response?.results?.[0]?.url ?? null;
+};
+
+export const validateXcSimple = (newUsername, newPassword) => {
+ const errs = {};
+ if (!newUsername.trim()) errs.newUsername = 'New username is required';
+ if (!newPassword.trim()) errs.newPassword = 'New password is required';
+ return errs;
+};
+
+export const prepareExpDate = (expDateValue, isXC) => {
+ if (isXC) return undefined;
+ if (expDateValue instanceof Date) return expDateValue.toISOString();
+ return expDateValue || null;
+};
+
+export const applyXcSimplePatterns = (
+ values,
+ m3u,
+ newUsername,
+ newPassword
+) => {
+ return {
+ ...values,
+ search_pattern: `${m3u?.username || ''}/${m3u?.password || ''}`,
+ replace_pattern: `${newUsername.trim()}/${newPassword.trim()}`,
+ };
+};
+
+export const buildSubmitValues = (
+ values,
+ profile,
+ isDefaultProfile,
+ isXC,
+ xcMode
+) => {
+ if (isDefaultProfile) {
+ return {
+ name: values.name,
+ search_pattern: values.search_pattern || '',
+ replace_pattern: values.replace_pattern || '',
+ custom_properties: {
+ ...(profile?.custom_properties || {}),
+ notes: values.notes || '',
+ },
+ };
+ }
+ return {
+ name: values.name,
+ max_streams: values.max_streams,
+ search_pattern: values.search_pattern,
+ replace_pattern: values.replace_pattern,
+ custom_properties: {
+ ...(profile?.custom_properties || {}),
+ notes: values.notes || '',
+ ...(isXC ? { xcMode } : {}),
+ },
+ };
+};
diff --git a/frontend/src/utils/forms/M3uProfilesUtils.js b/frontend/src/utils/forms/M3uProfilesUtils.js
new file mode 100644
index 00000000..17228b1f
--- /dev/null
+++ b/frontend/src/utils/forms/M3uProfilesUtils.js
@@ -0,0 +1,37 @@
+export const parseExpirationDate = (profile) => {
+ const expDate = profile?.custom_properties?.user_info?.exp_date;
+ if (!expDate) return null;
+ try {
+ return new Date(parseInt(expDate) * 1000);
+ } catch {
+ return null;
+ }
+};
+
+export const isAccountExpired = (profile) => {
+ const expDate = parseExpirationDate(profile);
+ if (!expDate) return false;
+ return expDate < new Date();
+};
+
+export const getExpirationInfo = (profile) => {
+ const expDate = parseExpirationDate(profile);
+ if (!expDate) return null;
+
+ const diffMs = expDate - new Date();
+ if (diffMs <= 0) return { text: 'Expired', color: 'red' };
+
+ const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+ if (days > 30) return { text: `${days} days`, color: 'green' };
+ if (days > 7) return { text: `${days} days`, color: 'yellow' };
+ if (days > 0) return { text: `${days} days`, color: 'orange' };
+
+ const hours = Math.floor(diffMs / (1000 * 60 * 60));
+ return { text: `${hours}h`, color: 'red' };
+};
+
+export const profileSortComparator = (a, b) => {
+ if (a.is_default) return -1;
+ if (b.is_default) return 1;
+ return a.name.localeCompare(b.name);
+};
diff --git a/frontend/src/utils/forms/M3uUtils.js b/frontend/src/utils/forms/M3uUtils.js
new file mode 100644
index 00000000..7fc7e369
--- /dev/null
+++ b/frontend/src/utils/forms/M3uUtils.js
@@ -0,0 +1,54 @@
+import API from '../../api.js';
+
+export const updatePlaylist = (playlist, values, file) => {
+ return API.updatePlaylist({
+ id: playlist.id,
+ ...values,
+ file,
+ });
+};
+
+export const addPlaylist = async (values, file) => {
+ return await API.addPlaylist({
+ ...values,
+ file,
+ });
+};
+
+export const getPlaylist = async (newPlaylist) => {
+ return await API.getPlaylist(newPlaylist.id);
+};
+
+export const refreshPlaylist = async (playlist) => {
+ return await API.refreshPlaylist(playlist.id);
+};
+
+export const prepareSubmitValues = (values, expDate) => {
+ const prepared = { ...values };
+
+ if (prepared.account_type === 'XC') {
+ delete prepared.exp_date;
+ } else if (expDate instanceof Date) {
+ prepared.exp_date = expDate.toISOString();
+ } else {
+ prepared.exp_date = null;
+ }
+
+ const hasCron =
+ prepared.cron_expression && prepared.cron_expression.trim() !== '';
+ if (hasCron) {
+ prepared.refresh_interval = 0;
+ } else {
+ prepared.cron_expression = '';
+ }
+
+ if (prepared.account_type == 'XC' && prepared.password == '') {
+ delete prepared.password;
+ }
+
+ if (prepared.user_agent == '0') {
+ prepared.user_agent = null;
+ }
+
+ return prepared;
+};
diff --git a/frontend/src/utils/forms/__tests__/AutoSyncAdvancedUtils.test.js b/frontend/src/utils/forms/__tests__/AutoSyncAdvancedUtils.test.js
new file mode 100644
index 00000000..f17e5a4b
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/AutoSyncAdvancedUtils.test.js
@@ -0,0 +1,257 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ getEpgSourceValue,
+ getEpgSourceData,
+ repackGroupChannels,
+ formatPreviewSummary,
+} from '../AutoSyncAdvancedUtils.js';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ repackGroupChannels: vi.fn(),
+ },
+}));
+
+import API from '../../../api.js';
+
+const makeEpgSource = (overrides = {}) => ({
+ id: 1,
+ name: 'Source One',
+ source_type: 'xmltv',
+ ...overrides,
+});
+
+const makeResult = (overrides = {}) => ({
+ match_count: 3,
+ total_in_group: 100,
+ total_scanned: 100,
+ scan_limit_hit: false,
+ ...overrides,
+});
+
+describe('AutoSyncAdvancedUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ── getEpgSourceValue ─────────────────────────────────────────────────────
+ describe('getEpgSourceValue', () => {
+ it('returns custom_epg_id as string when set', () => {
+ expect(getEpgSourceValue({ custom_epg_id: 5 })).toBe('5');
+ });
+
+ it('returns custom_epg_id as string when set to 0', () => {
+ expect(getEpgSourceValue({ custom_epg_id: 0 })).toBe('0');
+ });
+
+ it('returns "0" when force_dummy_epg is true and no custom_epg_id', () => {
+ expect(getEpgSourceValue({ force_dummy_epg: true })).toBe('0');
+ });
+
+ it('prefers custom_epg_id over force_dummy_epg', () => {
+ expect(
+ getEpgSourceValue({ custom_epg_id: 7, force_dummy_epg: true })
+ ).toBe('7');
+ });
+
+ it('returns null when custom_epg_id is null', () => {
+ expect(getEpgSourceValue({ custom_epg_id: null })).toBe(null);
+ });
+
+ it('returns null when custom_epg_id is undefined and force_dummy_epg is false', () => {
+ expect(
+ getEpgSourceValue({ custom_epg_id: undefined, force_dummy_epg: false })
+ ).toBe(null);
+ });
+
+ it('returns null for empty custom_properties object', () => {
+ expect(getEpgSourceValue({})).toBe(null);
+ });
+
+ it('returns null for null custom_properties', () => {
+ expect(getEpgSourceValue(null)).toBe(null);
+ });
+
+ it('returns null for undefined custom_properties', () => {
+ expect(getEpgSourceValue(undefined)).toBe(null);
+ });
+ });
+
+ // ── getEpgSourceData ──────────────────────────────────────────────────────
+ describe('getEpgSourceData', () => {
+ it('always includes "No EPG" as the first option', () => {
+ const result = getEpgSourceData([]);
+ expect(result[0]).toEqual({ value: '0', label: 'No EPG (Disabled)' });
+ });
+
+ it('returns only the No EPG option for an empty source list', () => {
+ expect(getEpgSourceData([])).toHaveLength(1);
+ });
+
+ it('labels xmltv sources correctly', () => {
+ const result = getEpgSourceData([
+ makeEpgSource({ source_type: 'xmltv' }),
+ ]);
+ expect(result[1].label).toBe('Source One (XMLTV)');
+ });
+
+ it('labels dummy sources correctly', () => {
+ const result = getEpgSourceData([
+ makeEpgSource({ source_type: 'dummy' }),
+ ]);
+ expect(result[1].label).toBe('Source One (Dummy)');
+ });
+
+ it('labels schedules_direct sources correctly', () => {
+ const result = getEpgSourceData([
+ makeEpgSource({ source_type: 'schedules_direct' }),
+ ]);
+ expect(result[1].label).toBe('Source One (Schedules Direct)');
+ });
+
+ it('falls back to raw source_type for unknown types', () => {
+ const result = getEpgSourceData([
+ makeEpgSource({ source_type: 'custom_type' }),
+ ]);
+ expect(result[1].label).toBe('Source One (custom_type)');
+ });
+
+ it('maps id to string value', () => {
+ const result = getEpgSourceData([makeEpgSource({ id: 42 })]);
+ expect(result[1].value).toBe('42');
+ });
+
+ it('sorts sources alphabetically by name', () => {
+ const sources = [
+ makeEpgSource({ id: 1, name: 'Zebra' }),
+ makeEpgSource({ id: 2, name: 'Alpha' }),
+ makeEpgSource({ id: 3, name: 'Mango' }),
+ ];
+ const result = getEpgSourceData(sources);
+ expect(result.map((r) => r.label)).toEqual([
+ 'No EPG (Disabled)',
+ 'Alpha (XMLTV)',
+ 'Mango (XMLTV)',
+ 'Zebra (XMLTV)',
+ ]);
+ });
+
+ it('does not mutate the original array', () => {
+ const sources = [
+ makeEpgSource({ id: 1, name: 'Zebra' }),
+ makeEpgSource({ id: 2, name: 'Alpha' }),
+ ];
+ const original = [...sources];
+ getEpgSourceData(sources);
+ expect(sources).toEqual(original);
+ });
+ });
+
+ // ── repackGroupChannels ───────────────────────────────────────────────────
+ describe('repackGroupChannels', () => {
+ it('calls API.repackGroupChannels with playlist id and channel_group', () => {
+ const playlist = { id: 10 };
+ const group = { channel_group: 5 };
+ vi.mocked(API.repackGroupChannels).mockResolvedValue({ ok: true });
+
+ repackGroupChannels(playlist, group);
+
+ expect(API.repackGroupChannels).toHaveBeenCalledWith(10, 5);
+ });
+
+ it('returns the API promise', async () => {
+ const playlist = { id: 10 };
+ const group = { channel_group: 5 };
+ vi.mocked(API.repackGroupChannels).mockResolvedValue({ ok: true });
+
+ await expect(repackGroupChannels(playlist, group)).resolves.toEqual({
+ ok: true,
+ });
+ });
+ });
+
+ // ── formatPreviewSummary ──────────────────────────────────────────────────
+ describe('formatPreviewSummary', () => {
+ it('returns null when result is null', () => {
+ expect(formatPreviewSummary('streams', null)).toBeNull();
+ });
+
+ it('returns null when result is undefined', () => {
+ expect(formatPreviewSummary('streams', undefined)).toBeNull();
+ });
+
+ it('formats normal result with plural matches', () => {
+ const result = makeResult({ match_count: 3, total_scanned: 50 });
+ expect(formatPreviewSummary('streams', result)).toBe(
+ '3 streams matches in 50 streams'
+ );
+ });
+
+ it('formats normal result with singular match', () => {
+ const result = makeResult({ match_count: 1, total_scanned: 50 });
+ expect(formatPreviewSummary('streams', result)).toBe(
+ '1 streams match in 50 streams'
+ );
+ });
+
+ it('uses singular "stream" when total_scanned is 1', () => {
+ const result = makeResult({ match_count: 1, total_scanned: 1 });
+ expect(formatPreviewSummary('streams', result)).toBe(
+ '1 streams match in 1 stream'
+ );
+ });
+
+ it('formats scan_limit_hit result with plural matches', () => {
+ const result = makeResult({
+ match_count: 5,
+ total_scanned: 200,
+ total_in_group: 1000,
+ scan_limit_hit: true,
+ });
+ expect(formatPreviewSummary('streams', result)).toBe(
+ '5 matches in first 200 streams scanned (of 1,000 total)'
+ );
+ });
+
+ it('formats scan_limit_hit result with singular match', () => {
+ const result = makeResult({
+ match_count: 1,
+ total_scanned: 200,
+ total_in_group: 1000,
+ scan_limit_hit: true,
+ });
+ expect(formatPreviewSummary('streams', result)).toBe(
+ '1 match in first 200 streams scanned (of 1,000 total)'
+ );
+ });
+
+ it('uses toLocaleString formatting for large numbers in scan_limit_hit', () => {
+ const result = makeResult({
+ match_count: 2,
+ total_scanned: 5000,
+ total_in_group: 10000,
+ scan_limit_hit: true,
+ });
+ expect(formatPreviewSummary('streams', result)).toBe(
+ '2 matches in first 5,000 streams scanned (of 10,000 total)'
+ );
+ });
+
+ it('includes the label in normal result output', () => {
+ const result = makeResult({ match_count: 2, total_scanned: 10 });
+ expect(formatPreviewSummary('channels', result)).toContain('channels');
+ });
+
+ it('does not include the label in scan_limit_hit output', () => {
+ const result = makeResult({
+ scan_limit_hit: true,
+ match_count: 2,
+ total_scanned: 10,
+ total_in_group: 100,
+ });
+ expect(formatPreviewSummary('channels', result)).not.toContain(
+ 'channels'
+ );
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/AutoSyncBasicUtils.test.js b/frontend/src/utils/forms/__tests__/AutoSyncBasicUtils.test.js
new file mode 100644
index 00000000..bcace1a7
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/AutoSyncBasicUtils.test.js
@@ -0,0 +1,158 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ computeRangeOverlapsFor,
+ clampChannelNumber,
+} from '../AutoSyncBasicUtils.js';
+
+vi.mock('../GroupSyncUtils.js', () => ({
+ getGroupReservation: vi.fn(),
+}));
+
+import { getGroupReservation } from '../GroupSyncUtils.js';
+
+const makeGroup = (overrides = {}) => ({
+ channel_group: 1,
+ name: 'Group A',
+ ...overrides,
+});
+
+describe('AutoSyncBasicUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ── computeRangeOverlapsFor ───────────────────────────────────────────────
+ describe('computeRangeOverlapsFor', () => {
+ it('returns [] when the target group has no reservation', () => {
+ getGroupReservation.mockReturnValue(null);
+ expect(computeRangeOverlapsFor(makeGroup(), [])).toEqual([]);
+ });
+
+ it('returns [] when there are no other groups', () => {
+ getGroupReservation.mockReturnValue([100, 200]);
+ expect(computeRangeOverlapsFor(makeGroup(), [])).toEqual([]);
+ });
+
+ it('skips groups with the same channel_group id', () => {
+ const group = makeGroup({ channel_group: 1 });
+ const same = makeGroup({ channel_group: 1, name: 'Same' });
+ getGroupReservation.mockReturnValue([100, 200]);
+ expect(computeRangeOverlapsFor(group, [same])).toEqual([]);
+ });
+
+ it('skips other groups with no reservation', () => {
+ const group = makeGroup({ channel_group: 1 });
+ const other = makeGroup({ channel_group: 2, name: 'Other' });
+ getGroupReservation
+ .mockReturnValueOnce([100, 200]) // target group
+ .mockReturnValueOnce(null); // other group
+ expect(computeRangeOverlapsFor(group, [other])).toEqual([]);
+ });
+
+ it('detects exact overlap', () => {
+ const group = makeGroup({ channel_group: 1 });
+ const other = makeGroup({ channel_group: 2, name: 'Other' });
+ getGroupReservation
+ .mockReturnValueOnce([100, 200])
+ .mockReturnValueOnce([100, 200]);
+ expect(computeRangeOverlapsFor(group, [other])).toEqual([
+ { name: 'Other', start: 100, end: 200 },
+ ]);
+ });
+
+ it('detects partial overlap at start boundary', () => {
+ const group = makeGroup({ channel_group: 1 });
+ const other = makeGroup({ channel_group: 2, name: 'Other' });
+ getGroupReservation
+ .mockReturnValueOnce([150, 250])
+ .mockReturnValueOnce([100, 150]);
+ expect(computeRangeOverlapsFor(group, [other])).toEqual([
+ { name: 'Other', start: 100, end: 150 },
+ ]);
+ });
+
+ it('detects partial overlap at end boundary', () => {
+ const group = makeGroup({ channel_group: 1 });
+ const other = makeGroup({ channel_group: 2, name: 'Other' });
+ getGroupReservation
+ .mockReturnValueOnce([100, 200])
+ .mockReturnValueOnce([200, 300]);
+ expect(computeRangeOverlapsFor(group, [other])).toEqual([
+ { name: 'Other', start: 200, end: 300 },
+ ]);
+ });
+
+ it('returns [] when other group is entirely before target', () => {
+ const group = makeGroup({ channel_group: 1 });
+ const other = makeGroup({ channel_group: 2, name: 'Other' });
+ getGroupReservation
+ .mockReturnValueOnce([200, 300])
+ .mockReturnValueOnce([100, 199]);
+ expect(computeRangeOverlapsFor(group, [other])).toEqual([]);
+ });
+
+ it('returns [] when other group is entirely after target', () => {
+ const group = makeGroup({ channel_group: 1 });
+ const other = makeGroup({ channel_group: 2, name: 'Other' });
+ getGroupReservation
+ .mockReturnValueOnce([100, 199])
+ .mockReturnValueOnce([200, 300]);
+ expect(computeRangeOverlapsFor(group, [other])).toEqual([]);
+ });
+
+ it('returns multiple overlapping groups', () => {
+ const group = makeGroup({ channel_group: 1 });
+ const other1 = makeGroup({ channel_group: 2, name: 'B' });
+ const other2 = makeGroup({ channel_group: 3, name: 'C' });
+ getGroupReservation
+ .mockReturnValueOnce([100, 300]) // target
+ .mockReturnValueOnce([150, 200]) // other1 overlaps
+ .mockReturnValueOnce([250, 350]); // other2 overlaps
+ expect(computeRangeOverlapsFor(group, [other1, other2])).toEqual([
+ { name: 'B', start: 150, end: 200 },
+ { name: 'C', start: 250, end: 350 },
+ ]);
+ });
+ });
+
+ // ── clampChannelNumber ────────────────────────────────────────────────────
+ describe('clampChannelNumber', () => {
+ it('returns the value unchanged when within range', () => {
+ expect(clampChannelNumber(500)).toBe(500);
+ });
+
+ it('clamps below 1 to 1', () => {
+ expect(clampChannelNumber(0)).toBe(1);
+ expect(clampChannelNumber(-50)).toBe(1);
+ });
+
+ it('clamps above 999999 to 999999', () => {
+ expect(clampChannelNumber(1000000)).toBe(999999);
+ expect(clampChannelNumber(99999999)).toBe(999999);
+ });
+
+ it('floors decimal values', () => {
+ expect(clampChannelNumber(5.9)).toBe(5);
+ expect(clampChannelNumber(1.1)).toBe(1);
+ });
+
+ it('coerces numeric strings', () => {
+ expect(clampChannelNumber('42')).toBe(42);
+ });
+
+ it('falls back to 1 for NaN inputs', () => {
+ expect(clampChannelNumber('abc')).toBe(1);
+ expect(clampChannelNumber(NaN)).toBe(1);
+ expect(clampChannelNumber(undefined)).toBe(1);
+ expect(clampChannelNumber(null)).toBe(1);
+ });
+
+ it('returns 1 for boundary value 1', () => {
+ expect(clampChannelNumber(1)).toBe(1);
+ });
+
+ it('returns 999999 for boundary value 999999', () => {
+ expect(clampChannelNumber(999999)).toBe(999999);
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/ChannelBatchUtils.bulkOverrideRouting.test.js b/frontend/src/utils/forms/__tests__/ChannelBatchUtils.bulkOverrideRouting.test.js
index 00e4f907..e48b4213 100644
--- a/frontend/src/utils/forms/__tests__/ChannelBatchUtils.bulkOverrideRouting.test.js
+++ b/frontend/src/utils/forms/__tests__/ChannelBatchUtils.bulkOverrideRouting.test.js
@@ -30,7 +30,7 @@ describe('updateChannelsWithOverrideRouting (manual finding #4)', () => {
await updateChannelsWithOverrideRouting(
[1, 2],
{ name: 'BulkRename' },
- channelsById,
+ channelsById
);
expect(API.bulkUpdateChannels).toHaveBeenCalledTimes(1);
@@ -48,7 +48,7 @@ describe('updateChannelsWithOverrideRouting (manual finding #4)', () => {
await updateChannelsWithOverrideRouting(
[1],
{ name: 'ManualRename' },
- channelsById,
+ channelsById
);
const body = API.bulkUpdateChannels.mock.calls[0][0];
expect(body).toEqual([{ id: 1, name: 'ManualRename' }]);
@@ -63,7 +63,7 @@ describe('updateChannelsWithOverrideRouting (manual finding #4)', () => {
await updateChannelsWithOverrideRouting(
[1, 2, 3],
{ name: 'Mixed', tvg_id: 'mixed.tvg' },
- channelsById,
+ channelsById
);
const body = API.bulkUpdateChannels.mock.calls[0][0];
expect(body).toEqual([
@@ -82,7 +82,7 @@ describe('updateChannelsWithOverrideRouting (manual finding #4)', () => {
await updateChannelsWithOverrideRouting(
[1],
{ hidden_from_output: true, name: 'Renamed' },
- channelsById,
+ channelsById
);
const body = API.bulkUpdateChannels.mock.calls[0][0];
expect(body).toEqual([
@@ -97,7 +97,7 @@ describe('updateChannelsWithOverrideRouting (manual finding #4)', () => {
await updateChannelsWithOverrideRouting(
[99],
{ name: 'Defensive' },
- {}, // empty lookup
+ {} // empty lookup
);
const body = API.bulkUpdateChannels.mock.calls[0][0];
expect(body).toEqual([{ id: 99, name: 'Defensive' }]);
diff --git a/frontend/src/utils/forms/__tests__/ChannelGroupUtils.test.js b/frontend/src/utils/forms/__tests__/ChannelGroupUtils.test.js
new file mode 100644
index 00000000..2699ae3c
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/ChannelGroupUtils.test.js
@@ -0,0 +1,154 @@
+// src/utils/forms/__tests__/ChannelGroupUtils.test.js
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ updateChannelGroup,
+ addChannelGroup,
+ deleteChannelGroup,
+ cleanupUnusedChannelGroups,
+} from '../ChannelGroupUtils.js';
+
+// ── API mock ───────────────────────────────────────────────────────────────────
+vi.mock('../../../api.js', () => ({
+ default: {
+ updateChannelGroup: vi.fn(),
+ addChannelGroup: vi.fn(),
+ deleteChannelGroup: vi.fn(),
+ cleanupUnusedChannelGroups: vi.fn(),
+ },
+}));
+
+import API from '../../../api.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const makeChannelGroup = (overrides = {}) => ({
+ id: 'group-1',
+ name: 'Sports',
+ ...overrides,
+});
+
+const makeValues = (overrides = {}) => ({
+ name: 'Updated Sports',
+ locked: false,
+ ...overrides,
+});
+
+describe('ChannelGroupUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ── updateChannelGroup ─────────────────────────────────────────────────────
+
+ describe('updateChannelGroup', () => {
+ it('calls API.updateChannelGroup with merged id and values', () => {
+ const group = makeChannelGroup();
+ const values = makeValues();
+ updateChannelGroup(group, values);
+ expect(API.updateChannelGroup).toHaveBeenCalledWith({
+ id: 'group-1',
+ name: 'Updated Sports',
+ locked: false,
+ });
+ });
+
+ it('returns the result of API.updateChannelGroup', () => {
+ const mockResult = { id: 'group-1', name: 'Updated Sports' };
+ vi.mocked(API.updateChannelGroup).mockReturnValue(mockResult);
+ const result = updateChannelGroup(makeChannelGroup(), makeValues());
+ expect(result).toBe(mockResult);
+ });
+
+ it('calls API.updateChannelGroup exactly once', () => {
+ updateChannelGroup(makeChannelGroup(), makeValues());
+ expect(API.updateChannelGroup).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ // ── addChannelGroup ────────────────────────────────────────────────────────
+
+ describe('addChannelGroup', () => {
+ it('calls API.addChannelGroup with the provided values', () => {
+ const values = makeValues();
+ addChannelGroup(values);
+ expect(API.addChannelGroup).toHaveBeenCalledWith(values);
+ });
+
+ it('returns the result of API.addChannelGroup', () => {
+ const mockResult = { id: 'group-2', name: 'Updated Sports' };
+ vi.mocked(API.addChannelGroup).mockReturnValue(mockResult);
+ const result = addChannelGroup(makeValues());
+ expect(result).toBe(mockResult);
+ });
+
+ it('calls API.addChannelGroup exactly once', () => {
+ addChannelGroup(makeValues());
+ expect(API.addChannelGroup).toHaveBeenCalledTimes(1);
+ });
+
+ it('passes values through unmodified', () => {
+ const values = { name: 'News', locked: true, custom: 'data' };
+ addChannelGroup(values);
+ expect(API.addChannelGroup).toHaveBeenCalledWith(values);
+ });
+ });
+
+ // ── deleteChannelGroup ─────────────────────────────────────────────────────
+
+ describe('deleteChannelGroup', () => {
+ it('calls API.deleteChannelGroup with the group id', () => {
+ const group = makeChannelGroup();
+ deleteChannelGroup(group);
+ expect(API.deleteChannelGroup).toHaveBeenCalledWith('group-1');
+ });
+
+ it('returns the result of API.deleteChannelGroup', () => {
+ const mockResult = { success: true };
+ vi.mocked(API.deleteChannelGroup).mockReturnValue(mockResult);
+ const result = deleteChannelGroup(makeChannelGroup());
+ expect(result).toBe(mockResult);
+ });
+
+ it('calls API.deleteChannelGroup exactly once', () => {
+ deleteChannelGroup(makeChannelGroup());
+ expect(API.deleteChannelGroup).toHaveBeenCalledTimes(1);
+ });
+
+ it('uses only the id from the group object', () => {
+ const group = makeChannelGroup({ id: 'group-42', name: 'Movies' });
+ deleteChannelGroup(group);
+ expect(API.deleteChannelGroup).toHaveBeenCalledWith('group-42');
+ });
+ });
+
+ // ── cleanupUnusedChannelGroups ─────────────────────────────────────────────
+
+ describe('cleanupUnusedChannelGroups', () => {
+ it('calls API.cleanupUnusedChannelGroups', async () => {
+ vi.mocked(API.cleanupUnusedChannelGroups).mockResolvedValue(undefined);
+ await cleanupUnusedChannelGroups();
+ expect(API.cleanupUnusedChannelGroups).toHaveBeenCalled();
+ });
+
+ it('calls API.cleanupUnusedChannelGroups exactly once', async () => {
+ vi.mocked(API.cleanupUnusedChannelGroups).mockResolvedValue(undefined);
+ await cleanupUnusedChannelGroups();
+ expect(API.cleanupUnusedChannelGroups).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns the resolved value from API.cleanupUnusedChannelGroups', async () => {
+ const mockResult = { removed: 5 };
+ vi.mocked(API.cleanupUnusedChannelGroups).mockResolvedValue(mockResult);
+ const result = await cleanupUnusedChannelGroups();
+ expect(result).toEqual(mockResult);
+ });
+
+ it('propagates rejection from API.cleanupUnusedChannelGroups', async () => {
+ vi.mocked(API.cleanupUnusedChannelGroups).mockRejectedValue(
+ new Error('Server error')
+ );
+ await expect(cleanupUnusedChannelGroups()).rejects.toThrow(
+ 'Server error'
+ );
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/LiveGroupFilterUtils.test.js b/frontend/src/utils/forms/__tests__/LiveGroupFilterUtils.test.js
new file mode 100644
index 00000000..feb878e1
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/LiveGroupFilterUtils.test.js
@@ -0,0 +1,498 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ getEPGs,
+ getChannelsInRange,
+ getStreamsRegexPreview,
+ isExpectedOccupantForGroup,
+ rangeFor,
+ abortTimers,
+ getRegexOptions,
+ computeAutoSyncStart,
+ isGroupVisible,
+} from '../LiveGroupFilterUtils.js';
+
+// ── API mock ─────────────────────────────────────────────────────────────────
+vi.mock('../../../api.js', () => ({
+ default: {
+ getEPGs: vi.fn(),
+ getChannelsInRange: vi.fn(),
+ getStreamsRegexPreview: vi.fn(),
+ },
+}));
+
+import API from '../../../api.js';
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+const makeEpgSource = (overrides = {}) => ({
+ id: 1,
+ name: 'Source One',
+ source_type: 'xmltv',
+ ...overrides,
+});
+
+const makeGroup = (overrides = {}) => ({
+ name: 'Group A',
+ enabled: true,
+ auto_channel_sync: true,
+ auto_sync_channel_start: 100,
+ auto_sync_channel_end: 200,
+ custom_properties: {},
+ channel_group: 1,
+ ...overrides,
+});
+
+const makeOccupant = (overrides = {}) => ({
+ auto_created: true,
+ has_channel_number_override: false,
+ channel_group_id: 1,
+ auto_created_by_account_id: 42,
+ ...overrides,
+});
+
+const makePlaylist = (overrides = {}) => ({
+ id: 42,
+ ...overrides,
+});
+
+const makeController = () => {
+ const controller = { signal: {} };
+ return controller;
+};
+
+describe('LiveGroupFilterUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ── getEPGs ────────────────────────────────────────────────────────────────
+ describe('getEPGs', () => {
+ it('delegates to API.getEPGs', () => {
+ const result = [makeEpgSource()];
+ vi.mocked(API.getEPGs).mockResolvedValue(result);
+ expect(getEPGs()).resolves.toEqual(result);
+ expect(API.getEPGs).toHaveBeenCalledOnce();
+ });
+ });
+
+ // ── getChannelsInRange ─────────────────────────────────────────────────────
+ describe('getChannelsInRange', () => {
+ it('calls API.getChannelsInRange with start, end, and signal', () => {
+ const controller = makeController();
+ const result = [{ id: 1 }];
+ vi.mocked(API.getChannelsInRange).mockResolvedValue(result);
+
+ getChannelsInRange(100, 200, controller);
+
+ expect(API.getChannelsInRange).toHaveBeenCalledWith(100, 200, {
+ signal: controller.signal,
+ });
+ });
+
+ it('returns the API promise', () => {
+ const controller = makeController();
+ const result = [{ id: 1 }];
+ vi.mocked(API.getChannelsInRange).mockResolvedValue(result);
+
+ expect(getChannelsInRange(100, 200, controller)).resolves.toEqual(result);
+ });
+ });
+
+ // ── getStreamsRegexPreview ─────────────────────────────────────────────────
+ describe('getStreamsRegexPreview', () => {
+ it('calls API with correct params when all values provided', () => {
+ const group = { name: 'Group A' };
+ const controller = makeController();
+ const playlist = makePlaylist();
+ vi.mocked(API.getStreamsRegexPreview).mockResolvedValue([]);
+
+ getStreamsRegexPreview(
+ group,
+ 'find',
+ 'replace',
+ 'match',
+ 'exclude',
+ controller,
+ playlist
+ );
+
+ expect(API.getStreamsRegexPreview).toHaveBeenCalledWith('Group A', {
+ find: 'find',
+ replace: 'replace',
+ match: 'match',
+ exclude: 'exclude',
+ limit: 10,
+ signal: controller.signal,
+ m3uAccountId: 42,
+ });
+ });
+
+ it('omits find/replace when find is falsy', () => {
+ const group = { name: 'Group A' };
+ const controller = makeController();
+ vi.mocked(API.getStreamsRegexPreview).mockResolvedValue([]);
+
+ getStreamsRegexPreview(group, '', 'replace', '', '', controller, null);
+
+ expect(API.getStreamsRegexPreview).toHaveBeenCalledWith('Group A', {
+ find: undefined,
+ replace: undefined,
+ match: undefined,
+ exclude: undefined,
+ limit: 10,
+ signal: controller.signal,
+ m3uAccountId: undefined,
+ });
+ });
+
+ it('omits replace when find is falsy but keeps match/exclude if truthy', () => {
+ const group = { name: 'Group A' };
+ const controller = makeController();
+ vi.mocked(API.getStreamsRegexPreview).mockResolvedValue([]);
+
+ getStreamsRegexPreview(
+ group,
+ '',
+ '',
+ 'match',
+ 'exclude',
+ controller,
+ null
+ );
+
+ expect(API.getStreamsRegexPreview).toHaveBeenCalledWith('Group A', {
+ find: undefined,
+ replace: undefined,
+ match: 'match',
+ exclude: 'exclude',
+ limit: 10,
+ signal: controller.signal,
+ m3uAccountId: undefined,
+ });
+ });
+ });
+
+ // ── isExpectedOccupantForGroup ─────────────────────────────────────────────
+ describe('isExpectedOccupantForGroup', () => {
+ it('returns false for null occupant', () => {
+ expect(isExpectedOccupantForGroup(null, 1, makePlaylist())).toBe(false);
+ });
+
+ it('returns false when occupant is not auto_created', () => {
+ const occupant = makeOccupant({ auto_created: false });
+ expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
+ false
+ );
+ });
+
+ it('returns false when occupant has a channel number override', () => {
+ const occupant = makeOccupant({ has_channel_number_override: true });
+ expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
+ false
+ );
+ });
+
+ it('returns false when occupant belongs to a different group', () => {
+ const occupant = makeOccupant({ channel_group_id: 99 });
+ expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
+ false
+ );
+ });
+
+ it('returns false when occupant was created by a different account', () => {
+ const occupant = makeOccupant({ auto_created_by_account_id: 99 });
+ expect(
+ isExpectedOccupantForGroup(occupant, 1, makePlaylist({ id: 42 }))
+ ).toBe(false);
+ });
+
+ it('returns true for a valid expected occupant', () => {
+ const occupant = makeOccupant();
+ expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
+ true
+ );
+ });
+
+ it('returns true when channel_group_id is undefined', () => {
+ const occupant = makeOccupant({ channel_group_id: undefined });
+ expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
+ true
+ );
+ });
+
+ it('returns true when auto_created_by_account_id is undefined', () => {
+ const occupant = makeOccupant({ auto_created_by_account_id: undefined });
+ expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
+ true
+ );
+ });
+ });
+
+ // ── rangeFor ──────────────────────────────────────────────────────────────
+ describe('rangeFor', () => {
+ it('returns null when group is disabled', () => {
+ expect(rangeFor(makeGroup({ enabled: false }))).toBeNull();
+ });
+
+ it('returns null when auto_channel_sync is off', () => {
+ expect(rangeFor(makeGroup({ auto_channel_sync: false }))).toBeNull();
+ });
+
+ it('returns null when mode is next_available', () => {
+ const group = makeGroup({
+ custom_properties: { channel_numbering_mode: 'next_available' },
+ });
+ expect(rangeFor(group)).toBeNull();
+ });
+
+ it('returns null when start is not finite', () => {
+ const group = makeGroup({ auto_sync_channel_start: 'abc' });
+ expect(rangeFor(group)).toBeNull();
+ });
+
+ it('returns correct range for fixed mode', () => {
+ const group = makeGroup({
+ auto_sync_channel_start: 100,
+ auto_sync_channel_end: 200,
+ custom_properties: { channel_numbering_mode: 'fixed' },
+ });
+ expect(rangeFor(group)).toEqual({ start: 100, end: 200, startRaw: 100 });
+ });
+
+ it('uses fallback start for provider mode', () => {
+ const group = makeGroup({
+ custom_properties: {
+ channel_numbering_mode: 'provider',
+ channel_numbering_fallback: 50,
+ },
+ auto_sync_channel_end: 150,
+ });
+ expect(rangeFor(group)).toEqual({ start: 50, end: 150, startRaw: 50 });
+ });
+
+ it('uses start as end when end is null', () => {
+ const group = makeGroup({
+ auto_sync_channel_start: 100,
+ auto_sync_channel_end: null,
+ });
+ expect(rangeFor(group)).toEqual({ start: 100, end: 100, startRaw: 100 });
+ });
+
+ it('uses start as end when end is empty string', () => {
+ const group = makeGroup({
+ auto_sync_channel_start: 100,
+ auto_sync_channel_end: '',
+ });
+ expect(rangeFor(group)).toEqual({ start: 100, end: 100, startRaw: 100 });
+ });
+
+ it('defaults start to 1 when auto_sync_channel_start is undefined', () => {
+ const group = makeGroup({
+ auto_sync_channel_start: undefined,
+ auto_sync_channel_end: 10,
+ });
+ expect(rangeFor(group)).toEqual({ start: 1, end: 10, startRaw: 1 });
+ });
+ });
+
+ // ── abortTimers ───────────────────────────────────────────────────────────
+ describe('abortTimers', () => {
+ it('clears all timeouts and aborts all controllers', () => {
+ const t1 = setTimeout(() => {}, 10000);
+ const clearSpy = vi.spyOn(globalThis, 'clearTimeout');
+
+ const abortFn = vi.fn();
+ const timerRef = { current: { a: t1 } };
+ const abortRef = { current: { b: { abort: abortFn } } };
+
+ abortTimers(timerRef, abortRef);
+
+ expect(clearSpy).toHaveBeenCalledWith(t1);
+ expect(abortFn).toHaveBeenCalledOnce();
+ expect(timerRef.current).toEqual({});
+ expect(abortRef.current).toEqual({});
+
+ clearSpy.mockRestore();
+ });
+
+ it('does not throw when abort throws', () => {
+ const timerRef = { current: {} };
+ const abortRef = {
+ current: {
+ a: {
+ abort: () => {
+ throw new Error('already aborted');
+ },
+ },
+ },
+ };
+
+ expect(() => abortTimers(timerRef, abortRef)).not.toThrow();
+ expect(abortRef.current).toEqual({});
+ });
+ });
+
+ // ── getRegexOptions ───────────────────────────────────────────────────────
+ describe('getRegexOptions', () => {
+ it('returns an object with the four regex fields', () => {
+ expect(getRegexOptions('find', 'replace', 'filter', 'exclude')).toEqual({
+ find: 'find',
+ replace: 'replace',
+ match: 'filter',
+ exclude: 'exclude',
+ });
+ });
+
+ it('preserves empty string values', () => {
+ expect(getRegexOptions('', '', '', '')).toEqual({
+ find: '',
+ replace: '',
+ match: '',
+ exclude: '',
+ });
+ });
+ });
+
+ // ── computeAutoSyncStart ──────────────────────────────────────────────────
+ describe('computeAutoSyncStart', () => {
+ it('returns 1 when no other groups are active', () => {
+ expect(computeAutoSyncStart([], 1)).toBe(1);
+ });
+
+ it('skips groups with the same id', () => {
+ const groups = [
+ makeGroup({
+ channel_group: 1,
+ auto_sync_channel_start: 100,
+ auto_sync_channel_end: 200,
+ }),
+ ];
+ expect(computeAutoSyncStart(groups, 1)).toBe(1);
+ });
+
+ it('skips disabled groups', () => {
+ const groups = [
+ makeGroup({
+ channel_group: 2,
+ enabled: false,
+ auto_sync_channel_start: 100,
+ auto_sync_channel_end: 200,
+ }),
+ ];
+ expect(computeAutoSyncStart(groups, 1)).toBe(1);
+ });
+
+ it('skips groups with auto_channel_sync off', () => {
+ const groups = [
+ makeGroup({
+ channel_group: 2,
+ auto_channel_sync: false,
+ auto_sync_channel_start: 100,
+ auto_sync_channel_end: 200,
+ }),
+ ];
+ expect(computeAutoSyncStart(groups, 1)).toBe(1);
+ });
+
+ it('skips groups with next_available mode', () => {
+ const groups = [
+ makeGroup({
+ channel_group: 2,
+ custom_properties: { channel_numbering_mode: 'next_available' },
+ auto_sync_channel_start: 100,
+ auto_sync_channel_end: 200,
+ }),
+ ];
+ expect(computeAutoSyncStart(groups, 1)).toBe(1);
+ });
+
+ it('returns upper + 1 of the highest active group range', () => {
+ const groups = [
+ makeGroup({
+ channel_group: 2,
+ auto_sync_channel_start: 100,
+ auto_sync_channel_end: 200,
+ }),
+ ];
+ expect(computeAutoSyncStart(groups, 1)).toBe(201);
+ });
+
+ it('handles multiple groups and picks the highest upper bound', () => {
+ const groups = [
+ makeGroup({
+ channel_group: 2,
+ auto_sync_channel_start: 100,
+ auto_sync_channel_end: 200,
+ }),
+ makeGroup({
+ channel_group: 3,
+ auto_sync_channel_start: 300,
+ auto_sync_channel_end: 400,
+ }),
+ ];
+ expect(computeAutoSyncStart(groups, 1)).toBe(401);
+ });
+
+ it('uses start as end when end is null', () => {
+ const groups = [
+ makeGroup({
+ channel_group: 2,
+ auto_sync_channel_start: 50,
+ auto_sync_channel_end: null,
+ }),
+ ];
+ expect(computeAutoSyncStart(groups, 1)).toBe(51);
+ });
+
+ it('uses provider fallback for provider mode', () => {
+ const groups = [
+ makeGroup({
+ channel_group: 2,
+ custom_properties: {
+ channel_numbering_mode: 'provider',
+ channel_numbering_fallback: 500,
+ },
+ auto_sync_channel_end: 600,
+ }),
+ ];
+ expect(computeAutoSyncStart(groups, 1)).toBe(601);
+ });
+ });
+
+ // ── isGroupVisible ────────────────────────────────────────────────────────
+ describe('isGroupVisible', () => {
+ it('returns true when text and status both match', () => {
+ const group = makeGroup({ name: 'Sports', enabled: true });
+ expect(isGroupVisible(group, 'sport', 'enabled')).toBe(true);
+ });
+
+ it('returns false when text does not match', () => {
+ const group = makeGroup({ name: 'Sports', enabled: true });
+ expect(isGroupVisible(group, 'news', 'all')).toBe(false);
+ });
+
+ it('returns false when status filter is enabled but group is disabled', () => {
+ const group = makeGroup({ name: 'Sports', enabled: false });
+ expect(isGroupVisible(group, 'sport', 'enabled')).toBe(false);
+ });
+
+ it('returns false when status filter is disabled but group is enabled', () => {
+ const group = makeGroup({ name: 'Sports', enabled: true });
+ expect(isGroupVisible(group, 'sport', 'disabled')).toBe(false);
+ });
+
+ it('returns true for disabled group with disabled filter', () => {
+ const group = makeGroup({ name: 'Sports', enabled: false });
+ expect(isGroupVisible(group, 'sport', 'disabled')).toBe(true);
+ });
+
+ it('matches regardless of case', () => {
+ const group = makeGroup({ name: 'Sports HD', enabled: true });
+ expect(isGroupVisible(group, 'SPORTS', 'all')).toBe(true);
+ });
+
+ it('returns true with empty text filter', () => {
+ const group = makeGroup({ name: 'Sports', enabled: true });
+ expect(isGroupVisible(group, '', 'all')).toBe(true);
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/LogoUtils.test.js b/frontend/src/utils/forms/__tests__/LogoUtils.test.js
new file mode 100644
index 00000000..b444e7da
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/LogoUtils.test.js
@@ -0,0 +1,439 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ uploadLogo,
+ createLogo,
+ updateLogo,
+ getResolver,
+ getUploadErrorMessage,
+ getUpdateLogoErrorMessage,
+ validateFileSize,
+ releaseUrl,
+ getFilenameWithoutExtension,
+} from '../LogoUtils.js';
+
+// ── API mock ───────────────────────────────────────────────────────────────────
+vi.mock('../../../api.js', () => ({
+ default: {
+ uploadLogo: vi.fn(),
+ createLogo: vi.fn(),
+ updateLogo: vi.fn(),
+ },
+}));
+
+// ── @hookform/resolvers/yup mock ───────────────────────────────────────────────
+vi.mock('@hookform/resolvers/yup', () => ({
+ yupResolver: vi.fn((schema) => ({ __resolver: true, schema })),
+}));
+
+import API from '../../../api.js';
+import { yupResolver } from '@hookform/resolvers/yup';
+
+describe('LogoUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ── uploadLogo ─────────────────────────────────────────────────────────────
+
+ describe('uploadLogo', () => {
+ const makeFile = () =>
+ new File(['content'], 'logo.png', { type: 'image/png' });
+ const makeValues = (overrides = {}) => ({ name: 'My Logo', ...overrides });
+
+ it('calls API.uploadLogo with the selected file and values.name', async () => {
+ const file = makeFile();
+ const values = makeValues();
+ vi.mocked(API.uploadLogo).mockResolvedValue({ id: '1' });
+
+ await uploadLogo(file, values);
+
+ expect(API.uploadLogo).toHaveBeenCalledWith(file, 'My Logo');
+ });
+
+ it('returns the result of API.uploadLogo', async () => {
+ const mockResult = { id: '1', name: 'My Logo' };
+ vi.mocked(API.uploadLogo).mockResolvedValue(mockResult);
+
+ const result = await uploadLogo(makeFile(), makeValues());
+
+ expect(result).toEqual(mockResult);
+ });
+
+ it('calls API.uploadLogo exactly once', async () => {
+ vi.mocked(API.uploadLogo).mockResolvedValue({});
+
+ await uploadLogo(makeFile(), makeValues());
+
+ expect(API.uploadLogo).toHaveBeenCalledTimes(1);
+ });
+
+ it('propagates rejection from API.uploadLogo', async () => {
+ vi.mocked(API.uploadLogo).mockRejectedValue(new Error('Upload failed'));
+
+ await expect(uploadLogo(makeFile(), makeValues())).rejects.toThrow(
+ 'Upload failed'
+ );
+ });
+ });
+
+ // ── createLogo ─────────────────────────────────────────────────────────────
+
+ describe('createLogo', () => {
+ const makeValues = () => ({
+ name: 'New Logo',
+ url: 'https://example.com/logo.png',
+ });
+
+ it('calls API.createLogo with the provided values', async () => {
+ const values = makeValues();
+ vi.mocked(API.createLogo).mockResolvedValue({ id: '2' });
+
+ await createLogo(values);
+
+ expect(API.createLogo).toHaveBeenCalledWith(values);
+ });
+
+ it('returns the result of API.createLogo', async () => {
+ const mockResult = { id: '2', name: 'New Logo' };
+ vi.mocked(API.createLogo).mockResolvedValue(mockResult);
+
+ const result = await createLogo(makeValues());
+
+ expect(result).toEqual(mockResult);
+ });
+
+ it('calls API.createLogo exactly once', async () => {
+ vi.mocked(API.createLogo).mockResolvedValue({});
+
+ await createLogo(makeValues());
+
+ expect(API.createLogo).toHaveBeenCalledTimes(1);
+ });
+
+ it('propagates rejection from API.createLogo', async () => {
+ vi.mocked(API.createLogo).mockRejectedValue(new Error('Create failed'));
+
+ await expect(createLogo(makeValues())).rejects.toThrow('Create failed');
+ });
+ });
+
+ // ── updateLogo ─────────────────────────────────────────────────────────────
+
+ describe('updateLogo', () => {
+ const makeLogo = (overrides = {}) => ({
+ id: 'logo-1',
+ name: 'Old Logo',
+ ...overrides,
+ });
+ const makeValues = () => ({
+ name: 'Updated Logo',
+ url: 'https://example.com/new.png',
+ });
+
+ it('calls API.updateLogo with the logo id and values', async () => {
+ const logo = makeLogo();
+ const values = makeValues();
+ vi.mocked(API.updateLogo).mockResolvedValue({ id: 'logo-1' });
+
+ await updateLogo(logo, values);
+
+ expect(API.updateLogo).toHaveBeenCalledWith('logo-1', values);
+ });
+
+ it('returns the result of API.updateLogo', async () => {
+ const mockResult = { id: 'logo-1', name: 'Updated Logo' };
+ vi.mocked(API.updateLogo).mockResolvedValue(mockResult);
+
+ const result = await updateLogo(makeLogo(), makeValues());
+
+ expect(result).toEqual(mockResult);
+ });
+
+ it('calls API.updateLogo exactly once', async () => {
+ vi.mocked(API.updateLogo).mockResolvedValue({});
+
+ await updateLogo(makeLogo(), makeValues());
+
+ expect(API.updateLogo).toHaveBeenCalledTimes(1);
+ });
+
+ it('uses only the id from the logo object', async () => {
+ const logo = makeLogo({ id: 'logo-99', name: 'Irrelevant' });
+ vi.mocked(API.updateLogo).mockResolvedValue({});
+
+ await updateLogo(logo, makeValues());
+
+ expect(API.updateLogo).toHaveBeenCalledWith('logo-99', expect.anything());
+ });
+
+ it('propagates rejection from API.updateLogo', async () => {
+ vi.mocked(API.updateLogo).mockRejectedValue(new Error('Update failed'));
+
+ await expect(updateLogo(makeLogo(), makeValues())).rejects.toThrow(
+ 'Update failed'
+ );
+ });
+ });
+
+ // ── getResolver ────────────────────────────────────────────────────────────
+
+ describe('getResolver', () => {
+ it('returns the result of yupResolver', () => {
+ const resolver = getResolver();
+
+ expect(yupResolver).toHaveBeenCalledTimes(1);
+ expect(resolver).toEqual(expect.objectContaining({ __resolver: true }));
+ });
+
+ it('passes a Yup schema to yupResolver', () => {
+ getResolver();
+
+ const [schema] = vi.mocked(yupResolver).mock.calls[0];
+ expect(schema).toBeDefined();
+ expect(typeof schema.validate).toBe('function');
+ });
+
+ it('schema validates a valid name and URL', async () => {
+ getResolver();
+ const [schema] = vi.mocked(yupResolver).mock.calls[0];
+
+ await expect(
+ schema.validate({
+ name: 'My Logo',
+ url: 'https://example.com/logo.png',
+ })
+ ).resolves.not.toThrow();
+ });
+
+ it('schema rejects missing name', async () => {
+ getResolver();
+ const [schema] = vi.mocked(yupResolver).mock.calls[0];
+
+ await expect(
+ schema.validate({ name: '', url: 'https://example.com/logo.png' })
+ ).rejects.toThrow('Name is required');
+ });
+
+ it('schema rejects missing url', async () => {
+ getResolver();
+ const [schema] = vi.mocked(yupResolver).mock.calls[0];
+
+ await expect(
+ schema.validate({ name: 'My Logo', url: '' })
+ ).rejects.toThrow('URL is required');
+ });
+
+ it('schema accepts a local /data/logos/ path as url', async () => {
+ getResolver();
+ const [schema] = vi.mocked(yupResolver).mock.calls[0];
+
+ await expect(
+ schema.validate({ name: 'Local Logo', url: '/data/logos/my-logo.png' })
+ ).resolves.not.toThrow();
+ });
+
+ it('schema rejects an invalid url that is not a local path', async () => {
+ getResolver();
+ const [schema] = vi.mocked(yupResolver).mock.calls[0];
+
+ await expect(
+ schema.validate({ name: 'Bad Logo', url: 'not-a-url' })
+ ).rejects.toThrow('Must be a valid URL or local file path');
+ });
+ });
+
+ // ── getUploadErrorMessage ──────────────────────────────────────────────────
+
+ describe('getUploadErrorMessage', () => {
+ it('returns timeout message for NETWORK_ERROR code', () => {
+ const result = getUploadErrorMessage({ code: 'NETWORK_ERROR' });
+ expect(result).toBe('Upload timed out. Please try again.');
+ });
+
+ it('returns timeout message when message includes "timeout"', () => {
+ const result = getUploadErrorMessage({
+ message: 'request timeout occurred',
+ });
+ expect(result).toBe('Upload timed out. Please try again.');
+ });
+
+ it('returns file too large message for status 413', () => {
+ const result = getUploadErrorMessage({ status: 413 });
+ expect(result).toBe('File too large. Please choose a smaller file.');
+ });
+
+ it('returns body error message when body.error is present', () => {
+ const result = getUploadErrorMessage({
+ body: { error: 'Unsupported format' },
+ });
+ expect(result).toBe('Unsupported format');
+ });
+
+ it('returns generic message when no specific condition matches', () => {
+ const result = getUploadErrorMessage({});
+ expect(result).toBe('Failed to upload logo file');
+ });
+
+ it('prioritizes NETWORK_ERROR over status 413', () => {
+ const result = getUploadErrorMessage({
+ code: 'NETWORK_ERROR',
+ status: 413,
+ });
+ expect(result).toBe('Upload timed out. Please try again.');
+ });
+
+ it('prioritizes status 413 over body.error', () => {
+ const result = getUploadErrorMessage({
+ status: 413,
+ body: { error: 'Custom error' },
+ });
+ expect(result).toBe('File too large. Please choose a smaller file.');
+ });
+ });
+
+ // ── getUpdateLogoErrorMessage ──────────────────────────────────────────────
+
+ describe('getUpdateLogoErrorMessage', () => {
+ const makeLogo = () => ({ id: 'logo-1', name: 'My Logo' });
+
+ it('returns timeout message for NETWORK_ERROR code', () => {
+ const result = getUpdateLogoErrorMessage(makeLogo(), {
+ code: 'NETWORK_ERROR',
+ });
+ expect(result).toBe('Request timed out. Please try again.');
+ });
+
+ it('returns timeout message when message includes "timeout"', () => {
+ const result = getUpdateLogoErrorMessage(makeLogo(), {
+ message: 'connection timeout',
+ });
+ expect(result).toBe('Request timed out. Please try again.');
+ });
+
+ it('returns response data error when present', () => {
+ const result = getUpdateLogoErrorMessage(makeLogo(), {
+ response: { data: { error: 'Duplicate name' } },
+ });
+ expect(result).toBe('Duplicate name');
+ });
+
+ it('returns "Failed to update logo" when logo is provided and no specific error', () => {
+ const result = getUpdateLogoErrorMessage(makeLogo(), {});
+ expect(result).toBe('Failed to update logo');
+ });
+
+ it('returns "Failed to create logo" when logo is null', () => {
+ const result = getUpdateLogoErrorMessage(null, {});
+ expect(result).toBe('Failed to create logo');
+ });
+
+ it('returns "Failed to create logo" when logo is undefined', () => {
+ const result = getUpdateLogoErrorMessage(undefined, {});
+ expect(result).toBe('Failed to create logo');
+ });
+
+ it('prioritizes NETWORK_ERROR over response.data.error', () => {
+ const result = getUpdateLogoErrorMessage(makeLogo(), {
+ code: 'NETWORK_ERROR',
+ response: { data: { error: 'Custom' } },
+ });
+ expect(result).toBe('Request timed out. Please try again.');
+ });
+ });
+
+ // ── validateFileSize ───────────────────────────────────────────────────────
+
+ describe('validateFileSize', () => {
+ const makeFile = (sizeBytes) => ({ size: sizeBytes });
+
+ it('returns true for a file exactly at the 5MB limit', () => {
+ const file = makeFile(5 * 1024 * 1024);
+ expect(validateFileSize(file)).toBe(true);
+ });
+
+ it('returns true for a file smaller than 5MB', () => {
+ const file = makeFile(1024 * 1024);
+ expect(validateFileSize(file)).toBe(true);
+ });
+
+ it('returns false for a file larger than 5MB', () => {
+ const file = makeFile(5 * 1024 * 1024 + 1);
+ expect(validateFileSize(file)).toBe(false);
+ });
+
+ it('returns true for a zero-byte file', () => {
+ const file = makeFile(0);
+ expect(validateFileSize(file)).toBe(true);
+ });
+ });
+
+ // ── releaseUrl ─────────────────────────────────────────────────────────────
+
+ describe('releaseUrl', () => {
+ beforeEach(() => {
+ URL.revokeObjectURL = vi.fn();
+ });
+
+ afterEach(() => {
+ delete URL.revokeObjectURL;
+ });
+
+ it('calls URL.revokeObjectURL for a blob URL', () => {
+ releaseUrl('blob:https://example.com/1234');
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith(
+ 'blob:https://example.com/1234'
+ );
+ });
+
+ it('does not call URL.revokeObjectURL for a non-blob URL', () => {
+ releaseUrl('https://example.com/logo.png');
+ expect(URL.revokeObjectURL).not.toHaveBeenCalled();
+ });
+
+ it('does not throw when url is null', () => {
+ expect(() => releaseUrl(null)).not.toThrow();
+ });
+
+ it('does not throw when url is undefined', () => {
+ expect(() => releaseUrl(undefined)).not.toThrow();
+ });
+
+ it('does not throw when url is an empty string', () => {
+ expect(() => releaseUrl('')).not.toThrow();
+ });
+ });
+
+ // ── getFilenameWithoutExtension ────────────────────────────────────────────
+
+ describe('getFilenameWithoutExtension', () => {
+ it('removes a single extension from a filename', () => {
+ expect(getFilenameWithoutExtension('logo.png')).toBe('logo');
+ });
+
+ it('removes only the last extension from a filename with multiple dots', () => {
+ expect(getFilenameWithoutExtension('my.logo.png')).toBe('my.logo');
+ });
+
+ it('handles filenames with no extension', () => {
+ expect(getFilenameWithoutExtension('logo')).toBe('logo');
+ });
+
+ it('handles filenames with a .jpg extension', () => {
+ expect(getFilenameWithoutExtension('channel-logo.jpg')).toBe(
+ 'channel-logo'
+ );
+ });
+
+ it('handles filenames with a .svg extension', () => {
+ expect(getFilenameWithoutExtension('icon.svg')).toBe('icon');
+ });
+
+ it('handles an empty string', () => {
+ expect(getFilenameWithoutExtension('')).toBe('');
+ });
+
+ it('handles a filename that is just an extension', () => {
+ expect(getFilenameWithoutExtension('.png')).toBe('');
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/M3uFilterUtils.test.js b/frontend/src/utils/forms/__tests__/M3uFilterUtils.test.js
new file mode 100644
index 00000000..0d582e1f
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/M3uFilterUtils.test.js
@@ -0,0 +1,230 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ addM3UFilter,
+ updateM3UFilter,
+ deleteM3UFilter,
+} from '../M3uFilterUtils.js';
+
+// ── API mock ───────────────────────────────────────────────────────────────────
+vi.mock('../../../api.js', () => ({
+ default: {
+ addM3UFilter: vi.fn(),
+ updateM3UFilter: vi.fn(),
+ deleteM3UFilter: vi.fn(),
+ },
+}));
+
+import API from '../../../api.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const makeM3U = (overrides = {}) => ({
+ id: 'm3u-1',
+ name: 'My Playlist',
+ ...overrides,
+});
+const makeFilter = (overrides = {}) => ({
+ id: 'filter-1',
+ name: 'Filter A',
+ ...overrides,
+});
+const makeValues = (overrides = {}) => ({
+ keyword: 'sports',
+ type: 'include',
+ ...overrides,
+});
+
+describe('M3uFilterUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ── addM3UFilter ───────────────────────────────────────────────────────────
+
+ describe('addM3UFilter', () => {
+ it('calls API.addM3UFilter with the m3u id and values', async () => {
+ vi.mocked(API.addM3UFilter).mockResolvedValue(undefined);
+
+ await addM3UFilter(makeM3U(), makeValues());
+
+ expect(API.addM3UFilter).toHaveBeenCalledWith('m3u-1', makeValues());
+ });
+
+ it('calls API.addM3UFilter exactly once', async () => {
+ vi.mocked(API.addM3UFilter).mockResolvedValue(undefined);
+
+ await addM3UFilter(makeM3U(), makeValues());
+
+ expect(API.addM3UFilter).toHaveBeenCalledTimes(1);
+ });
+
+ it('uses only the id from the m3u object', async () => {
+ vi.mocked(API.addM3UFilter).mockResolvedValue(undefined);
+ const m3u = makeM3U({ id: 'm3u-99', name: 'Irrelevant' });
+
+ await addM3UFilter(m3u, makeValues());
+
+ expect(API.addM3UFilter).toHaveBeenCalledWith(
+ 'm3u-99',
+ expect.anything()
+ );
+ });
+
+ it('passes values through unmodified', async () => {
+ vi.mocked(API.addM3UFilter).mockResolvedValue(undefined);
+ const values = { keyword: 'news', type: 'exclude', extra: 'data' };
+
+ await addM3UFilter(makeM3U(), values);
+
+ expect(API.addM3UFilter).toHaveBeenCalledWith(expect.anything(), values);
+ });
+
+ it('propagates rejection from API.addM3UFilter', async () => {
+ vi.mocked(API.addM3UFilter).mockRejectedValue(new Error('Add failed'));
+
+ await expect(addM3UFilter(makeM3U(), makeValues())).rejects.toThrow(
+ 'Add failed'
+ );
+ });
+
+ it('resolves without returning a value', async () => {
+ vi.mocked(API.addM3UFilter).mockResolvedValue({ id: 'filter-2' });
+
+ const result = await addM3UFilter(makeM3U(), makeValues());
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ // ── updateM3UFilter ────────────────────────────────────────────────────────
+
+ describe('updateM3UFilter', () => {
+ it('calls API.updateM3UFilter with the m3u id, filter id, and values', () => {
+ vi.mocked(API.updateM3UFilter).mockReturnValue({ id: 'filter-1' });
+
+ updateM3UFilter(makeM3U(), makeFilter(), makeValues());
+
+ expect(API.updateM3UFilter).toHaveBeenCalledWith(
+ 'm3u-1',
+ 'filter-1',
+ makeValues()
+ );
+ });
+
+ it('returns the result of API.updateM3UFilter', () => {
+ const mockResult = { id: 'filter-1', keyword: 'sports' };
+ vi.mocked(API.updateM3UFilter).mockReturnValue(mockResult);
+
+ const result = updateM3UFilter(makeM3U(), makeFilter(), makeValues());
+
+ expect(result).toBe(mockResult);
+ });
+
+ it('calls API.updateM3UFilter exactly once', () => {
+ vi.mocked(API.updateM3UFilter).mockReturnValue({});
+
+ updateM3UFilter(makeM3U(), makeFilter(), makeValues());
+
+ expect(API.updateM3UFilter).toHaveBeenCalledTimes(1);
+ });
+
+ it('uses only the id from the m3u object', () => {
+ vi.mocked(API.updateM3UFilter).mockReturnValue({});
+ const m3u = makeM3U({ id: 'm3u-42', name: 'Irrelevant' });
+
+ updateM3UFilter(m3u, makeFilter(), makeValues());
+
+ expect(API.updateM3UFilter).toHaveBeenCalledWith(
+ 'm3u-42',
+ expect.anything(),
+ expect.anything()
+ );
+ });
+
+ it('uses only the id from the filter object', () => {
+ vi.mocked(API.updateM3UFilter).mockReturnValue({});
+ const filter = makeFilter({ id: 'filter-99', name: 'Irrelevant' });
+
+ updateM3UFilter(makeM3U(), filter, makeValues());
+
+ expect(API.updateM3UFilter).toHaveBeenCalledWith(
+ expect.anything(),
+ 'filter-99',
+ expect.anything()
+ );
+ });
+
+ it('passes values through unmodified', () => {
+ vi.mocked(API.updateM3UFilter).mockReturnValue({});
+ const values = { keyword: 'movies', type: 'exclude', extra: 'data' };
+
+ updateM3UFilter(makeM3U(), makeFilter(), values);
+
+ expect(API.updateM3UFilter).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ values
+ );
+ });
+ });
+
+ // ── deleteM3UFilter ────────────────────────────────────────────────────────
+
+ describe('deleteM3UFilter', () => {
+ it('calls API.deleteM3UFilter with the playlist id and filter id', async () => {
+ vi.mocked(API.deleteM3UFilter).mockResolvedValue(undefined);
+
+ await deleteM3UFilter(makeM3U(), 'filter-1');
+
+ expect(API.deleteM3UFilter).toHaveBeenCalledWith('m3u-1', 'filter-1');
+ });
+
+ it('calls API.deleteM3UFilter exactly once', async () => {
+ vi.mocked(API.deleteM3UFilter).mockResolvedValue(undefined);
+
+ await deleteM3UFilter(makeM3U(), 'filter-1');
+
+ expect(API.deleteM3UFilter).toHaveBeenCalledTimes(1);
+ });
+
+ it('uses only the id from the playlist object', async () => {
+ vi.mocked(API.deleteM3UFilter).mockResolvedValue(undefined);
+ const playlist = makeM3U({ id: 'playlist-99', name: 'Irrelevant' });
+
+ await deleteM3UFilter(playlist, 'filter-1');
+
+ expect(API.deleteM3UFilter).toHaveBeenCalledWith(
+ 'playlist-99',
+ expect.anything()
+ );
+ });
+
+ it('passes the filter id through unmodified', async () => {
+ vi.mocked(API.deleteM3UFilter).mockResolvedValue(undefined);
+
+ await deleteM3UFilter(makeM3U(), 'filter-42');
+
+ expect(API.deleteM3UFilter).toHaveBeenCalledWith(
+ expect.anything(),
+ 'filter-42'
+ );
+ });
+
+ it('propagates rejection from API.deleteM3UFilter', async () => {
+ vi.mocked(API.deleteM3UFilter).mockRejectedValue(
+ new Error('Delete failed')
+ );
+
+ await expect(deleteM3UFilter(makeM3U(), 'filter-1')).rejects.toThrow(
+ 'Delete failed'
+ );
+ });
+
+ it('resolves without returning a value', async () => {
+ vi.mocked(API.deleteM3UFilter).mockResolvedValue({ success: true });
+
+ const result = await deleteM3UFilter(makeM3U(), 'filter-1');
+
+ expect(result).toBeUndefined();
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/M3uGroupFilterUtils.test.js b/frontend/src/utils/forms/__tests__/M3uGroupFilterUtils.test.js
new file mode 100644
index 00000000..f4270bef
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/M3uGroupFilterUtils.test.js
@@ -0,0 +1,479 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ buildGroupStates,
+ saveAndRefreshPlaylist,
+} from '../M3uGroupFilterUtils.js';
+
+// ── API mock ───────────────────────────────────────────────────────────────────
+vi.mock('../../../api.js', () => ({
+ default: {
+ updateM3UGroupSettings: vi.fn(),
+ },
+}));
+
+// ── M3uUtils mock ──────────────────────────────────────────────────────────────
+vi.mock('../M3uUtils.js', () => ({
+ refreshPlaylist: vi.fn(),
+ updatePlaylist: vi.fn(),
+}));
+
+import API from '../../../api.js';
+import { refreshPlaylist, updatePlaylist } from '../M3uUtils.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const makePlaylist = (overrides = {}) => ({
+ id: 'playlist-1',
+ name: 'My Playlist',
+ ...overrides,
+});
+
+const makeChannelGroups = () => ({
+ 'group-1': { name: 'Sports' },
+ 'group-2': { name: 'News' },
+ 'group-3': { name: 'Movies' },
+});
+
+const makePlaylistChannelGroup = (overrides = {}) => ({
+ channel_group: 'group-1',
+ enabled: true,
+ original_enabled: false,
+ auto_channel_sync: false,
+ auto_sync_channel_start: 1.0,
+ custom_properties: null,
+ ...overrides,
+});
+
+const makeCategoryState = (overrides = {}) => ({
+ id: 'cat-1',
+ enabled: true,
+ original_enabled: false,
+ custom_properties: null,
+ ...overrides,
+});
+
+const makeAutoEnableSettings = () => ({ auto_enable: true });
+
+describe('M3uGroupFilterUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(updatePlaylist).mockResolvedValue(undefined);
+ vi.mocked(API.updateM3UGroupSettings).mockResolvedValue(undefined);
+ vi.mocked(refreshPlaylist).mockResolvedValue(undefined);
+ });
+
+ // ── buildGroupStates ───────────────────────────────────────────────────────
+
+ describe('buildGroupStates', () => {
+ it('maps playlistChannelGroups to group states with channel group names', () => {
+ const channelGroups = makeChannelGroups();
+ const playlistChannelGroups = [makePlaylistChannelGroup()];
+
+ const result = buildGroupStates(channelGroups, playlistChannelGroups);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('Sports');
+ });
+
+ it('filters out groups whose channel_group key is not in channelGroups', () => {
+ const channelGroups = makeChannelGroups();
+ const playlistChannelGroups = [
+ makePlaylistChannelGroup({ channel_group: 'group-1' }),
+ makePlaylistChannelGroup({ channel_group: 'group-unknown' }),
+ ];
+
+ const result = buildGroupStates(channelGroups, playlistChannelGroups);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].channel_group).toBe('group-1');
+ });
+
+ it('returns an empty array when playlistChannelGroups is empty', () => {
+ const result = buildGroupStates(makeChannelGroups(), []);
+ expect(result).toEqual([]);
+ });
+
+ it('returns an empty array when no groups match channelGroups', () => {
+ const result = buildGroupStates(makeChannelGroups(), [
+ makePlaylistChannelGroup({ channel_group: 'group-unknown' }),
+ ]);
+ expect(result).toEqual([]);
+ });
+
+ it('defaults auto_channel_sync to false when not set', () => {
+ const result = buildGroupStates(makeChannelGroups(), [
+ makePlaylistChannelGroup({ auto_channel_sync: undefined }),
+ ]);
+ expect(result[0].auto_channel_sync).toBe(false);
+ });
+
+ it('preserves auto_channel_sync when set to true', () => {
+ const result = buildGroupStates(makeChannelGroups(), [
+ makePlaylistChannelGroup({ auto_channel_sync: true }),
+ ]);
+ expect(result[0].auto_channel_sync).toBe(true);
+ });
+
+ it('defaults auto_sync_channel_start to 1.0 when not set', () => {
+ const result = buildGroupStates(makeChannelGroups(), [
+ makePlaylistChannelGroup({ auto_sync_channel_start: undefined }),
+ ]);
+ expect(result[0].auto_sync_channel_start).toBe(1.0);
+ });
+
+ it('preserves auto_sync_channel_start when set', () => {
+ const result = buildGroupStates(makeChannelGroups(), [
+ makePlaylistChannelGroup({ auto_sync_channel_start: 5.0 }),
+ ]);
+ expect(result[0].auto_sync_channel_start).toBe(5.0);
+ });
+
+ it('spreads all original group properties into the result', () => {
+ const group = makePlaylistChannelGroup({
+ enabled: true,
+ extra_prop: 'value',
+ });
+ const result = buildGroupStates(makeChannelGroups(), [group]);
+ expect(result[0].enabled).toBe(true);
+ expect(result[0].extra_prop).toBe('value');
+ });
+
+ it('maps multiple groups correctly', () => {
+ const channelGroups = makeChannelGroups();
+ const playlistChannelGroups = [
+ makePlaylistChannelGroup({ channel_group: 'group-1' }),
+ makePlaylistChannelGroup({ channel_group: 'group-2' }),
+ makePlaylistChannelGroup({ channel_group: 'group-3' }),
+ ];
+
+ const result = buildGroupStates(channelGroups, playlistChannelGroups);
+
+ expect(result).toHaveLength(3);
+ expect(result.map((r) => r.name)).toEqual(['Sports', 'News', 'Movies']);
+ });
+
+ // ── parseCustomProperties (via buildGroupStates) ─────────────────────────
+
+ describe('parseCustomProperties (via custom_properties field)', () => {
+ it('returns {} when custom_properties is null', () => {
+ const result = buildGroupStates(makeChannelGroups(), [
+ makePlaylistChannelGroup({ custom_properties: null }),
+ ]);
+ expect(result[0].custom_properties).toEqual({});
+ });
+
+ it('returns {} when custom_properties is undefined', () => {
+ const result = buildGroupStates(makeChannelGroups(), [
+ makePlaylistChannelGroup({ custom_properties: undefined }),
+ ]);
+ expect(result[0].custom_properties).toEqual({});
+ });
+
+ it('parses a valid JSON string into an object', () => {
+ const result = buildGroupStates(makeChannelGroups(), [
+ makePlaylistChannelGroup({ custom_properties: '{"key":"value"}' }),
+ ]);
+ expect(result[0].custom_properties).toEqual({ key: 'value' });
+ });
+
+ it('returns an already-parsed object as-is', () => {
+ const result = buildGroupStates(makeChannelGroups(), [
+ makePlaylistChannelGroup({ custom_properties: { key: 'value' } }),
+ ]);
+ expect(result[0].custom_properties).toEqual({ key: 'value' });
+ });
+
+ it('returns {} for invalid JSON string', () => {
+ const result = buildGroupStates(makeChannelGroups(), [
+ makePlaylistChannelGroup({ custom_properties: '{invalid-json}' }),
+ ]);
+ expect(result[0].custom_properties).toEqual({});
+ });
+ });
+ });
+
+ // ── saveAndRefreshPlaylist ─────────────────────────────────────────────────
+
+ describe('saveAndRefreshPlaylist', () => {
+ it('calls updatePlaylist with playlist and autoEnableSettings', async () => {
+ const playlist = makePlaylist();
+ const autoEnableSettings = makeAutoEnableSettings();
+
+ await saveAndRefreshPlaylist(playlist, [], [], [], autoEnableSettings);
+
+ expect(updatePlaylist).toHaveBeenCalledWith(playlist, autoEnableSettings);
+ });
+
+ it('calls API.updateM3UGroupSettings with playlist id, groupSettings, and categorySettings', async () => {
+ const playlist = makePlaylist();
+
+ await saveAndRefreshPlaylist(
+ playlist,
+ [],
+ [],
+ [],
+ makeAutoEnableSettings()
+ );
+
+ expect(API.updateM3UGroupSettings).toHaveBeenCalledWith(
+ 'playlist-1',
+ expect.anything(),
+ expect.anything()
+ );
+ });
+
+ it('calls refreshPlaylist with playlist', async () => {
+ const playlist = makePlaylist();
+
+ await saveAndRefreshPlaylist(
+ playlist,
+ [],
+ [],
+ [],
+ makeAutoEnableSettings()
+ );
+
+ expect(refreshPlaylist).toHaveBeenCalledWith(playlist);
+ });
+
+ it('calls updatePlaylist, updateM3UGroupSettings, and refreshPlaylist exactly once each', async () => {
+ await saveAndRefreshPlaylist(
+ makePlaylist(),
+ [],
+ [],
+ [],
+ makeAutoEnableSettings()
+ );
+
+ expect(updatePlaylist).toHaveBeenCalledTimes(1);
+ expect(API.updateM3UGroupSettings).toHaveBeenCalledTimes(1);
+ expect(refreshPlaylist).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls updatePlaylist before updateM3UGroupSettings', async () => {
+ const callOrder = [];
+ vi.mocked(updatePlaylist).mockImplementation(async () => {
+ callOrder.push('updatePlaylist');
+ });
+ vi.mocked(API.updateM3UGroupSettings).mockImplementation(async () => {
+ callOrder.push('updateM3UGroupSettings');
+ });
+ vi.mocked(refreshPlaylist).mockImplementation(async () => {
+ callOrder.push('refreshPlaylist');
+ });
+
+ await saveAndRefreshPlaylist(
+ makePlaylist(),
+ [],
+ [],
+ [],
+ makeAutoEnableSettings()
+ );
+
+ expect(callOrder.indexOf('updatePlaylist')).toBeLessThan(
+ callOrder.indexOf('updateM3UGroupSettings')
+ );
+ });
+
+ it('calls updateM3UGroupSettings before refreshPlaylist', async () => {
+ const callOrder = [];
+ vi.mocked(updatePlaylist).mockImplementation(async () => {
+ callOrder.push('updatePlaylist');
+ });
+ vi.mocked(API.updateM3UGroupSettings).mockImplementation(async () => {
+ callOrder.push('updateM3UGroupSettings');
+ });
+ vi.mocked(refreshPlaylist).mockImplementation(async () => {
+ callOrder.push('refreshPlaylist');
+ });
+
+ await saveAndRefreshPlaylist(
+ makePlaylist(),
+ [],
+ [],
+ [],
+ makeAutoEnableSettings()
+ );
+
+ expect(callOrder.indexOf('updateM3UGroupSettings')).toBeLessThan(
+ callOrder.indexOf('refreshPlaylist')
+ );
+ });
+
+ it('propagates rejection from updatePlaylist', async () => {
+ vi.mocked(updatePlaylist).mockRejectedValue(new Error('Update failed'));
+
+ await expect(
+ saveAndRefreshPlaylist(
+ makePlaylist(),
+ [],
+ [],
+ [],
+ makeAutoEnableSettings()
+ )
+ ).rejects.toThrow('Update failed');
+ });
+
+ it('propagates rejection from API.updateM3UGroupSettings', async () => {
+ vi.mocked(API.updateM3UGroupSettings).mockRejectedValue(
+ new Error('Settings failed')
+ );
+
+ await expect(
+ saveAndRefreshPlaylist(
+ makePlaylist(),
+ [],
+ [],
+ [],
+ makeAutoEnableSettings()
+ )
+ ).rejects.toThrow('Settings failed');
+ });
+
+ it('propagates rejection from refreshPlaylist', async () => {
+ vi.mocked(refreshPlaylist).mockRejectedValue(new Error('Refresh failed'));
+
+ await expect(
+ saveAndRefreshPlaylist(
+ makePlaylist(),
+ [],
+ [],
+ [],
+ makeAutoEnableSettings()
+ )
+ ).rejects.toThrow('Refresh failed');
+ });
+
+ it('does not call refreshPlaylist when updateM3UGroupSettings rejects', async () => {
+ vi.mocked(API.updateM3UGroupSettings).mockRejectedValue(
+ new Error('fail')
+ );
+
+ await saveAndRefreshPlaylist(
+ makePlaylist(),
+ [],
+ [],
+ [],
+ makeAutoEnableSettings()
+ ).catch(() => {});
+
+ expect(refreshPlaylist).not.toHaveBeenCalled();
+ });
+
+ // ── prepareCategorySettings (via categorySettings arg) ───────────────────
+
+ describe('prepareCategorySettings (via API.updateM3UGroupSettings call)', () => {
+ it('includes category states where enabled differs from original_enabled', async () => {
+ const changedMovie = makeCategoryState({
+ enabled: true,
+ original_enabled: false,
+ });
+ const unchangedMovie = makeCategoryState({
+ id: 'cat-2',
+ enabled: false,
+ original_enabled: false,
+ });
+
+ await saveAndRefreshPlaylist(
+ makePlaylist(),
+ [],
+ [changedMovie],
+ [unchangedMovie],
+ makeAutoEnableSettings()
+ );
+
+ const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings)
+ .mock.calls[0];
+ expect(categorySettings).toHaveLength(1);
+ expect(categorySettings[0].id).toBe('cat-1');
+ });
+
+ it('excludes category states where enabled equals original_enabled', async () => {
+ const unchanged = makeCategoryState({
+ enabled: true,
+ original_enabled: true,
+ });
+
+ await saveAndRefreshPlaylist(
+ makePlaylist(),
+ [],
+ [unchanged],
+ [],
+ makeAutoEnableSettings()
+ );
+
+ const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings)
+ .mock.calls[0];
+ expect(categorySettings).toHaveLength(0);
+ });
+
+ it('merges movie and series category states together', async () => {
+ const movieCat = makeCategoryState({
+ id: 'movie-1',
+ enabled: true,
+ original_enabled: false,
+ });
+ const seriesCat = makeCategoryState({
+ id: 'series-1',
+ enabled: false,
+ original_enabled: true,
+ });
+
+ await saveAndRefreshPlaylist(
+ makePlaylist(),
+ [],
+ [movieCat],
+ [seriesCat],
+ makeAutoEnableSettings()
+ );
+
+ const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings)
+ .mock.calls[0];
+ expect(categorySettings).toHaveLength(2);
+ expect(categorySettings.map((c) => c.id)).toEqual([
+ 'movie-1',
+ 'series-1',
+ ]);
+ });
+
+ it('sets custom_properties to undefined when null', async () => {
+ const cat = makeCategoryState({
+ custom_properties: null,
+ enabled: true,
+ original_enabled: false,
+ });
+
+ await saveAndRefreshPlaylist(
+ makePlaylist(),
+ [],
+ [cat],
+ [],
+ makeAutoEnableSettings()
+ );
+
+ const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings)
+ .mock.calls[0];
+ expect(categorySettings[0].custom_properties).toBeUndefined();
+ });
+
+ it('preserves custom_properties when present', async () => {
+ const cat = makeCategoryState({
+ custom_properties: { key: 'value' },
+ enabled: true,
+ original_enabled: false,
+ });
+
+ await saveAndRefreshPlaylist(
+ makePlaylist(),
+ [],
+ [cat],
+ [],
+ makeAutoEnableSettings()
+ );
+
+ const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings)
+ .mock.calls[0];
+ expect(categorySettings[0].custom_properties).toEqual({ key: 'value' });
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/M3uProfileUtils.test.js b/frontend/src/utils/forms/__tests__/M3uProfileUtils.test.js
new file mode 100644
index 00000000..9324d58c
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/M3uProfileUtils.test.js
@@ -0,0 +1,615 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as Yup from 'yup';
+import {
+ updateM3UProfile,
+ addM3UProfile,
+ deleteM3UProfile,
+ getDetectedMode,
+ applyRegex,
+ buildProfileSchema,
+ fetchFirstStreamUrl,
+ validateXcSimple,
+ prepareExpDate,
+ applyXcSimplePatterns,
+ buildSubmitValues,
+ splitByPattern,
+} from '../M3uProfileUtils.js';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ updateM3UProfile: vi.fn(),
+ addM3UProfile: vi.fn(),
+ deleteM3UProfile: vi.fn(),
+ queryStreams: vi.fn(),
+ },
+}));
+
+import API from '../../../api.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const makeM3U = (overrides = {}) => ({
+ id: 'm3u-1',
+ name: 'My Playlist',
+ ...overrides,
+});
+const makeSubmitValues = (overrides = {}) => ({
+ name: 'Profile A',
+ xcMode: false,
+ ...overrides,
+});
+
+describe('M3uProfileUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ── updateM3UProfile ───────────────────────────────────────────────────────
+
+ describe('updateM3UProfile', () => {
+ it('calls API.updateM3UProfile with the m3u id and submitValues', async () => {
+ vi.mocked(API.updateM3UProfile).mockResolvedValue(undefined);
+ const m3u = makeM3U();
+ const values = makeSubmitValues();
+
+ await updateM3UProfile(m3u.id, values);
+
+ expect(API.updateM3UProfile).toHaveBeenCalledWith(m3u.id, values);
+ });
+
+ it('calls API.updateM3UProfile exactly once', async () => {
+ vi.mocked(API.updateM3UProfile).mockResolvedValue(undefined);
+ const m3u = makeM3U();
+
+ await updateM3UProfile(m3u.id, makeSubmitValues());
+
+ expect(API.updateM3UProfile).toHaveBeenCalledTimes(1);
+ });
+
+ it('uses only the id from the m3u object', async () => {
+ vi.mocked(API.updateM3UProfile).mockResolvedValue(undefined);
+ const m3u = makeM3U({ id: 'playlist-99', name: 'Irrelevant' });
+
+ await updateM3UProfile(m3u.id, makeSubmitValues());
+
+ expect(API.updateM3UProfile).toHaveBeenCalledWith(
+ m3u.id,
+ expect.anything()
+ );
+ });
+
+ it('passes submitValues through unmodified', async () => {
+ vi.mocked(API.updateM3UProfile).mockResolvedValue(undefined);
+ const values = makeSubmitValues({ xcMode: true, extra: 'data' });
+ const m3u = makeM3U();
+
+ await updateM3UProfile(m3u.id, values);
+
+ expect(API.updateM3UProfile).toHaveBeenCalledWith(
+ expect.anything(),
+ values
+ );
+ });
+
+ it('propagates rejection from API.updateM3UProfile', async () => {
+ vi.mocked(API.updateM3UProfile).mockRejectedValue(
+ new Error('Update failed')
+ );
+ const m3u = makeM3U();
+
+ await expect(
+ updateM3UProfile(m3u.id, makeSubmitValues())
+ ).rejects.toThrow('Update failed');
+ });
+
+ it('resolves without returning a value', async () => {
+ vi.mocked(API.updateM3UProfile).mockResolvedValue({ success: true });
+ const m3u = makeM3U();
+
+ const result = await updateM3UProfile(m3u.id, makeSubmitValues());
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ // ── addM3UProfile ──────────────────────────────────────────────────────────
+
+ describe('addM3UProfile', () => {
+ it('calls API.addM3UProfile with the m3u id and submitValues', async () => {
+ vi.mocked(API.addM3UProfile).mockResolvedValue(undefined);
+ const m3u = makeM3U();
+ const values = makeSubmitValues();
+
+ await addM3UProfile(m3u.id, values);
+
+ expect(API.addM3UProfile).toHaveBeenCalledWith(m3u.id, values);
+ });
+
+ it('calls API.addM3UProfile exactly once', async () => {
+ vi.mocked(API.addM3UProfile).mockResolvedValue(undefined);
+ const m3u = makeM3U();
+
+ await addM3UProfile(m3u.id, makeSubmitValues());
+
+ expect(API.addM3UProfile).toHaveBeenCalledTimes(1);
+ });
+
+ it('uses only the id from the m3u object', async () => {
+ vi.mocked(API.addM3UProfile).mockResolvedValue(undefined);
+ const m3u = makeM3U({ id: 'playlist-42', name: 'Irrelevant' });
+
+ await addM3UProfile(m3u.id, makeSubmitValues());
+
+ expect(API.addM3UProfile).toHaveBeenCalledWith(m3u.id, expect.anything());
+ });
+
+ it('passes submitValues through unmodified', async () => {
+ vi.mocked(API.addM3UProfile).mockResolvedValue(undefined);
+ const values = makeSubmitValues({ xcMode: true, extra: 'data' });
+ const m3u = makeM3U();
+
+ await addM3UProfile(m3u.id, values);
+
+ expect(API.addM3UProfile).toHaveBeenCalledWith(expect.anything(), values);
+ });
+
+ it('propagates rejection from API.addM3UProfile', async () => {
+ vi.mocked(API.addM3UProfile).mockRejectedValue(new Error('Add failed'));
+ const m3u = makeM3U();
+
+ await expect(addM3UProfile(m3u.id, makeSubmitValues())).rejects.toThrow(
+ 'Add failed'
+ );
+ });
+
+ it('resolves without returning a value', async () => {
+ vi.mocked(API.addM3UProfile).mockResolvedValue({ id: 'new-profile' });
+ const m3u = makeM3U();
+
+ const result = await addM3UProfile(m3u.id, makeSubmitValues());
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ // ── deleteM3UProfile ───────────────────────────────────────────────────────
+
+ describe('deleteM3UProfile', () => {
+ it('calls API.deleteM3UProfile with the playlist id and profile id', async () => {
+ vi.mocked(API.deleteM3UProfile).mockResolvedValue(undefined);
+ const m3u = makeM3U();
+
+ await deleteM3UProfile(m3u.id, 'profile-1');
+
+ expect(API.deleteM3UProfile).toHaveBeenCalledWith(m3u.id, 'profile-1');
+ });
+
+ it('calls API.deleteM3UProfile exactly once', async () => {
+ vi.mocked(API.deleteM3UProfile).mockResolvedValue(undefined);
+ const m3u = makeM3U();
+
+ await deleteM3UProfile(m3u.id, 'profile-1');
+
+ expect(API.deleteM3UProfile).toHaveBeenCalledTimes(1);
+ });
+
+ it('uses only the id from the playlist object', async () => {
+ vi.mocked(API.deleteM3UProfile).mockResolvedValue(undefined);
+ const playlist = makeM3U({ id: 'playlist-99', name: 'Irrelevant' });
+
+ await deleteM3UProfile(playlist.id, 'profile-1');
+
+ expect(API.deleteM3UProfile).toHaveBeenCalledWith(
+ playlist.id,
+ expect.anything()
+ );
+ });
+
+ it('passes the profile id through unmodified', async () => {
+ vi.mocked(API.deleteM3UProfile).mockResolvedValue(undefined);
+ const m3u = makeM3U();
+
+ await deleteM3UProfile(m3u.id, 'profile-42');
+
+ expect(API.deleteM3UProfile).toHaveBeenCalledWith(
+ expect.anything(),
+ 'profile-42'
+ );
+ });
+
+ it('propagates rejection from API.deleteM3UProfile', async () => {
+ vi.mocked(API.deleteM3UProfile).mockRejectedValue(
+ new Error('Delete failed')
+ );
+ const m3u = makeM3U();
+
+ await expect(deleteM3UProfile(m3u.id, 'profile-1')).rejects.toThrow(
+ 'Delete failed'
+ );
+ });
+
+ it('resolves without returning a value', async () => {
+ vi.mocked(API.deleteM3UProfile).mockResolvedValue({ success: true });
+ const m3u = makeM3U();
+
+ const result = await deleteM3UProfile(m3u.id, 'profile-1');
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ // ── getDetectedMode ───────────────────────────────────────────────────────
+
+ describe('getDetectedMode', () => {
+ it('returns storedMode when provided', () => {
+ expect(getDetectedMode('advanced', null, null)).toBe('advanced');
+ });
+
+ it('returns "simple" when search_pattern matches username/password', () => {
+ const m3u = { username: 'user', password: 'pass' };
+ const profile = { search_pattern: 'user/pass' };
+ expect(getDetectedMode(null, profile, m3u)).toBe('simple');
+ });
+
+ it('returns "advanced" when search_pattern exists but does not match username/password', () => {
+ const m3u = { username: 'user', password: 'pass' };
+ const profile = { search_pattern: 'other/pattern' };
+ expect(getDetectedMode(null, profile, m3u)).toBe('advanced');
+ });
+
+ it('returns "simple" when no storedMode and no profile', () => {
+ expect(getDetectedMode(null, null, null)).toBe('simple');
+ });
+
+ it('returns "simple" when profile has no search_pattern', () => {
+ const profile = {};
+ expect(
+ getDetectedMode(null, profile, { username: 'u', password: 'p' })
+ ).toBe('simple');
+ });
+ });
+
+ // ── applyRegex ───────────────────────────────────────────────────────────
+
+ describe('applyRegex', () => {
+ it('returns input when pattern is empty', () => {
+ expect(applyRegex('hello world', '', 'X')).toBe('hello world');
+ });
+
+ it('returns input when input is empty/null', () => {
+ expect(applyRegex(null, 'pattern', 'X')).toBeNull();
+ expect(applyRegex('', 'pattern', 'X')).toBe('');
+ });
+
+ it('applies regex replacement globally', () => {
+ expect(applyRegex('aabaa', 'a', 'X')).toBe('XXbXX');
+ });
+
+ it('returns input when regex is invalid', () => {
+ expect(applyRegex('hello', '[invalid', 'X')).toBe('hello');
+ });
+
+ it('replaces matched groups', () => {
+ expect(applyRegex('foo/bar', '(foo)', 'baz')).toBe('baz/bar');
+ });
+ });
+
+ // ── buildProfileSchema ─────────────────────────────────────────────────
+
+ describe('buildProfileSchema', () => {
+ it('requires search_pattern and replace_pattern when not default and not XC', async () => {
+ const schema = buildProfileSchema(false, false);
+ await expect(
+ schema.validate({ name: 'Test' }, { abortEarly: false })
+ ).rejects.toThrow();
+ const error = await schema
+ .validate({ name: 'Test' }, { abortEarly: false })
+ .catch((e) => e);
+ expect(error.errors).toContain('Search pattern is required');
+ expect(error.errors).toContain('Replace pattern is required');
+ });
+
+ it('does not require search_pattern and replace_pattern for default profile', async () => {
+ const schema = buildProfileSchema(true, false);
+ await expect(schema.validate({ name: 'Test' })).resolves.toBeTruthy();
+ });
+
+ it('does not require search_pattern and replace_pattern when isXC is true', async () => {
+ const schema = buildProfileSchema(false, true);
+ await expect(schema.validate({ name: 'Test' })).resolves.toBeTruthy();
+ });
+
+ it('requires name in all cases', async () => {
+ const schema = buildProfileSchema(true, true);
+ const error = await schema
+ .validate({}, { abortEarly: false })
+ .catch((e) => e);
+ expect(error.errors).toContain('Name is required');
+ });
+
+ it('validates successfully with all required fields', async () => {
+ const schema = buildProfileSchema(false, false);
+ await expect(
+ schema.validate({
+ name: 'Test',
+ search_pattern: 'foo',
+ replace_pattern: 'bar',
+ })
+ ).resolves.toBeTruthy();
+ });
+ });
+
+ // ── fetchFirstStreamUrl ─────────────────────────────────────────────────
+
+ describe('fetchFirstStreamUrl', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it('returns url from first result', async () => {
+ API.queryStreams.mockResolvedValue({
+ results: [{ url: 'http://stream.url' }],
+ });
+ const url = await fetchFirstStreamUrl(5);
+ expect(url).toBe('http://stream.url');
+ const params = API.queryStreams.mock.calls[0][0];
+ expect(params.get('page')).toBe('1');
+ expect(params.get('page_size')).toBe('1');
+ expect(params.get('m3u_account')).toBe('5');
+ });
+
+ it('returns null when results are empty', async () => {
+ API.queryStreams.mockResolvedValue({ results: [] });
+ const url = await fetchFirstStreamUrl(5);
+ expect(url).toBeNull();
+ });
+
+ it('returns null when response is null', async () => {
+ API.queryStreams.mockResolvedValue(null);
+ const url = await fetchFirstStreamUrl(5);
+ expect(url).toBeNull();
+ });
+ });
+
+ // ── validateXcSimple ─────────────────────────────────────────────────
+
+ describe('validateXcSimple', () => {
+ it('returns errors when both fields are empty', () => {
+ const errs = validateXcSimple('', '');
+ expect(errs.newUsername).toBe('New username is required');
+ expect(errs.newPassword).toBe('New password is required');
+ });
+
+ it('returns error only for empty username', () => {
+ const errs = validateXcSimple('', 'pass');
+ expect(errs.newUsername).toBe('New username is required');
+ expect(errs.newPassword).toBeUndefined();
+ });
+
+ it('returns error only for empty password', () => {
+ const errs = validateXcSimple('user', '');
+ expect(errs.newUsername).toBeUndefined();
+ expect(errs.newPassword).toBe('New password is required');
+ });
+
+ it('returns no errors when both fields are valid', () => {
+ const errs = validateXcSimple('user', 'pass');
+ expect(errs).toEqual({});
+ });
+
+ it('treats whitespace-only values as empty', () => {
+ const errs = validateXcSimple(' ', ' ');
+ expect(errs.newUsername).toBe('New username is required');
+ expect(errs.newPassword).toBe('New password is required');
+ });
+ });
+
+ // ── prepareExpDate ─────────────────────────────────────────────────
+
+ describe('prepareExpDate', () => {
+ it('returns undefined when isXC is true', () => {
+ expect(prepareExpDate('2025-01-01', true)).toBeUndefined();
+ });
+
+ it('returns ISO string when value is a Date', () => {
+ const date = new Date('2025-06-15T00:00:00.000Z');
+ expect(prepareExpDate(date, false)).toBe(date.toISOString());
+ });
+
+ it('returns the value as-is when it is a string', () => {
+ expect(prepareExpDate('2025-06-15', false)).toBe('2025-06-15');
+ });
+
+ it('returns null when value is falsy and not XC', () => {
+ expect(prepareExpDate(null, false)).toBeNull();
+ expect(prepareExpDate('', false)).toBeNull();
+ expect(prepareExpDate(undefined, false)).toBeNull();
+ });
+ });
+
+ // ── applyXcSimplePatterns ───────────────────────────────────────────────
+
+ describe('applyXcSimplePatterns', () => {
+ it('sets search_pattern and replace_pattern from m3u credentials and new credentials', () => {
+ const m3u = { username: 'oldUser', password: 'oldPass' };
+ const values = { name: 'Test', notes: 'some note' };
+ const result = applyXcSimplePatterns(values, m3u, 'newUser', 'newPass');
+ expect(result.search_pattern).toBe('oldUser/oldPass');
+ expect(result.replace_pattern).toBe('newUser/newPass');
+ expect(result.name).toBe('Test');
+ });
+
+ it('handles missing m3u username and password gracefully', () => {
+ const result = applyXcSimplePatterns({}, null, 'newUser', 'newPass');
+ expect(result.search_pattern).toBe('/');
+ expect(result.replace_pattern).toBe('newUser/newPass');
+ });
+
+ it('trims whitespace from newUsername and newPassword', () => {
+ const m3u = { username: 'u', password: 'p' };
+ const result = applyXcSimplePatterns(
+ {},
+ m3u,
+ ' newUser ',
+ ' newPass '
+ );
+ expect(result.replace_pattern).toBe('newUser/newPass');
+ });
+ });
+
+ // ── buildSubmitValues ─────────────────────────────────────────────────
+
+ describe('buildSubmitValues', () => {
+ const baseValues = {
+ name: 'Profile Name',
+ max_streams: 5,
+ search_pattern: 'foo',
+ replace_pattern: 'bar',
+ notes: 'some notes',
+ };
+
+ it('returns name, search_pattern, replace_pattern and custom_properties for default profile', () => {
+ const profile = { custom_properties: { existing: 'prop' } };
+ const result = buildSubmitValues(baseValues, profile, true, false, null);
+ expect(result).toEqual({
+ name: 'Profile Name',
+ search_pattern: 'foo',
+ replace_pattern: 'bar',
+ custom_properties: { existing: 'prop', notes: 'some notes' },
+ });
+ expect(result.max_streams).toBeUndefined();
+ });
+
+ it('returns full values for non-default, non-XC profile', () => {
+ const profile = { custom_properties: {} };
+ const result = buildSubmitValues(baseValues, profile, false, false, null);
+ expect(result).toEqual({
+ name: 'Profile Name',
+ max_streams: 5,
+ search_pattern: 'foo',
+ replace_pattern: 'bar',
+ custom_properties: { notes: 'some notes' },
+ });
+ });
+
+ it('includes xcMode in custom_properties when isXC is true', () => {
+ const profile = { custom_properties: {} };
+ const result = buildSubmitValues(
+ baseValues,
+ profile,
+ false,
+ true,
+ 'simple'
+ );
+ expect(result.custom_properties.xcMode).toBe('simple');
+ });
+
+ it('does not include xcMode when isXC is false', () => {
+ const profile = { custom_properties: {} };
+ const result = buildSubmitValues(
+ baseValues,
+ profile,
+ false,
+ false,
+ 'simple'
+ );
+ expect(result.custom_properties.xcMode).toBeUndefined();
+ });
+
+ it('handles null or undefined profile custom_properties', () => {
+ const result = buildSubmitValues(baseValues, null, true, false, null);
+ expect(result.custom_properties).toEqual({ notes: 'some notes' });
+ });
+
+ it('uses empty string for notes when notes is not provided', () => {
+ const values = { ...baseValues, notes: undefined };
+ const result = buildSubmitValues(values, null, true, false, null);
+ expect(result.custom_properties.notes).toBe('');
+ });
+ });
+
+ // ── splitByPattern ─────────────────────────────────────────────────────────
+
+ describe('splitByPattern', () => {
+ it('returns null when pattern is empty', () => {
+ expect(splitByPattern('hello world', '')).toBeNull();
+ });
+
+ it('returns null when input is empty', () => {
+ expect(splitByPattern('', 'hello')).toBeNull();
+ });
+
+ it('returns null when input is null', () => {
+ expect(splitByPattern(null, 'hello')).toBeNull();
+ });
+
+ it('returns null when pattern is null', () => {
+ expect(splitByPattern('hello', null)).toBeNull();
+ });
+
+ it('returns null for an invalid regex pattern', () => {
+ expect(splitByPattern('hello', '[invalid')).toBeNull();
+ });
+
+ it('returns a single unmatched segment when pattern does not match', () => {
+ expect(splitByPattern('hello world', 'xyz')).toEqual([
+ { text: 'hello world', matched: false },
+ ]);
+ });
+
+ it('returns a single matched segment when entire input matches', () => {
+ expect(splitByPattern('hello', 'hello')).toEqual([
+ { text: 'hello', matched: true },
+ ]);
+ });
+
+ it('splits into unmatched/matched/unmatched segments', () => {
+ expect(splitByPattern('say hello there', 'hello')).toEqual([
+ { text: 'say ', matched: false },
+ { text: 'hello', matched: true },
+ { text: ' there', matched: false },
+ ]);
+ });
+
+ it('handles multiple matches in the input', () => {
+ expect(splitByPattern('aXbXc', 'X')).toEqual([
+ { text: 'a', matched: false },
+ { text: 'X', matched: true },
+ { text: 'b', matched: false },
+ { text: 'X', matched: true },
+ { text: 'c', matched: false },
+ ]);
+ });
+
+ it('handles a match at the start of the input', () => {
+ expect(splitByPattern('helloworld', 'hello')).toEqual([
+ { text: 'hello', matched: true },
+ { text: 'world', matched: false },
+ ]);
+ });
+
+ it('handles a match at the end of the input', () => {
+ expect(splitByPattern('worldhello', 'hello')).toEqual([
+ { text: 'world', matched: false },
+ { text: 'hello', matched: true },
+ ]);
+ });
+
+ it('handles capture-group patterns', () => {
+ const result = splitByPattern('foo/bar', '(foo)');
+ expect(result).toEqual([
+ { text: 'foo', matched: true },
+ { text: '/bar', matched: false },
+ ]);
+ });
+
+ it('handles case-insensitive flag in pattern', () => {
+ const result = splitByPattern('Hello World', '(?i)hello');
+ // invalid flag combo — regex throws — should return null
+ expect(result).toBeNull();
+ });
+
+ it('does not loop infinitely on zero-length matches', () => {
+ // zero-length match: pattern matches between every character
+ const result = splitByPattern('abc', 'x*');
+ expect(Array.isArray(result)).toBe(true);
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/M3uProfilesUtils.test.js b/frontend/src/utils/forms/__tests__/M3uProfilesUtils.test.js
new file mode 100644
index 00000000..ae493a16
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/M3uProfilesUtils.test.js
@@ -0,0 +1,287 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ parseExpirationDate,
+ isAccountExpired,
+ getExpirationInfo,
+ profileSortComparator,
+} from '../M3uProfilesUtils.js';
+
+// ── Fixed "now" for deterministic tests ───────────────────────────────────────
+const NOW = new Date('2024-06-01T12:00:00Z');
+
+// Unix timestamp helpers
+const unixSecondsFromNow = (offsetMs) =>
+ String(Math.floor((NOW.getTime() + offsetMs) / 1000));
+
+const MS = {
+ hour: 1000 * 60 * 60,
+ day: 1000 * 60 * 60 * 24,
+};
+
+describe('M3uProfilesUtils', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(NOW);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ // ── parseExpirationDate ────────────────────────────────────────────────────
+
+ describe('parseExpirationDate', () => {
+ const makeProfile = (expDate) => ({
+ custom_properties: { user_info: { exp_date: expDate } },
+ });
+
+ it('returns a Date object for a valid unix timestamp string', () => {
+ const expUnix = unixSecondsFromNow(MS.day);
+ const result = parseExpirationDate(makeProfile(expUnix));
+ expect(result).toBeInstanceOf(Date);
+ });
+
+ it('returns the correct date for a known unix timestamp', () => {
+ // 2024-06-02T12:00:00Z = NOW + 1 day
+ const expUnix = unixSecondsFromNow(MS.day);
+ const result = parseExpirationDate(makeProfile(expUnix));
+ expect(result.getTime()).toBeCloseTo(NOW.getTime() + MS.day, -3);
+ });
+
+ it('returns null when profile is null', () => {
+ expect(parseExpirationDate(null)).toBeNull();
+ });
+
+ it('returns null when profile is undefined', () => {
+ expect(parseExpirationDate(undefined)).toBeNull();
+ });
+
+ it('returns null when custom_properties is missing', () => {
+ expect(parseExpirationDate({})).toBeNull();
+ });
+
+ it('returns null when user_info is missing', () => {
+ expect(parseExpirationDate({ custom_properties: {} })).toBeNull();
+ });
+
+ it('returns null when exp_date is missing', () => {
+ expect(
+ parseExpirationDate({ custom_properties: { user_info: {} } })
+ ).toBeNull();
+ });
+
+ it('returns null when exp_date is null', () => {
+ expect(parseExpirationDate(makeProfile(null))).toBeNull();
+ });
+
+ it('returns null when exp_date is an empty string', () => {
+ expect(parseExpirationDate(makeProfile(''))).toBeNull();
+ });
+
+ it('returns epoch start when exp_date is zero', () => {
+ expect(parseExpirationDate(makeProfile('0'))).toEqual(new Date(0));
+ });
+ });
+
+ // ── isAccountExpired ───────────────────────────────────────────────────────
+
+ describe('isAccountExpired', () => {
+ const makeProfile = (expDate) => ({
+ custom_properties: { user_info: { exp_date: expDate } },
+ });
+
+ it('returns false when expiration date is in the future', () => {
+ const expUnix = unixSecondsFromNow(MS.day);
+ expect(isAccountExpired(makeProfile(expUnix))).toBe(false);
+ });
+
+ it('returns true when expiration date is in the past', () => {
+ const expUnix = unixSecondsFromNow(-MS.day);
+ expect(isAccountExpired(makeProfile(expUnix))).toBe(true);
+ });
+
+ it('returns true when expiration date is exactly now (expired at boundary)', () => {
+ // The exp date equals NOW exactly; NOW < NOW is false, so not expired
+ // Using -1ms to ensure it's strictly in the past
+ const pastUnix = unixSecondsFromNow(-1000);
+ expect(isAccountExpired(makeProfile(pastUnix))).toBe(true);
+ });
+
+ it('returns false when no expiration date is present', () => {
+ expect(isAccountExpired({})).toBe(false);
+ });
+
+ it('returns false when profile is null', () => {
+ expect(isAccountExpired(null)).toBe(false);
+ });
+
+ it('returns false when profile is undefined', () => {
+ expect(isAccountExpired(undefined)).toBe(false);
+ });
+
+ it('returns false when exp_date is empty string', () => {
+ expect(isAccountExpired(makeProfile(''))).toBe(false);
+ });
+ });
+
+ // ── getExpirationInfo ──────────────────────────────────────────────────────
+
+ describe('getExpirationInfo', () => {
+ const makeProfile = (expDate) => ({
+ custom_properties: { user_info: { exp_date: expDate } },
+ });
+
+ it('returns null when no expiration date is present', () => {
+ expect(getExpirationInfo({})).toBeNull();
+ });
+
+ it('returns null when profile is null', () => {
+ expect(getExpirationInfo(null)).toBeNull();
+ });
+
+ it('returns { text: "Expired", color: "red" } when date is in the past', () => {
+ const expUnix = unixSecondsFromNow(-MS.day);
+ expect(getExpirationInfo(makeProfile(expUnix))).toEqual({
+ text: 'Expired',
+ color: 'red',
+ });
+ });
+
+ it('returns green color when more than 30 days remain', () => {
+ const expUnix = unixSecondsFromNow(31 * MS.day);
+ const result = getExpirationInfo(makeProfile(expUnix));
+ expect(result.color).toBe('green');
+ expect(result.text).toMatch(/days/);
+ });
+
+ it('returns correct day count for 60 days remaining', () => {
+ const expUnix = unixSecondsFromNow(60 * MS.day);
+ const result = getExpirationInfo(makeProfile(expUnix));
+ expect(result.text).toBe('60 days');
+ expect(result.color).toBe('green');
+ });
+
+ it('returns yellow color when 8–30 days remain', () => {
+ const expUnix = unixSecondsFromNow(15 * MS.day);
+ const result = getExpirationInfo(makeProfile(expUnix));
+ expect(result.color).toBe('yellow');
+ expect(result.text).toBe('15 days');
+ });
+
+ it('returns yellow for exactly 8 days remaining', () => {
+ const expUnix = unixSecondsFromNow(8 * MS.day);
+ const result = getExpirationInfo(makeProfile(expUnix));
+ expect(result.color).toBe('yellow');
+ });
+
+ it('returns orange color when 1–7 days remain', () => {
+ const expUnix = unixSecondsFromNow(3 * MS.day);
+ const result = getExpirationInfo(makeProfile(expUnix));
+ expect(result.color).toBe('orange');
+ expect(result.text).toBe('3 days');
+ });
+
+ it('returns orange for exactly 1 day remaining', () => {
+ const expUnix = unixSecondsFromNow(1 * MS.day);
+ const result = getExpirationInfo(makeProfile(expUnix));
+ expect(result.color).toBe('orange');
+ expect(result.text).toBe('1 days');
+ });
+
+ it('returns red color with hours when less than 1 day remains', () => {
+ const expUnix = unixSecondsFromNow(5 * MS.hour);
+ const result = getExpirationInfo(makeProfile(expUnix));
+ expect(result.color).toBe('red');
+ expect(result.text).toBe('5h');
+ });
+
+ it('returns "0h" when expiration is imminent (minutes away)', () => {
+ const expUnix = unixSecondsFromNow(30 * 60 * 1000); // 30 minutes
+ const result = getExpirationInfo(makeProfile(expUnix));
+ expect(result.color).toBe('red');
+ expect(result.text).toBe('0h');
+ });
+
+ it('returns correct hours text for 23 hours remaining', () => {
+ const expUnix = unixSecondsFromNow(23 * MS.hour);
+ const result = getExpirationInfo(makeProfile(expUnix));
+ expect(result.text).toBe('23h');
+ expect(result.color).toBe('red');
+ });
+ });
+
+ // ── profileSortComparator ──────────────────────────────────────────────────
+
+ describe('profileSortComparator', () => {
+ const makeProfile = (name, isDefault = false) => ({
+ name,
+ is_default: isDefault,
+ });
+
+ it('sorts default profile before non-default', () => {
+ const defaultProfile = makeProfile('Zebra', true);
+ const normal = makeProfile('Alpha');
+ expect(profileSortComparator(defaultProfile, normal)).toBe(-1);
+ });
+
+ it('sorts non-default after default profile', () => {
+ const normal = makeProfile('Alpha');
+ const defaultProfile = makeProfile('Zebra', true);
+ expect(profileSortComparator(normal, defaultProfile)).toBe(1);
+ });
+
+ it('sorts two non-default profiles alphabetically ascending', () => {
+ const a = makeProfile('Alpha');
+ const b = makeProfile('Beta');
+ expect(profileSortComparator(a, b)).toBeLessThan(0);
+ });
+
+ it('sorts two non-default profiles alphabetically descending', () => {
+ const a = makeProfile('Zebra');
+ const b = makeProfile('Alpha');
+ expect(profileSortComparator(a, b)).toBeGreaterThan(0);
+ });
+
+ it('returns 0 for two non-default profiles with the same name', () => {
+ const a = makeProfile('Same');
+ const b = makeProfile('Same');
+ expect(profileSortComparator(a, b)).toBe(0);
+ });
+
+ it('places default profile first when sorting an array', () => {
+ const profiles = [
+ makeProfile('Charlie'),
+ makeProfile('Alpha'),
+ makeProfile('Default Profile', true),
+ makeProfile('Beta'),
+ ];
+ const sorted = [...profiles].sort(profileSortComparator);
+ expect(sorted[0].is_default).toBe(true);
+ });
+
+ it('sorts remaining profiles alphabetically after default', () => {
+ const profiles = [
+ makeProfile('Charlie'),
+ makeProfile('Alpha'),
+ makeProfile('Default Profile', true),
+ makeProfile('Beta'),
+ ];
+ const sorted = [...profiles].sort(profileSortComparator);
+ expect(sorted.slice(1).map((p) => p.name)).toEqual([
+ 'Alpha',
+ 'Beta',
+ 'Charlie',
+ ]);
+ });
+
+ it('is stable when no profile is default (pure alphabetical)', () => {
+ const profiles = [
+ makeProfile('Zeta'),
+ makeProfile('Alpha'),
+ makeProfile('Mu'),
+ ];
+ const sorted = [...profiles].sort(profileSortComparator);
+ expect(sorted.map((p) => p.name)).toEqual(['Alpha', 'Mu', 'Zeta']);
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/M3uUtils.test.js b/frontend/src/utils/forms/__tests__/M3uUtils.test.js
new file mode 100644
index 00000000..398059ce
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/M3uUtils.test.js
@@ -0,0 +1,237 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ updatePlaylist,
+ addPlaylist,
+ getPlaylist,
+ refreshPlaylist,
+ prepareSubmitValues,
+} from '../M3uUtils.js';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ updatePlaylist: vi.fn(),
+ addPlaylist: vi.fn(),
+ getPlaylist: vi.fn(),
+ refreshPlaylist: vi.fn(),
+ },
+}));
+
+import API from '../../../api.js';
+
+describe('M3uUtils', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ // ── updatePlaylist ─────────────────────────────────────────────────────────────
+
+ describe('updatePlaylist', () => {
+ it('calls API.updatePlaylist with merged id, values, and file', () => {
+ const playlist = { id: 1 };
+ const values = { name: 'Test' };
+ const file = new File([''], 'test.m3u');
+ updatePlaylist(playlist, values, file);
+ expect(API.updatePlaylist).toHaveBeenCalledWith({
+ id: 1,
+ name: 'Test',
+ file,
+ });
+ });
+
+ it('returns the result of API.updatePlaylist', async () => {
+ API.updatePlaylist.mockResolvedValue({ id: 1, name: 'Updated' });
+ const result = await updatePlaylist({ id: 1 }, { name: 'Updated' }, null);
+ expect(result).toEqual({ id: 1, name: 'Updated' });
+ });
+
+ it('passes null file when no file provided', () => {
+ updatePlaylist({ id: 2 }, { name: 'No File' }, null);
+ expect(API.updatePlaylist).toHaveBeenCalledWith({
+ id: 2,
+ name: 'No File',
+ file: null,
+ });
+ });
+ });
+
+ // ── addPlaylist ────────────────────────────────────────────────────────────────
+
+ describe('addPlaylist', () => {
+ it('calls API.addPlaylist with merged values and file', async () => {
+ const file = new File([''], 'playlist.m3u');
+ await addPlaylist({ name: 'New' }, file);
+ expect(API.addPlaylist).toHaveBeenCalledWith({ name: 'New', file });
+ });
+
+ it('returns the result of API.addPlaylist', async () => {
+ API.addPlaylist.mockResolvedValue({ id: 5, name: 'New' });
+ const result = await addPlaylist({ name: 'New' }, null);
+ expect(result).toEqual({ id: 5, name: 'New' });
+ });
+
+ it('passes null file when no file provided', async () => {
+ await addPlaylist({ name: 'No File' }, null);
+ expect(API.addPlaylist).toHaveBeenCalledWith({
+ name: 'No File',
+ file: null,
+ });
+ });
+ });
+
+ // ── getPlaylist ────────────────────────────────────────────────────────────────
+
+ describe('getPlaylist', () => {
+ it('calls API.getPlaylist with the playlist id', async () => {
+ API.getPlaylist.mockResolvedValue({ id: 3 });
+ await getPlaylist({ id: 3 });
+ expect(API.getPlaylist).toHaveBeenCalledWith(3);
+ });
+
+ it('returns the result of API.getPlaylist', async () => {
+ API.getPlaylist.mockResolvedValue({ id: 3, name: 'Fetched' });
+ const result = await getPlaylist({ id: 3 });
+ expect(result).toEqual({ id: 3, name: 'Fetched' });
+ });
+ });
+
+ // ── refreshPlaylist ────────────────────────────────────────────────────────────
+
+ describe('refreshPlaylist', () => {
+ it('calls API.refreshPlaylist with the playlist id', async () => {
+ API.refreshPlaylist.mockResolvedValue(undefined);
+ await refreshPlaylist({ id: 7 });
+ expect(API.refreshPlaylist).toHaveBeenCalledWith(7);
+ });
+
+ it('returns the result of API.refreshPlaylist', async () => {
+ API.refreshPlaylist.mockResolvedValue(undefined);
+ const result = await refreshPlaylist({ id: 7 });
+ expect(result).toEqual(undefined);
+ });
+ });
+
+ // ── prepareSubmitValues ────────────────────────────────────────────────────────
+
+ describe('prepareSubmitValues', () => {
+ describe('exp_date handling', () => {
+ it('deletes exp_date when account_type is XC', () => {
+ const values = { account_type: 'XC', exp_date: '2025-01-01' };
+ const result = prepareSubmitValues(values, null);
+ expect(result.exp_date).toBeUndefined();
+ });
+
+ it('converts Date to ISO string when expDate is a Date instance', () => {
+ const date = new Date('2025-06-15T00:00:00.000Z');
+ const values = { account_type: 'M3U', exp_date: null };
+ const result = prepareSubmitValues(values, date);
+ expect(result.exp_date).toBe(date.toISOString());
+ });
+
+ it('sets exp_date to null when expDate is not a Date and not XC', () => {
+ const values = { account_type: 'M3U', exp_date: '2025-01-01' };
+ const result = prepareSubmitValues(values, null);
+ expect(result.exp_date).toBeNull();
+ });
+
+ it('sets exp_date to null when expDate is a string', () => {
+ const values = { account_type: 'M3U' };
+ const result = prepareSubmitValues(values, '2025-01-01');
+ expect(result.exp_date).toBeNull();
+ });
+ });
+
+ describe('cron_expression / refresh_interval handling', () => {
+ it('sets refresh_interval to 0 when cron_expression is non-empty', () => {
+ const values = {
+ account_type: 'M3U',
+ cron_expression: '0 * * * *',
+ refresh_interval: 30,
+ };
+ const result = prepareSubmitValues(values, null);
+ expect(result.refresh_interval).toBe(0);
+ expect(result.cron_expression).toBe('0 * * * *');
+ });
+
+ it('sets cron_expression to empty string when it is blank', () => {
+ const values = {
+ account_type: 'M3U',
+ cron_expression: ' ',
+ refresh_interval: 30,
+ };
+ const result = prepareSubmitValues(values, null);
+ expect(result.cron_expression).toBe('');
+ expect(result.refresh_interval).toBe(30);
+ });
+
+ it('sets cron_expression to empty string when it is empty', () => {
+ const values = {
+ account_type: 'M3U',
+ cron_expression: '',
+ refresh_interval: 60,
+ };
+ const result = prepareSubmitValues(values, null);
+ expect(result.cron_expression).toBe('');
+ expect(result.refresh_interval).toBe(60);
+ });
+
+ it('handles undefined cron_expression', () => {
+ const values = { account_type: 'M3U', refresh_interval: 30 };
+ const result = prepareSubmitValues(values, null);
+ expect(result.cron_expression).toBe('');
+ expect(result.refresh_interval).toBe(30);
+ });
+ });
+
+ describe('password handling for XC', () => {
+ it('deletes password when account_type is XC and password is empty string', () => {
+ const values = { account_type: 'XC', password: '' };
+ const result = prepareSubmitValues(values, null);
+ expect(result.password).toBeUndefined();
+ });
+
+ it('keeps password when account_type is XC and password is non-empty', () => {
+ const values = { account_type: 'XC', password: 'secret' };
+ const result = prepareSubmitValues(values, null);
+ expect(result.password).toBe('secret');
+ });
+
+ it('keeps password when account_type is not XC and password is empty', () => {
+ const values = { account_type: 'M3U', password: '' };
+ const result = prepareSubmitValues(values, null);
+ expect(result.password).toBe('');
+ });
+ });
+
+ describe('user_agent handling', () => {
+ it('sets user_agent to null when value is "0"', () => {
+ const values = { account_type: 'M3U', user_agent: '0' };
+ const result = prepareSubmitValues(values, null);
+ expect(result.user_agent).toBeNull();
+ });
+
+ it('keeps user_agent when it is not "0"', () => {
+ const values = { account_type: 'M3U', user_agent: 'Mozilla/5.0' };
+ const result = prepareSubmitValues(values, null);
+ expect(result.user_agent).toBe('Mozilla/5.0');
+ });
+
+ it('keeps user_agent when it is undefined', () => {
+ const values = { account_type: 'M3U' };
+ const result = prepareSubmitValues(values, null);
+ expect(result.user_agent).toBeUndefined();
+ });
+ });
+
+ describe('does not mutate original values', () => {
+ it('returns a new object and does not mutate the input', () => {
+ const values = {
+ account_type: 'M3U',
+ cron_expression: '',
+ refresh_interval: 30,
+ user_agent: '0',
+ };
+ const original = { ...values };
+ prepareSubmitValues(values, null);
+ expect(values).toEqual(original);
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js
index bf9b6e3f..c5e57400 100644
--- a/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js
+++ b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js
@@ -2,7 +2,15 @@ import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js';
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../networkUtils.js';
// Default CIDR ranges for M3U/EPG endpoints (local networks only)
-const M3U_EPG_DEFAULTS = ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '::1/128', 'fc00::/7', 'fe80::/10'];
+const M3U_EPG_DEFAULTS = [
+ '127.0.0.0/8',
+ '10.0.0.0/8',
+ '172.16.0.0/12',
+ '192.168.0.0/16',
+ '::1/128',
+ 'fc00::/7',
+ 'fe80::/10',
+];
const OPEN_DEFAULTS = ['0.0.0.0/0', '::/0'];
const isValidEntry = (entry) =>
@@ -22,7 +30,9 @@ export const getNetworkAccessFormValidation = () =>
Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
acc[key] = (tags) => {
if (!tags || tags.length === 0) return null;
- return tags.some((t) => !isValidEntry(t)) ? 'Invalid IP address or CIDR range' : null;
+ return tags.some((t) => !isValidEntry(t))
+ ? 'Invalid IP address or CIDR range'
+ : null;
};
return acc;
}, {});
diff --git a/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js
index 2aa3ecfc..3b5a0866 100644
--- a/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js
+++ b/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js
@@ -109,9 +109,13 @@ describe('NetworkAccessFormUtils', () => {
NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
- expect(validator(['192.168.1.256.1/24'])).toBe('Invalid IP address or CIDR range');
+ expect(validator(['192.168.1.256.1/24'])).toBe(
+ 'Invalid IP address or CIDR range'
+ );
expect(validator(['invalid'])).toBe('Invalid IP address or CIDR range');
- expect(validator(['192.168.1.0/256'])).toBe('Invalid IP address or CIDR range');
+ expect(validator(['192.168.1.0/256'])).toBe(
+ 'Invalid IP address or CIDR range'
+ );
});
it('should return error when any entry in the list is invalid', () => {
@@ -119,8 +123,12 @@ describe('NetworkAccessFormUtils', () => {
NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
- expect(validator(['192.168.1.0/24', 'invalid'])).toBe('Invalid IP address or CIDR range');
- expect(validator(['invalid', '192.168.1.0/24'])).toBe('Invalid IP address or CIDR range');
+ expect(validator(['192.168.1.0/24', 'invalid'])).toBe(
+ 'Invalid IP address or CIDR range'
+ );
+ expect(validator(['invalid', '192.168.1.0/24'])).toBe(
+ 'Invalid IP address or CIDR range'
+ );
expect(validator(['192.168.1.0/24', '10.0.0.0/8', 'invalid'])).toBe(
'Invalid IP address or CIDR range'
);