diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml
new file mode 100644
index 00000000..828bdc43
--- /dev/null
+++ b/.github/workflows/frontend-tests.yml
@@ -0,0 +1,35 @@
+name: Frontend Tests
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ defaults:
+ run:
+ working-directory: ./frontend
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: './frontend/package-lock.json'
+
+ - name: Install dependencies
+ run: npm ci
+
+# - name: Run linter
+# run: npm run lint
+
+ - name: Run tests
+ run: npm test
diff --git a/.gitignore b/.gitignore
index a9d76412..20968f46 100755
--- a/.gitignore
+++ b/.gitignore
@@ -18,4 +18,5 @@ dump.rdb
debugpy*
uwsgi.sock
package-lock.json
-models
\ No newline at end of file
+models
+.idea
\ No newline at end of file
diff --git a/frontend/src/pages/__tests__/Channels.test.jsx b/frontend/src/pages/__tests__/Channels.test.jsx
new file mode 100644
index 00000000..e029952f
--- /dev/null
+++ b/frontend/src/pages/__tests__/Channels.test.jsx
@@ -0,0 +1,48 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import useAuthStore from '../../store/auth';
+import useLocalStorage from '../../hooks/useLocalStorage';
+import ChannelsPage from '../Channels';
+
+vi.mock('../../store/auth');
+vi.mock('../../hooks/useLocalStorage');
+vi.mock('../../components/tables/ChannelsTable', () => ({
+ default: () =>
ChannelsTable
+}));
+vi.mock('../../components/tables/StreamsTable', () => ({
+ default: () => StreamsTable
+}));
+vi.mock('@mantine/core', () => ({
+ Box: ({ children, ...props }) => {children}
,
+}));
+vi.mock('allotment', () => ({
+ Allotment: ({ children }) => {children}
,
+}));
+
+describe('ChannelsPage', () => {
+ beforeEach(() => {
+ useLocalStorage.mockReturnValue([[50, 50], vi.fn()]);
+ });
+
+ it('renders nothing when user is not authenticated', () => {
+ useAuthStore.mockReturnValue({ id: null, user_level: 0 });
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders only ChannelsTable for standard users', () => {
+ useAuthStore.mockReturnValue({ id: 1, user_level: 1 });
+ render();
+ expect(screen.getByTestId('channels-table')).toBeInTheDocument();
+ expect(screen.queryByTestId('streams-table')).not.toBeInTheDocument();
+ });
+
+ it('renders split view for higher-level users', async () => {
+ useAuthStore.mockReturnValue({ id: 1, user_level: 2 });
+ render();
+ expect(screen.getByTestId('channels-table')).toBeInTheDocument();
+ await waitFor(() =>
+ expect(screen.getByTestId('streams-table')).toBeInTheDocument()
+ );
+ });
+});
diff --git a/frontend/src/pages/__tests__/ContentSources.test.jsx b/frontend/src/pages/__tests__/ContentSources.test.jsx
new file mode 100644
index 00000000..3f2ce1c5
--- /dev/null
+++ b/frontend/src/pages/__tests__/ContentSources.test.jsx
@@ -0,0 +1,33 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import ContentSourcesPage from '../ContentSources';
+import useUserAgentsStore from '../../store/userAgents';
+
+vi.mock('../../store/userAgents');
+vi.mock('../../components/tables/M3UsTable', () => ({
+ default: () => M3UsTable
+}));
+vi.mock('../../components/tables/EPGsTable', () => ({
+ default: () => EPGsTable
+}));
+vi.mock('@mantine/core', () => ({
+ Box: ({ children, ...props }) => {children}
,
+ Stack: ({ children, ...props }) => {children}
,
+}));
+
+describe('ContentSourcesPage', () => {
+ it('renders error on userAgents error', () => {
+ const errorMessage = 'Failed to load userAgents.';
+ useUserAgentsStore.mockReturnValue(errorMessage);
+ render();
+ const element = screen.getByText(/Something went wrong/i);
+ expect(element).toBeInTheDocument();
+ });
+
+ it('no error renders tables', () => {
+ useUserAgentsStore.mockReturnValue(null);
+ render();
+ expect(screen.getByTestId('m3us-table')).toBeInTheDocument();
+ expect(screen.getByTestId('epgs-table')).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/pages/__tests__/DVR.test.jsx b/frontend/src/pages/__tests__/DVR.test.jsx
new file mode 100644
index 00000000..597f1472
--- /dev/null
+++ b/frontend/src/pages/__tests__/DVR.test.jsx
@@ -0,0 +1,541 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import DVRPage from '../DVR';
+import dayjs from 'dayjs';
+import useChannelsStore from '../../store/channels';
+import useSettingsStore from '../../store/settings';
+import useVideoStore from '../../store/useVideoStore';
+import useLocalStorage from '../../hooks/useLocalStorage';
+import {
+ isAfter,
+ isBefore,
+ useTimeHelpers,
+} from '../../utils/dateTimeUtils.js';
+import { categorizeRecordings } from '../../utils/pages/DVRUtils.js';
+import { getPosterUrl, getRecordingUrl, getShowVideoUrl } from '../../utils/cards/RecordingCardUtils.js';
+
+vi.mock('../../store/channels');
+vi.mock('../../store/settings');
+vi.mock('../../store/useVideoStore');
+vi.mock('../../hooks/useLocalStorage');
+
+// Mock Mantine components
+vi.mock('@mantine/core', () => ({
+ Box: ({ children }) => {children}
,
+ Container: ({ children }) => {children}
,
+ Title: ({ children, order }) => {children}
,
+ Text: ({ children }) => {children}
,
+ Button: ({ children, onClick, leftSection, loading, ...props }) => (
+
+ ),
+ Badge: ({ children }) => {children},
+ SimpleGrid: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ Stack: ({ children }) => {children}
,
+ Divider: () =>
,
+ useMantineTheme: () => ({
+ tailwind: {
+ green: { 5: '#22c55e' },
+ red: { 6: '#dc2626' },
+ yellow: { 6: '#ca8a04' },
+ gray: { 6: '#52525b' },
+ },
+ }),
+}));
+
+// Mock components
+vi.mock('../../components/cards/RecordingCard', () => ({
+ default: ({ recording, onOpenDetails, onOpenRecurring }) => (
+
+ {recording.custom_properties?.Title || 'Recording'}
+
+ {recording.custom_properties?.rule && (
+
+ )}
+
+ ),
+}));
+
+vi.mock('../../components/forms/RecordingDetailsModal', () => ({
+ default: ({ opened, onClose, recording, onEdit, onWatchLive, onWatchRecording }) =>
+ opened ? (
+
+
{recording?.custom_properties?.Title}
+
+
+
+
+
+ ) : null,
+}));
+
+vi.mock('../../components/forms/RecurringRuleModal', () => ({
+ default: ({ opened, onClose, ruleId }) =>
+ opened ? (
+
+
Rule ID: {ruleId}
+
+
+ ) : null,
+}));
+
+vi.mock('../../components/forms/Recording', () => ({
+ default: ({ isOpen, onClose, recording }) =>
+ isOpen ? (
+
+
Recording ID: {recording?.id || 'new'}
+
+
+ ) : null,
+}));
+
+vi.mock('../../components/ErrorBoundary', () => ({
+ default: ({ children }) => {children}
,
+}));
+
+vi.mock('../../utils/dateTimeUtils.js', async (importActual) => {
+ const actual = await importActual();
+ return {
+ ...actual,
+ isBefore: vi.fn(),
+ isAfter: vi.fn(),
+ useTimeHelpers: vi.fn(),
+ };
+});
+vi.mock('../../utils/cards/RecordingCardUtils.js', () => ({
+ getPosterUrl: vi.fn(),
+ getRecordingUrl: vi.fn(),
+ getShowVideoUrl: vi.fn(),
+}));
+vi.mock('../../utils/pages/DVRUtils.js', async (importActual) => {
+ const actual = await importActual();
+ return {
+ ...actual,
+ categorizeRecordings: vi.fn(),
+ };
+});
+
+describe('DVRPage', () => {
+ const mockShowVideo = vi.fn();
+ const mockFetchRecordings = vi.fn();
+ const mockFetchChannels = vi.fn();
+ const mockFetchRecurringRules = vi.fn();
+ const mockRemoveRecording = vi.fn();
+
+ const defaultChannelsState = {
+ recordings: [],
+ channels: {},
+ recurringRules: [],
+ fetchRecordings: mockFetchRecordings,
+ fetchChannels: mockFetchChannels,
+ fetchRecurringRules: mockFetchRecurringRules,
+ removeRecording: mockRemoveRecording,
+ };
+
+ const defaultSettingsState = {
+ settings: {
+ 'system-time-zone': { value: 'America/New_York' },
+ },
+ environment: {
+ env_mode: 'production',
+ },
+ };
+
+ const defaultVideoState = {
+ showVideo: mockShowVideo,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ const now = new Date('2024-01-15T12:00:00Z');
+ vi.setSystemTime(now);
+
+ isAfter.mockImplementation((a, b) => new Date(a) > new Date(b));
+ isBefore.mockImplementation((a, b) => new Date(a) < new Date(b));
+ useTimeHelpers.mockReturnValue({
+ toUserTime: (dt) => dayjs(dt).tz('America/New_York').toDate(),
+ userNow: () => dayjs().tz('America/New_York').toDate(),
+ });
+
+ categorizeRecordings.mockImplementation((recordings, toUserTime, now) => {
+ const inProgress = [];
+ const upcoming = [];
+ const completed = [];
+ recordings.forEach((rec) => {
+ const start = toUserTime(rec.start_time);
+ const end = toUserTime(rec.end_time);
+ if (now >= start && now <= end) inProgress.push(rec);
+ else if (now < start) upcoming.push(rec);
+ else completed.push(rec);
+ });
+ return { inProgress, upcoming, completed };
+ });
+
+ getPosterUrl.mockImplementation((recording) =>
+ recording?.id ? `http://poster.url/${recording.id}` : null
+ );
+ getRecordingUrl.mockImplementation((custom_properties) =>
+ custom_properties?.recording_url
+ );
+ getShowVideoUrl.mockImplementation((channel) =>
+ channel?.stream_url
+ );
+
+ useChannelsStore.mockImplementation((selector) => {
+ return selector ? selector(defaultChannelsState) : defaultChannelsState;
+ });
+ useChannelsStore.getState = () => defaultChannelsState;
+
+ useSettingsStore.mockImplementation((selector) => {
+ return selector ? selector(defaultSettingsState) : defaultSettingsState;
+ });
+ useSettingsStore.getState = () => defaultSettingsState;
+
+ useVideoStore.mockImplementation((selector) => {
+ return selector ? selector(defaultVideoState) : defaultVideoState;
+ });
+ useVideoStore.getState = () => defaultVideoState;
+
+ useLocalStorage.mockReturnValue(['America/New_York', vi.fn()]);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.clearAllTimers(); // Clear pending timers
+ vi.useRealTimers();
+ });
+
+ describe('Initial Render', () => {
+ it('renders new recording buttons', () => {
+ render();
+
+ expect(screen.getByText('New Recording')).toBeInTheDocument();
+ });
+
+ it('renders empty state when no recordings', () => {
+ render();
+
+ expect(screen.getByText('No upcoming recordings.')).toBeInTheDocument();
+ });
+ });
+
+ describe('Recording Display', () => {
+ it('displays recordings grouped by date', () => {
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recordings = [
+ {
+ id: 1,
+ channel: 1,
+ start_time: now.toISOString(),
+ end_time: now.add(1, 'hour').toISOString(),
+ custom_properties: { Title: 'Show 1' },
+ },
+ {
+ id: 2,
+ channel: 1,
+ start_time: now.add(1, 'day').toISOString(),
+ end_time: now.add(1, 'day').add(1, 'hour').toISOString(),
+ custom_properties: { Title: 'Show 2' },
+ },
+ ];
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = { ...defaultChannelsState, recordings };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ expect(screen.getByTestId('recording-card-1')).toBeInTheDocument();
+ expect(screen.getByTestId('recording-card-2')).toBeInTheDocument();
+ });
+ });
+
+ describe('New Recording', () => {
+ it('opens recording form when new recording button is clicked', async () => {
+ render();
+
+ const newButton = screen.getByText('New Recording');
+ fireEvent.click(newButton);
+
+ expect(screen.getByTestId('recording-form')).toBeInTheDocument();
+ });
+
+ it('closes recording form when close is clicked', async () => {
+ render();
+
+ const newButton = screen.getByText('New Recording');
+ fireEvent.click(newButton);
+
+ expect(screen.getByTestId('recording-form')).toBeInTheDocument();
+
+ const closeButton = screen.getByText('Close Form');
+ fireEvent.click(closeButton);
+
+ expect(screen.queryByTestId('recording-form')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Recording Details Modal', () => {
+ const setupRecording = () => {
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.toISOString(),
+ end_time: now.add(1, 'hour').toISOString(),
+ custom_properties: { Title: 'Test Show' },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: { 1: { id: 1, name: 'Channel 1', stream_url: 'http://stream.url' } },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ return recording;
+ };
+
+ it('opens details modal when recording card is clicked', async () => {
+ vi.useRealTimers();
+
+ setupRecording();
+ render();
+
+ const detailsButton = screen.getByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ await screen.findByTestId('details-modal');
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Show');
+ });
+
+ it('closes details modal when close is clicked', async () => {
+ vi.useRealTimers();
+
+ setupRecording();
+ render();
+
+ const detailsButton = screen.getByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ await screen.findByTestId('details-modal');
+
+ const closeButton = screen.getByText('Close Modal');
+ fireEvent.click(closeButton);
+
+ expect(screen.queryByTestId('details-modal')).not.toBeInTheDocument();
+ });
+
+ it('opens edit form from details modal', async () => {
+ vi.useRealTimers();
+
+ setupRecording();
+ render();
+
+ const detailsButton = screen.getByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ await screen.findByTestId('details-modal');
+
+ const editButton = screen.getByText('Edit');
+ fireEvent.click(editButton);
+
+ expect(screen.queryByTestId('details-modal')).not.toBeInTheDocument();
+ expect(screen.getByTestId('recording-form')).toBeInTheDocument();
+ });
+ });
+
+ describe('Recurring Rule Modal', () => {
+ it('opens recurring rule modal when recording has rule', async () => {
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.toISOString(),
+ end_time: now.add(1, 'hour').toISOString(),
+ custom_properties: {
+ Title: 'Recurring Show',
+ rule: { id: 100 }
+ },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: { 1: { id: 1, name: 'Channel 1' } },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ const recurringButton = screen.getByText('Open Recurring');
+ fireEvent.click(recurringButton);
+
+ expect(screen.getByTestId('recurring-modal')).toBeInTheDocument();
+ expect(screen.getByText('Rule ID: 100')).toBeInTheDocument();
+ });
+
+ it('closes recurring modal when close is clicked', async () => {
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.toISOString(),
+ end_time: now.add(1, 'hour').toISOString(),
+ custom_properties: {
+ Title: 'Recurring Show',
+ rule: { id: 100 },
+ },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: { 1: { id: 1, name: 'Channel 1' } },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ const recurringButton = screen.getByText('Open Recurring');
+ fireEvent.click(recurringButton);
+
+ expect(screen.getByTestId('recurring-modal')).toBeInTheDocument();
+
+ const closeButton = screen.getByText('Close Recurring');
+ fireEvent.click(closeButton);
+
+ expect(screen.queryByTestId('recurring-modal')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Watch Functionality', () => {
+ it('calls showVideo for watch live on in-progress recording', async () => {
+ vi.useRealTimers();
+
+ const now = dayjs();
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.subtract(30, 'minutes').toISOString(),
+ end_time: now.add(30, 'minutes').toISOString(),
+ custom_properties: { Title: 'Live Show' },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: {
+ 1: { id: 1, name: 'Channel 1', stream_url: 'http://stream.url' },
+ },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ const detailsButton = screen.getByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ await screen.findByTestId('details-modal');
+
+ const watchLiveButton = screen.getByText('Watch Live');
+ fireEvent.click(watchLiveButton);
+
+ expect(mockShowVideo).toHaveBeenCalledWith(
+ expect.stringContaining('stream.url'),
+ 'live'
+ );
+ });
+
+ it('calls showVideo for watch recording on completed recording', async () => {
+ vi.useRealTimers();
+
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.subtract(2, 'hours').toISOString(),
+ end_time: now.subtract(1, 'hour').toISOString(),
+ custom_properties: {
+ Title: 'Recorded Show',
+ recording_url: 'http://recording.url/video.mp4',
+ },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: { 1: { id: 1, name: 'Channel 1' } },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ const detailsButton = screen.getByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ await screen.findByTestId('details-modal');
+
+ const watchButton = screen.getByText('Watch Recording');
+ fireEvent.click(watchButton);
+
+ expect(mockShowVideo).toHaveBeenCalledWith(
+ expect.stringContaining('http://recording.url/video.mp4'),
+ 'vod',
+ expect.objectContaining({
+ name: 'Recording',
+ })
+ );
+ });
+
+ it('does not call showVideo when recording URL is missing', async () => {
+ vi.useRealTimers();
+
+ const now = dayjs('2024-01-15T12:00:00Z');
+ const recording = {
+ id: 1,
+ channel: 1,
+ start_time: now.subtract(2, 'hours').toISOString(),
+ end_time: now.subtract(1, 'hour').toISOString(),
+ custom_properties: { Title: 'No URL Show' },
+ };
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...defaultChannelsState,
+ recordings: [recording],
+ channels: { 1: { id: 1, name: 'Channel 1' } },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ const detailsButton = await screen.findByText('Open Details');
+ fireEvent.click(detailsButton);
+
+ const modal = await screen.findByTestId('details-modal');
+ expect(modal).toBeInTheDocument();
+
+ const watchButton = screen.getByText('Watch Recording');
+ fireEvent.click(watchButton);
+
+ expect(mockShowVideo).not.toHaveBeenCalled();
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/pages/__tests__/Guide.test.jsx b/frontend/src/pages/__tests__/Guide.test.jsx
new file mode 100644
index 00000000..feb5325c
--- /dev/null
+++ b/frontend/src/pages/__tests__/Guide.test.jsx
@@ -0,0 +1,619 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import {
+ render,
+ screen,
+ waitFor,
+ fireEvent,
+} from '@testing-library/react';
+import dayjs from 'dayjs';
+import Guide from '../Guide';
+import useChannelsStore from '../../store/channels';
+import useLogosStore from '../../store/logos';
+import useEPGsStore from '../../store/epgs';
+import useSettingsStore from '../../store/settings';
+import useVideoStore from '../../store/useVideoStore';
+import useLocalStorage from '../../hooks/useLocalStorage';
+import { showNotification } from '../../utils/notificationUtils.js';
+import * as guideUtils from '../guideUtils';
+import * as recordingCardUtils from '../../utils/cards/RecordingCardUtils.js';
+import * as dateTimeUtils from '../../utils/dateTimeUtils.js';
+import userEvent from '@testing-library/user-event';
+
+// Mock dependencies
+vi.mock('../../store/channels');
+vi.mock('../../store/logos');
+vi.mock('../../store/epgs');
+vi.mock('../../store/settings');
+vi.mock('../../store/useVideoStore');
+vi.mock('../../hooks/useLocalStorage');
+
+vi.mock('@mantine/hooks', () => ({
+ useElementSize: () => ({
+ ref: vi.fn(),
+ width: 1200,
+ height: 800,
+ }),
+}));
+vi.mock('@mantine/core', async () => {
+ const actual = await vi.importActual('@mantine/core');
+ return {
+ ...actual,
+ Box: ({ children, style, onClick, className, ref }) => (
+
+ {children}
+
+ ),
+ Flex: ({ children, direction, justify, align, gap, mb, style }) => (
+
+ {children}
+
+ ),
+ Group: ({ children, gap, justify }) => (
+
+ {children}
+
+ ),
+ Title: ({ children, order, size }) => (
+
+ {children}
+
+ ),
+ Text: ({ children, size, c, fw, lineClamp, style, onClick }) => (
+
+ {children}
+
+ ),
+ Paper: ({ children, style, onClick }) => (
+
+ {children}
+
+ ),
+ Button: ({ children, onClick, leftSection, variant, size, color, disabled }) => (
+
+ ),
+ TextInput: ({ value, onChange, placeholder, icon, rightSection }) => (
+
+ {icon}
+
+ {rightSection}
+
+ ),
+ Select: ({ value, onChange, data, placeholder, clearable }) => (
+
+ ),
+ ActionIcon: ({ children, onClick, variant, size, color }) => (
+
+ ),
+ Tooltip: ({ children, label }) => {children}
,
+ LoadingOverlay: ({ visible }) => (visible ? Loading...
: null),
+ };
+});
+vi.mock('react-window', () => ({
+ VariableSizeList: ({ children, itemData, itemCount }) => (
+
+ {Array.from({ length: Math.min(itemCount, 5) }, (_, i) =>
+
+ {children({
+ index: i,
+ style: {},
+ data: itemData.filteredChannels[i]
+ })}
+
+ )}
+
+ ),
+}));
+
+vi.mock('../../components/GuideRow', () => ({
+ default: ({ data }) => GuideRow for {data?.name}
,
+}));
+vi.mock('../../components/HourTimeline', () => ({
+ default: ({ hourTimeline }) => (
+
+ {hourTimeline.map((hour, i) => (
+
{hour.label}
+ ))}
+
+ ),
+}));
+vi.mock('../../components/forms/ProgramRecordingModal', () => ({
+ __esModule: true,
+ default: ({ opened, onClose, program, onRecordOne }) =>
+ opened ? (
+
+
{program?.title}
+
+
+
+ ) : null,
+}));
+vi.mock('../../components/forms/SeriesRecordingModal', () => ({
+ __esModule: true,
+ default: ({ opened, onClose, rules }) =>
+ opened ? (
+
+
Series Rules: {rules.length}
+
+
+ ) : null,
+}));
+
+vi.mock('../guideUtils', async () => {
+ const actual = await vi.importActual('../guideUtils');
+ return {
+ ...actual,
+ fetchPrograms: vi.fn(),
+ createRecording: vi.fn(),
+ createSeriesRule: vi.fn(),
+ evaluateSeriesRule: vi.fn(),
+ fetchRules: vi.fn(),
+ filterGuideChannels: vi.fn(),
+ getGroupOptions: vi.fn(),
+ getProfileOptions: vi.fn(),
+ };
+});
+vi.mock('../../utils/cards/RecordingCardUtils.js', async () => {
+ const actual = await vi.importActual('../../utils/cards/RecordingCardUtils.js');
+ return {
+ ...actual,
+ getShowVideoUrl: vi.fn(),
+ };
+});
+vi.mock('../../utils/dateTimeUtils.js', async () => {
+ const actual = await vi.importActual('../../utils/dateTimeUtils.js');
+ return {
+ ...actual,
+ getNow: vi.fn(),
+ add: vi.fn(),
+ format: vi.fn(),
+ initializeTime: vi.fn(),
+ startOfDay: vi.fn(),
+ convertToMs: vi.fn(),
+ useDateTimeFormat: vi.fn(),
+ };
+});
+vi.mock('../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+describe('Guide', () => {
+ let mockChannelsState;
+ let mockShowVideo;
+ let mockFetchRecordings;
+ const now = dayjs('2024-01-15T12:00:00Z');
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
+
+ mockChannelsState = {
+ channels: {
+ 'channel-1': {
+ id: 'channel-1',
+ uuid: 'uuid-1',
+ name: 'Test Channel 1',
+ channel_number: 1,
+ logo_id: 'logo-1',
+ stream_url: 'http://stream1.test',
+ },
+ 'channel-2': {
+ id: 'channel-2',
+ uuid: 'uuid-2',
+ name: 'Test Channel 2',
+ channel_number: 2,
+ logo_id: 'logo-2',
+ stream_url: 'http://stream2.test',
+ },
+ },
+ recordings: [],
+ channelGroups: {
+ 'group-1': { id: 'group-1', name: 'News', channels: ['channel-1'] },
+ },
+ profiles: {
+ 'profile-1': { id: 'profile-1', name: 'HD Profile' },
+ },
+ };
+
+ mockShowVideo = vi.fn();
+ mockFetchRecordings = vi.fn().mockResolvedValue([]);
+
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ ...mockChannelsState,
+ fetchRecordings: mockFetchRecordings,
+ };
+ return selector ? selector(state) : state;
+ });
+
+ useLogosStore.mockReturnValue({
+ 'logo-1': { url: 'http://logo1.png' },
+ 'logo-2': { url: 'http://logo2.png' },
+ });
+
+ useEPGsStore.mockImplementation((selector) =>
+ selector ? selector({ tvgsById: {}, epgs: {} }) : { tvgsById: {}, epgs: {} }
+ );
+
+ useSettingsStore.mockReturnValue('production');
+ useVideoStore.mockReturnValue(mockShowVideo);
+ useLocalStorage.mockReturnValue(['12h', vi.fn()]);
+
+ dateTimeUtils.getNow.mockReturnValue(now);
+ dateTimeUtils.format.mockImplementation((date, format) => {
+ if (format?.includes('dddd')) return 'Monday, 01/15/2024 • 12:00 PM';
+ return '12:00 PM';
+ });
+ dateTimeUtils.initializeTime.mockImplementation(date => date || now);
+ dateTimeUtils.startOfDay.mockReturnValue(now.startOf('day'));
+ dateTimeUtils.add.mockImplementation((date, amount, unit) =>
+ dayjs(date).add(amount, unit)
+ );
+ dateTimeUtils.convertToMs.mockImplementation(date => dayjs(date).valueOf());
+ dateTimeUtils.useDateTimeFormat.mockReturnValue(['12h', 'MM/DD/YYYY']);
+
+ guideUtils.fetchPrograms.mockResolvedValue([
+ {
+ id: 'prog-1',
+ tvg_id: 'tvg-1',
+ title: 'Test Program 1',
+ description: 'Description 1',
+ start_time: now.toISOString(),
+ end_time: now.add(1, 'hour').toISOString(),
+ programStart: now,
+ programEnd: now.add(1, 'hour'),
+ startMs: now.valueOf(),
+ endMs: now.add(1, 'hour').valueOf(),
+ isLive: true,
+ isPast: false,
+ },
+ ]);
+
+ guideUtils.fetchRules.mockResolvedValue([]);
+ guideUtils.filterGuideChannels.mockImplementation(
+ (channels) => Object.values(channels)
+ );
+ guideUtils.createRecording.mockResolvedValue(undefined);
+ guideUtils.createSeriesRule.mockResolvedValue(undefined);
+ guideUtils.evaluateSeriesRule.mockResolvedValue(undefined);
+ guideUtils.getGroupOptions.mockReturnValue([
+ { value: 'all', label: 'All Groups' },
+ { value: 'group-1', label: 'News' },
+ ]);
+ guideUtils.getProfileOptions.mockReturnValue([
+ { value: 'all', label: 'All Profiles' },
+ { value: 'profile-1', label: 'HD Profile' },
+ ]);
+
+ recordingCardUtils.getShowVideoUrl.mockReturnValue('http://video.test');
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ });
+
+ describe('Rendering', () => {
+ it('renders the TV Guide title', async () => {
+ render();
+
+ expect(screen.getByText('TV Guide')).toBeInTheDocument();
+ });
+
+ it('displays current time in header', async () => {
+ render();
+
+ expect(screen.getByText(/Monday, 01\/15\/2024/)).toBeInTheDocument();
+ });
+
+ it('renders channel rows when channels are available', async () => {
+ render();
+
+ expect(screen.getAllByTestId('guide-row')).toHaveLength(2);
+ });
+
+ it('shows no channels message when filters exclude all channels', async () => {
+ guideUtils.filterGuideChannels.mockReturnValue([]);
+
+ render();
+
+ // await waitFor(() => {
+ expect(screen.getByText('No channels match your filters')).toBeInTheDocument();
+ // });
+ });
+
+ it('displays channel count', async () => {
+ render();
+
+ // await waitFor(() => {
+ expect(screen.getByText(/2 channels/)).toBeInTheDocument();
+ // });
+ });
+ });
+
+ describe('Search Functionality', () => {
+ it('updates search query when user types', async () => {
+ vi.useRealTimers();
+
+ render();
+
+ const searchInput = screen.getByPlaceholderText('Search channels...');
+ fireEvent.change(searchInput, { target: { value: 'Test' } });
+
+ expect(searchInput).toHaveValue('Test');
+ });
+
+ it('clears search query when clear button is clicked', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render();
+
+ const searchInput = screen.getByPlaceholderText('Search channels...');
+
+ await user.type(searchInput, 'Test');
+ expect(searchInput).toHaveValue('Test');
+
+ await user.click(screen.getByText('Clear Filters'));
+ expect(searchInput).toHaveValue('');
+ });
+
+ it('calls filterGuideChannels with search query', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render();
+
+ const searchInput = await screen.findByPlaceholderText('Search channels...');
+ await user.type(searchInput, 'News');
+
+ await waitFor(() => {
+ expect(guideUtils.filterGuideChannels).toHaveBeenCalledWith(
+ expect.anything(),
+ 'News',
+ 'all',
+ 'all',
+ expect.anything()
+ );
+ });
+ });
+ });
+
+ describe('Filter Functionality', () => {
+ it('filters by channel group', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render();
+
+ const groupSelect = await screen.findByLabelText('Filter by group');
+ await user.selectOptions(groupSelect, 'group-1');
+
+ await waitFor(() => {
+ expect(guideUtils.filterGuideChannels).toHaveBeenCalledWith(
+ expect.anything(),
+ '',
+ 'group-1',
+ 'all',
+ expect.anything()
+ );
+ });
+ });
+
+ it('filters by profile', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render();
+
+ const profileSelect = await screen.findByLabelText('Filter by profile');
+ await user.selectOptions(profileSelect, 'profile-1');
+
+ await waitFor(() => {
+ expect(guideUtils.filterGuideChannels).toHaveBeenCalledWith(
+ expect.anything(),
+ '',
+ 'all',
+ 'profile-1',
+ expect.anything()
+ );
+ });
+ });
+
+ it('clears all filters when Clear Filters is clicked', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render();
+
+ // Set some filters
+ const searchInput = await screen.findByPlaceholderText('Search channels...');
+ await user.type(searchInput, 'Test');
+
+ // Clear them
+ const clearButton = await screen.findByText('Clear Filters');
+ await user.click(clearButton);
+
+ expect(searchInput).toHaveValue('');
+ });
+ });
+
+ describe('Recording Functionality', () => {
+ it('opens Series Rules modal when button is clicked', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup();
+ render();
+
+ const rulesButton = await screen.findByText('Series Rules');
+ await user.click(rulesButton);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('series-recording-modal')).toBeInTheDocument();
+ });
+
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
+ });
+
+ it('fetches rules when opening Series Rules modal', async () => {
+ vi.useRealTimers();
+
+ const mockRules = [{ id: 1, title: 'Test Rule' }];
+ guideUtils.fetchRules.mockResolvedValue(mockRules);
+
+ const user = userEvent.setup();
+ render();
+
+ const rulesButton = await screen.findByText('Series Rules');
+ await user.click(rulesButton);
+
+ await waitFor(() => {
+ expect(guideUtils.fetchRules).toHaveBeenCalled();
+ });
+
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
+ });
+ });
+
+ describe('Navigation', () => {
+ it('scrolls to current time when Jump to current time is clicked', async () => {
+ vi.useRealTimers();
+
+ const user = userEvent.setup({ delay: null });
+ render();
+
+ const jumpButton = await screen.findByTitle('Jump to current time');
+ await user.click(jumpButton);
+
+ // Verify button was clicked (scroll behavior is tested in integration tests)
+ expect(jumpButton).toBeInTheDocument();
+ });
+ });
+
+ describe('Time Updates', () => {
+ it('updates current time every second', async () => {
+ render();
+
+ expect(screen.getByText(/Monday, 01\/15\/2024/)).toBeInTheDocument();
+
+ // Advance time by 1 second
+ vi.advanceTimersByTime(1000);
+
+ expect(dateTimeUtils.getNow).toHaveBeenCalled();
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('shows notification when no channels are available', async () => {
+ useChannelsStore.mockImplementation((selector) => {
+ const state = { channels: {}, recordings: [], channelGroups: {}, profiles: {} };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ expect(showNotification).toHaveBeenCalledWith({
+ title: 'No channels available',
+ color: 'red.5',
+ });
+ });
+ });
+
+ describe('Watch Functionality', () => {
+ it('calls showVideo when watch button is clicked on live program', async () => {
+ vi.useRealTimers();
+
+ // Mock a live program
+ const liveProgram = {
+ id: 'prog-live',
+ tvg_id: 'tvg-1',
+ title: 'Live Show',
+ description: 'Live Description',
+ start_time: now.subtract(30, 'minutes').toISOString(),
+ end_time: now.add(30, 'minutes').toISOString(),
+ programStart: now.subtract(30, 'minutes'),
+ programEnd: now.add(30, 'minutes'),
+ startMs: now.subtract(30, 'minutes').valueOf(),
+ endMs: now.add(30, 'minutes').valueOf(),
+ isLive: true,
+ isPast: false,
+ };
+
+ guideUtils.fetchPrograms.mockResolvedValue([liveProgram]);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('TV Guide')).toBeInTheDocument();
+ });
+
+ // Implementation depends on how programs are rendered - this is a placeholder
+ // You would need to find and click the actual watch button in the rendered program
+
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
+ });
+
+ it('does not show watch button for past programs', async () => {
+ vi.useRealTimers();
+
+ const pastProgram = {
+ id: 'prog-past',
+ tvg_id: 'tvg-1',
+ title: 'Past Show',
+ description: 'Past Description',
+ start_time: now.subtract(2, 'hours').toISOString(),
+ end_time: now.subtract(1, 'hour').toISOString(),
+ programStart: now.subtract(2, 'hours'),
+ programEnd: now.subtract(1, 'hour'),
+ startMs: now.subtract(2, 'hours').valueOf(),
+ endMs: now.subtract(1, 'hour').valueOf(),
+ isLive: false,
+ isPast: true,
+ };
+
+ guideUtils.fetchPrograms.mockResolvedValue([pastProgram]);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('TV Guide')).toBeInTheDocument();
+ });
+
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/pages/__tests__/Login.test.jsx b/frontend/src/pages/__tests__/Login.test.jsx
new file mode 100644
index 00000000..3db66883
--- /dev/null
+++ b/frontend/src/pages/__tests__/Login.test.jsx
@@ -0,0 +1,37 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import Login from '../Login';
+import useAuthStore from '../../store/auth';
+
+vi.mock('../../store/auth');
+vi.mock('../../components/forms/LoginForm', () => ({
+ default: () => LoginForm
+}));
+vi.mock('../../components/forms/SuperuserForm', () => ({
+ default: () => SuperuserForm
+}));
+vi.mock('@mantine/core', () => ({
+ Text: ({ children }) => {children}
,
+}));
+
+describe('Login', () => {
+ it('renders SuperuserForm when superuser does not exist', async () => {
+ useAuthStore.mockReturnValue(false);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('superuser-form')).toBeInTheDocument();
+ });
+ expect(screen.queryByTestId('login-form')).not.toBeInTheDocument();
+ });
+
+ it('renders LoginForm when superuser exists', () => {
+ useAuthStore.mockReturnValue(true);
+
+ render();
+
+ expect(screen.getByTestId('login-form')).toBeInTheDocument();
+ expect(screen.queryByTestId('superuser-form')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/__tests__/Logos.test.jsx b/frontend/src/pages/__tests__/Logos.test.jsx
new file mode 100644
index 00000000..b710b2ef
--- /dev/null
+++ b/frontend/src/pages/__tests__/Logos.test.jsx
@@ -0,0 +1,172 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import LogosPage from '../Logos';
+import useLogosStore from '../../store/logos';
+import useVODLogosStore from '../../store/vodLogos';
+import { showNotification, updateNotification } from '../../utils/notificationUtils.js';
+
+vi.mock('../../store/logos');
+vi.mock('../../store/vodLogos');
+vi.mock('../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+ updateNotification: vi.fn(),
+}));
+vi.mock('../../components/tables/LogosTable', () => ({
+ default: () => LogosTable
+}));
+vi.mock('../../components/tables/VODLogosTable', () => ({
+ default: () => VODLogosTable
+}));
+vi.mock('@mantine/core', () => {
+ const tabsComponent = ({ children, value, onChange }) =>
+ onChange('vod')}>{children}
;
+ tabsComponent.List = ({ children }) => {children}
;
+ tabsComponent.Tab = ({ children, value }) => ;
+
+ return {
+ Box: ({ children, ...props }) => {children}
,
+ Flex: ({ children, ...props }) => {children}
,
+ Text: ({ children, ...props }) => {children},
+ Tabs: tabsComponent,
+ TabsList: tabsComponent.List,
+ TabsTab: tabsComponent.Tab,
+ };
+});
+
+describe('LogosPage', () => {
+ const mockFetchAllLogos = vi.fn();
+ const mockNeedsAllLogos = vi.fn();
+
+ const defaultLogosState = {
+ fetchAllLogos: mockFetchAllLogos,
+ needsAllLogos: mockNeedsAllLogos,
+ logos: { 1: {}, 2: {}, 3: {} },
+ };
+
+ const defaultVODLogosState = {
+ totalCount: 5,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ useLogosStore.mockImplementation((selector) => {
+ return selector ? selector(defaultLogosState) : defaultLogosState;
+ });
+ useLogosStore.getState = () => defaultLogosState;
+
+ useVODLogosStore.mockImplementation((selector) => {
+ return selector ? selector(defaultVODLogosState) : defaultVODLogosState;
+ });
+
+ mockNeedsAllLogos.mockReturnValue(true);
+ mockFetchAllLogos.mockResolvedValue();
+ });
+
+ it('renders with channel logos tab by default', () => {
+ render();
+
+ expect(screen.getByText('Logos')).toBeInTheDocument();
+ expect(screen.getByTestId('logos-table')).toBeInTheDocument();
+ expect(screen.queryByTestId('vod-logos-table')).not.toBeInTheDocument();
+ });
+
+ it('displays correct channel logos count', () => {
+ render();
+
+ expect(screen.getByText(/\(3 logos\)/i)).toBeInTheDocument();
+ });
+
+ it('displays singular "logo" when count is 1', () => {
+ useLogosStore.mockImplementation((selector) => {
+ const state = {
+ fetchAllLogos: mockFetchAllLogos,
+ needsAllLogos: mockNeedsAllLogos,
+ logos: { 1: {} },
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ expect(screen.getByText(/\(1 logo\)/i)).toBeInTheDocument();
+ });
+
+ it('fetches all logos on mount when needed', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(mockNeedsAllLogos).toHaveBeenCalled();
+ expect(mockFetchAllLogos).toHaveBeenCalled();
+ });
+ });
+
+ it('does not fetch logos when not needed', async () => {
+ mockNeedsAllLogos.mockReturnValue(false);
+
+ render();
+
+ await waitFor(() => {
+ expect(mockNeedsAllLogos).toHaveBeenCalled();
+ expect(mockFetchAllLogos).not.toHaveBeenCalled();
+ });
+ });
+
+ it('shows error notification when fetching logos fails', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const error = new Error('Failed to fetch');
+ mockFetchAllLogos.mockRejectedValue(error);
+
+ render();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith({
+ title: 'Error',
+ message: 'Failed to load channel logos',
+ color: 'red',
+ });
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to load channel logos:',
+ error
+ );
+ });
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('switches to VOD logos tab when clicked', () => {
+ const { rerender } = render();
+
+ expect(screen.getByTestId('logos-table')).toBeInTheDocument();
+
+ const tabs = screen.getByTestId('tabs');
+ fireEvent.click(tabs);
+
+ rerender();
+
+ expect(screen.getByTestId('vod-logos-table')).toBeInTheDocument();
+ expect(screen.queryByTestId('logos-table')).not.toBeInTheDocument();
+ });
+
+ it('renders both tab options', () => {
+ render();
+
+ expect(screen.getByText('Channel Logos')).toBeInTheDocument();
+ expect(screen.getByText('VOD Logos')).toBeInTheDocument();
+ });
+
+ it('displays zero logos correctly', () => {
+ useLogosStore.mockImplementation((selector) => {
+ const state = {
+ fetchAllLogos: mockFetchAllLogos,
+ needsAllLogos: mockNeedsAllLogos,
+ logos: {},
+ };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ expect(screen.getByText(/\(0 logos\)/i)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/__tests__/Plugins.test.jsx b/frontend/src/pages/__tests__/Plugins.test.jsx
new file mode 100644
index 00000000..cbf052ed
--- /dev/null
+++ b/frontend/src/pages/__tests__/Plugins.test.jsx
@@ -0,0 +1,561 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import PluginsPage from '../Plugins';
+import { showNotification, updateNotification } from '../../utils/notificationUtils.js';
+import {
+ deletePluginByKey,
+ importPlugin,
+ setPluginEnabled,
+ updatePluginSettings,
+} from '../../utils/pages/PluginsUtils';
+import { usePluginStore } from '../../store/plugins';
+
+vi.mock('../../store/plugins');
+
+vi.mock('../../utils/pages/PluginsUtils', () => ({
+ deletePluginByKey: vi.fn(),
+ importPlugin: vi.fn(),
+ setPluginEnabled: vi.fn(),
+ updatePluginSettings: vi.fn(),
+ runPluginAction: vi.fn(),
+}));
+vi.mock('../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+ updateNotification: vi.fn(),
+}));
+
+vi.mock('@mantine/core', async () => {
+ return {
+ AppShellMain: ({ children }) => {children}
,
+ Box: ({ children, style }) => {children}
,
+ Stack: ({ children, gap }) => {children}
,
+ Group: ({ children, justify, mb }) => (
+
+ {children}
+
+ ),
+ Alert: ({ children, color, title }) => (
+
+ {title &&
{title}
}
+ {children}
+
+ ),
+ Text: ({ children, size, fw, c }) => (
+
+ {children}
+
+ ),
+ Button: ({ children, onClick, leftSection, variant, color, loading, disabled, fullWidth }) => (
+
+ ),
+ Loader: () => Loading...
,
+ Switch: ({ checked, onChange, label, description }) => (
+
+ ),
+ Divider: ({ my }) =>
,
+ ActionIcon: ({ children, onClick, color, variant, title }) => (
+
+ ),
+ SimpleGrid: ({ children, cols }) => (
+ {children}
+ ),
+ Modal: ({ opened, onClose, title, children, size, centered }) =>
+ opened ? (
+
+
{title}
+
+ {children}
+
+ ) : null,
+ FileInput: ({ value, onChange, label, placeholder, accept }) => (
+
+ {label && }
+ onChange?.(e.target.files[0])}
+ placeholder={placeholder}
+ accept={accept}
+ aria-label={label}
+ />
+
+ ),
+ };
+});
+vi.mock('@mantine/dropzone', () => ({
+ Dropzone: ({ children, onDrop, accept, maxSize }) => (
+ {
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ onDrop([file]);
+ }}
+ >
+
Drop files
+ {children}
+
+ ),
+}));
+
+vi.mock('../../components/cards/PluginCard.jsx', () => ({
+ default: ({ plugin }) => (
+
+
{plugin.name}
+
{plugin.description}
+
+ ),
+}));
+
+describe('PluginsPage', () => {
+ const mockPlugins = [
+ {
+ key: 'plugin1',
+ name: 'Test Plugin 1',
+ description: 'Description 1',
+ enabled: true,
+ ever_enabled: true,
+ },
+ {
+ key: 'plugin2',
+ name: 'Test Plugin 2',
+ description: 'Description 2',
+ enabled: false,
+ ever_enabled: false,
+ },
+ ];
+
+ const mockPluginStoreState = {
+ plugins: mockPlugins,
+ loading: false,
+ fetchPlugins: vi.fn(),
+ updatePlugin: vi.fn(),
+ removePlugin: vi.fn(),
+ invalidatePlugins: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ usePluginStore.mockImplementation((selector) => {
+ return selector ? selector(mockPluginStoreState) : mockPluginStoreState;
+ });
+ usePluginStore.getState = vi.fn(() => mockPluginStoreState);
+ });
+
+ describe('Rendering', () => {
+ it('renders the page with plugins list', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Plugins')).toBeInTheDocument();
+ expect(screen.getByText('Test Plugin 1')).toBeInTheDocument();
+ expect(screen.getByText('Test Plugin 2')).toBeInTheDocument();
+ });
+ });
+
+ it('renders import button', () => {
+ render();
+
+ expect(screen.getByText('Import Plugin')).toBeInTheDocument();
+ });
+
+ it('renders reload button', () => {
+ render();
+
+ const reloadButton = screen.getByTitle('Reload');
+ expect(reloadButton).toBeInTheDocument();
+ });
+
+ it('shows loader when loading and no plugins', () => {
+ const loadingState = { plugins: [], loading: true, fetchPlugins: vi.fn() };
+ usePluginStore.mockImplementation((selector) => {
+ return selector ? selector(loadingState) : loadingState;
+ });
+ usePluginStore.getState = vi.fn(() => loadingState);
+
+ render();
+
+ expect(screen.getByTestId('loader')).toBeInTheDocument();
+ });
+
+ it('shows empty state when no plugins', () => {
+ const emptyState = { plugins: [], loading: false, fetchPlugins: vi.fn() };
+ usePluginStore.mockImplementation((selector) => {
+ return selector ? selector(emptyState) : emptyState;
+ });
+ usePluginStore.getState = vi.fn(() => emptyState);
+
+ render();
+
+ expect(screen.getByText(/No plugins found/)).toBeInTheDocument();
+ });
+ });
+
+ describe('Import Plugin', () => {
+ it('opens import modal when import button is clicked', () => {
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Import Plugin');
+ });
+
+ it('shows dropzone and file input in import modal', () => {
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ expect(screen.getByTestId('dropzone')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Select plugin .zip')).toBeInTheDocument();
+ });
+
+ it('closes import modal when close button is clicked', () => {
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText('Close Modal'));
+
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('handles file upload via dropzone', async () => {
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: { key: 'new-plugin', name: 'New Plugin', description: 'New Description' },
+ });
+
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+ const dropzone = screen.getByTestId('dropzone');
+ fireEvent.click(dropzone);
+
+ await waitFor(() => {
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ expect(uploadButton).not.toBeDisabled();
+ });
+ });
+
+ it('uploads plugin successfully', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: false,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(importPlugin).toHaveBeenCalledWith(file);
+ expect(showNotification).toHaveBeenCalled();
+ expect(updateNotification).toHaveBeenCalled();
+ });
+ });
+
+ it('handles upload failure', async () => {
+ importPlugin.mockResolvedValue({
+ success: false,
+ error: 'Upload failed',
+ });
+
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(updateNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ color: 'red',
+ title: 'Import failed',
+ })
+ );
+ });
+ });
+
+ it('shows enable switch after successful import', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: false,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('New Plugin')).toBeInTheDocument();
+ expect(screen.getByText('Enable now')).toBeInTheDocument();
+ });
+ });
+
+ it('enables plugin after import when switch is toggled', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: true,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+ setPluginEnabled.mockResolvedValue({ success: true });
+
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Enable now')).toBeInTheDocument();
+ });
+
+ const enableSwitch = screen.getByRole('checkbox');
+ fireEvent.click(enableSwitch);
+
+ const enableButton = screen.getAllByText('Enable').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(enableButton);
+
+ await waitFor(() => {
+ expect(setPluginEnabled).toHaveBeenCalledWith('new-plugin', true);
+ });
+ });
+ });
+
+ describe('Trust Warning', () => {
+ it('shows trust warning for untrusted plugins', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: false,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+ setPluginEnabled.mockResolvedValue({ success: true, ever_enabled: true });
+
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Enable now')).toBeInTheDocument();
+ });
+
+ const enableSwitch = screen.getByRole('checkbox');
+ fireEvent.click(enableSwitch);
+
+ const enableButton = screen.getAllByText('Enable').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(enableButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Enable third-party plugins?')).toBeInTheDocument();
+ });
+ });
+
+ it('enables plugin when trust is confirmed', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: false,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+ setPluginEnabled.mockResolvedValue({ success: true, ever_enabled: true });
+
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Enable now')).toBeInTheDocument();
+ });
+
+ const enableSwitch = screen.getByRole('checkbox');
+ fireEvent.click(enableSwitch);
+
+ const enableButton = screen.getAllByText('Enable').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(enableButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('I understand, enable')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText('I understand, enable'));
+
+ await waitFor(() => {
+ expect(setPluginEnabled).toHaveBeenCalledWith('new-plugin', true);
+ });
+ });
+
+ it('cancels enable when trust is denied', async () => {
+ const mockPlugin = {
+ key: 'new-plugin',
+ name: 'New Plugin',
+ description: 'New Description',
+ ever_enabled: false,
+ };
+ importPlugin.mockResolvedValue({
+ success: true,
+ plugin: mockPlugin,
+ });
+
+ render();
+
+ fireEvent.click(screen.getByText('Import Plugin'));
+
+ const fileInput = screen.getByPlaceholderText('Select plugin .zip');
+ const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ fireEvent.change(fileInput, { target: { files: [file] } });
+
+ const uploadButton = screen.getAllByText('Upload').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(uploadButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Enable now')).toBeInTheDocument();
+ });
+
+ const enableSwitch = screen.getByRole('checkbox');
+ fireEvent.click(enableSwitch);
+
+ const enableButton = screen.getAllByText('Enable').find(btn =>
+ btn.tagName === 'BUTTON'
+ );
+ fireEvent.click(enableButton);
+
+ await waitFor(() => {
+ const cancelButtons = screen.getAllByText('Cancel');
+ expect(cancelButtons.length).toBeGreaterThan(0);
+ });
+
+ const cancelButtons = screen.getAllByText('Cancel');
+ fireEvent.click(cancelButtons[cancelButtons.length - 1]);
+
+ await waitFor(() => {
+ expect(setPluginEnabled).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('Reload', () => {
+ it('reloads plugins when reload button is clicked', async () => {
+ const invalidatePlugins = vi.fn();
+ usePluginStore.getState = vi.fn(() => ({
+ ...mockPluginStoreState,
+ invalidatePlugins,
+ }));
+
+ render();
+
+ const reloadButton = screen.getByTitle('Reload');
+ fireEvent.click(reloadButton);
+
+ await waitFor(() => {
+ expect(invalidatePlugins).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/frontend/src/pages/__tests__/Settings.test.jsx b/frontend/src/pages/__tests__/Settings.test.jsx
new file mode 100644
index 00000000..6a254326
--- /dev/null
+++ b/frontend/src/pages/__tests__/Settings.test.jsx
@@ -0,0 +1,208 @@
+import {
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import SettingsPage from '../Settings';
+import useAuthStore from '../../store/auth';
+import { USER_LEVELS } from '../../constants';
+import userEvent from '@testing-library/user-event';
+
+// Mock all dependencies
+vi.mock('../../store/auth');
+vi.mock('../../components/tables/UserAgentsTable', () => ({
+ default: ({ active }) => UserAgentsTable {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/tables/StreamProfilesTable', () => ({
+ default: ({ active }) => StreamProfilesTable {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/backups/BackupManager', () => ({
+ default: ({ active }) => BackupManager {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/UiSettingsForm', () => ({
+ default: ({ active }) => UiSettingsForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/NetworkAccessForm', () => ({
+ default: ({ active }) => NetworkAccessForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/ProxySettingsForm', () => ({
+ default: ({ active }) => ProxySettingsForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/StreamSettingsForm', () => ({
+ default: ({ active }) => StreamSettingsForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/DvrSettingsForm', () => ({
+ default: ({ active }) => DvrSettingsForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/forms/settings/SystemSettingsForm', () => ({
+ default: ({ active }) => SystemSettingsForm {active ? 'active' : 'inactive'}
,
+}));
+vi.mock('../../components/ErrorBoundary', () => ({
+ default: ({ children }) => {children}
,
+}));
+
+vi.mock('@mantine/core', async () => {
+ const accordionComponent = ({ children, onChange, defaultValue }) => {children}
;
+ accordionComponent.Item = ({ children, value }) => (
+ {children}
+ );
+ accordionComponent.Control = ({ children }) => (
+
+ );
+ accordionComponent.Panel = ({ children }) => (
+ {children}
+ );
+
+ return {
+ Accordion: accordionComponent,
+ AccordionItem: accordionComponent.Item,
+ AccordionControl: accordionComponent.Control,
+ AccordionPanel: accordionComponent.Panel,
+ Box: ({ children }) => {children}
,
+ Center: ({ children }) => {children}
,
+ Loader: () => Loading...
,
+ Text: ({ children }) => {children},
+ };
+});
+
+
+describe('SettingsPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Rendering for Regular User', () => {
+ beforeEach(() => {
+ useAuthStore.mockReturnValue({
+ user_level: USER_LEVELS.USER,
+ username: 'testuser',
+ });
+ });
+
+ it('renders the settings page', () => {
+ render();
+
+ expect(screen.getByTestId('accordion')).toBeInTheDocument();
+ });
+
+ it('renders UI Settings accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-ui-settings')).toBeInTheDocument();
+ expect(screen.getByText('UI Settings')).toBeInTheDocument();
+ });
+
+ it('opens UI Settings panel by default', () => {
+ render();
+
+ expect(screen.getByTestId('ui-settings-form')).toBeInTheDocument();
+ });
+
+ it('does not render admin-only sections for regular users', () => {
+ render();
+
+ expect(screen.queryByText('DVR')).not.toBeInTheDocument();
+ expect(screen.queryByText('Stream Settings')).not.toBeInTheDocument();
+ expect(screen.queryByText('System Settings')).not.toBeInTheDocument();
+ expect(screen.queryByText('User-Agents')).not.toBeInTheDocument();
+ expect(screen.queryByText('Stream Profiles')).not.toBeInTheDocument();
+ expect(screen.queryByText('Network Access')).not.toBeInTheDocument();
+ expect(screen.queryByText('Proxy Settings')).not.toBeInTheDocument();
+ expect(screen.queryByText('Backup & Restore')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Rendering for Admin User', () => {
+ beforeEach(() => {
+ useAuthStore.mockReturnValue({
+ user_level: USER_LEVELS.ADMIN,
+ username: 'admin',
+ });
+ });
+
+ it('renders all accordion items for admin', async () => {
+ render();
+
+ expect(screen.getByText('UI Settings')).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(screen.getByText('DVR')).toBeInTheDocument();
+ expect(screen.getByText('Stream Settings')).toBeInTheDocument();
+ expect(screen.getByText('System Settings')).toBeInTheDocument();
+ expect(screen.getByText('User-Agents')).toBeInTheDocument();
+ expect(screen.getByText('Stream Profiles')).toBeInTheDocument();
+ expect(screen.getByText('Network Access')).toBeInTheDocument();
+ expect(screen.getByText('Proxy Settings')).toBeInTheDocument();
+ expect(screen.getByText('Backup & Restore')).toBeInTheDocument();
+ });
+ });
+
+ it('renders DVR settings accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-dvr-settings')).toBeInTheDocument();
+ });
+
+ it('renders Stream Settings accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-stream-settings')).toBeInTheDocument();
+ });
+
+ it('renders System Settings accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-system-settings')).toBeInTheDocument();
+ });
+
+ it('renders User-Agents accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-user-agents')).toBeInTheDocument();
+ });
+
+ it('renders Stream Profiles accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-stream-profiles')).toBeInTheDocument();
+ });
+
+ it('renders Network Access accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-network-access')).toBeInTheDocument();
+ });
+
+ it('renders Proxy Settings accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-proxy-settings')).toBeInTheDocument();
+ });
+
+ it('renders Backup & Restore accordion item', () => {
+ render();
+
+ expect(screen.getByTestId('accordion-item-backups')).toBeInTheDocument();
+ });
+ });
+
+ describe('Accordion Interactions', () => {
+ beforeEach(() => {
+ useAuthStore.mockReturnValue({
+ user_level: USER_LEVELS.ADMIN,
+ username: 'admin',
+ });
+ });
+
+ it('opens DVR settings when clicked', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const streamSettingsButton = screen.getByText('DVR');
+ await user.click(streamSettingsButton);
+
+ await screen.findByTestId('dvr-settings-form');
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/pages/__tests__/Stats.test.jsx b/frontend/src/pages/__tests__/Stats.test.jsx
new file mode 100644
index 00000000..bf5cdb42
--- /dev/null
+++ b/frontend/src/pages/__tests__/Stats.test.jsx
@@ -0,0 +1,494 @@
+// src/pages/__tests__/Stats.test.jsx
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ render,
+ screen,
+ waitFor,
+ fireEvent,
+ act,
+} from '@testing-library/react';
+import StatsPage from '../Stats';
+import useStreamProfilesStore from '../../store/streamProfiles';
+import useLocalStorage from '../../hooks/useLocalStorage';
+import useChannelsStore from '../../store/channels';
+import useLogosStore from '../../store/logos';
+import {
+ fetchActiveChannelStats,
+ getClientStats,
+ getCombinedConnections,
+ getStatsByChannelId,
+ getVODStats,
+ stopChannel,
+ stopClient,
+ stopVODClient,
+} from '../../utils/pages/StatsUtils.js';
+
+// Mock dependencies
+vi.mock('../../store/channels');
+vi.mock('../../store/logos');
+vi.mock('../../store/streamProfiles');
+vi.mock('../../hooks/useLocalStorage');
+
+vi.mock('../../components/SystemEvents', () => ({
+ default: () => SystemEvents
+}));
+
+vi.mock('../../components/ErrorBoundary.jsx', () => ({
+ default: ({ children }) => {children}
+}));
+
+vi.mock('../../components/cards/VodConnectionCard.jsx', () => ({
+ default: ({ vodContent, stopVODClient }) => (
+
+ VODConnectionCard - {vodContent.content_uuid}
+ {vodContent.connections?.map((conn) => (
+
+ ))}
+
+ ),
+}));
+
+vi.mock('../../components/cards/StreamConnectionCard.jsx', () => ({
+ default: ({ channel }) => (
+
+ StreamConnectionCard - {channel.uuid}
+
+ ),
+}));
+
+// Mock Mantine components
+vi.mock('@mantine/core', () => ({
+ Box: ({ children, ...props }) => {children}
,
+ Button: ({ children, onClick, loading, ...props }) => (
+
+ ),
+ Group: ({ children }) => {children}
,
+ LoadingOverlay: () => Loading...
,
+ Text: ({ children }) => {children},
+ Title: ({ children }) => {children}
,
+ NumberInput: ({ value, onChange, min, max, ...props }) => (
+ onChange(Number(e.target.value))}
+ min={min}
+ max={max}
+ {...props}
+ />
+ ),
+}));
+
+//mock stats utils
+vi.mock('../../utils/pages/StatsUtils', () => {
+ return {
+ fetchActiveChannelStats: vi.fn(),
+ getVODStats: vi.fn(),
+ getClientStats: vi.fn(),
+ getCombinedConnections: vi.fn(),
+ getStatsByChannelId: vi.fn(),
+ stopChannel: vi.fn(),
+ stopClient: vi.fn(),
+ stopVODClient: vi.fn(),
+ };
+});
+
+describe('StatsPage', () => {
+ const mockChannels = [
+ { id: 1, uuid: 'channel-1', name: 'Channel 1' },
+ { id: 2, uuid: 'channel-2', name: 'Channel 2' },
+ ];
+
+ const mockChannelsByUUID = {
+ 'channel-1': mockChannels[0],
+ 'channel-2': mockChannels[1],
+ };
+
+ const mockStreamProfiles = [
+ { id: 1, name: 'Profile 1' },
+ ];
+
+ const mockLogos = {
+ 'logo-1': 'logo-url-1',
+ };
+
+ const mockChannelStats = {
+ channels: [
+ { channel_id: 1, uuid: 'channel-1', connections: 2 },
+ { channel_id: 2, uuid: 'channel-2', connections: 1 },
+ ],
+ };
+
+ const mockVODStats = {
+ vod_connections: [
+ {
+ content_uuid: 'vod-1',
+ connections: [
+ { client_id: 'client-1', ip: '192.168.1.1' },
+ ],
+ },
+ ],
+ };
+
+ const mockProcessedChannelHistory = {
+ 1: { id: 1, uuid: 'channel-1', connections: 2 },
+ 2: { id: 2, uuid: 'channel-2', connections: 1 },
+ };
+
+ const mockClients = [
+ { id: 'client-1', channel_id: 1 },
+ { id: 'client-2', channel_id: 1 },
+ { id: 'client-3', channel_id: 2 },
+ ];
+
+ const mockCombinedConnections = [
+ { id: 1, type: 'stream', data: { id: 1, uuid: 'channel-1' } },
+ { id: 2, type: 'stream', data: { id: 2, uuid: 'channel-2' } },
+ { id: 3, type: 'vod', data: { content_uuid: 'vod-1', connections: [{ client_id: 'client-1' }] } },
+ ];
+
+ let mockSetChannelStats;
+ let mockSetRefreshInterval;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mockSetChannelStats = vi.fn();
+ mockSetRefreshInterval = vi.fn();
+
+ // Setup store mocks
+ useChannelsStore.mockImplementation((selector) => {
+ const state = {
+ channels: mockChannels,
+ channelsByUUID: mockChannelsByUUID,
+ stats: { channels: mockChannelStats.channels },
+ setChannelStats: mockSetChannelStats,
+ };
+ return selector ? selector(state) : state;
+ });
+
+ useStreamProfilesStore.mockImplementation((selector) => {
+ const state = {
+ profiles: mockStreamProfiles,
+ };
+ return selector ? selector(state) : state;
+ });
+
+ useLogosStore.mockImplementation((selector) => {
+ const state = {
+ logos: mockLogos,
+ };
+ return selector ? selector(state) : state;
+ });
+
+ useLocalStorage.mockReturnValue([5, mockSetRefreshInterval]);
+
+ // Setup API mocks
+ fetchActiveChannelStats.mockResolvedValue(mockChannelStats);
+ getVODStats.mockResolvedValue(mockVODStats);
+ getStatsByChannelId.mockReturnValue(mockProcessedChannelHistory);
+ getClientStats.mockReturnValue(mockClients);
+ getCombinedConnections.mockReturnValue(mockCombinedConnections);
+ stopVODClient.mockResolvedValue({});
+
+ delete window.location;
+ window.location = { pathname: '/stats' };
+ });
+
+ describe('Initial Rendering', () => {
+ it('renders the page title', async () => {
+ render();
+ await screen.findByText('Active Connections')
+ });
+
+ it('fetches initial stats on mount', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+ expect(getVODStats).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('displays connection counts', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/2 streams/)).toBeInTheDocument();
+ expect(screen.getByText(/1 VOD connection/)).toBeInTheDocument();
+ });
+ });
+
+ it('renders SystemEvents component', async () => {
+ render();
+ await screen.findByTestId('system-events')
+ });
+ });
+
+ describe('Refresh Interval Controls', () => {
+ it('displays default refresh interval', () => {
+ render();
+
+ waitFor(() => {
+ const input = screen.getByTestId('refresh-interval-input');
+ expect(input).toHaveValue(5);
+ });
+ });
+
+ it('updates refresh interval when input changes', async () => {
+ render();
+
+ const input = screen.getByTestId('refresh-interval-input');
+ fireEvent.change(input, { target: { value: '10' } });
+
+ await waitFor(() => {
+ expect(mockSetRefreshInterval).toHaveBeenCalledWith(10);
+ });
+ });
+
+ it('displays polling active message when interval > 0', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/Refreshing every 5s/)).toBeInTheDocument();
+ });
+ });
+
+ it('displays disabled message when interval is 0', async () => {
+ useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]);
+ render();
+
+ await screen.findByText('Refreshing disabled')
+ });
+ });
+
+ describe('Auto-refresh Polling', () => {
+ it('sets up polling interval for stats', async () => {
+ vi.useFakeTimers();
+
+ render();
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+ expect(getVODStats).toHaveBeenCalledTimes(2);
+
+ // Advance timers by 5 seconds
+ await act(async () => {
+ vi.advanceTimersByTime(5000);
+ });
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3);
+ expect(getVODStats).toHaveBeenCalledTimes(3);
+
+ vi.useRealTimers();
+ });
+
+ it('does not poll when interval is 0', async () => {
+ vi.useFakeTimers();
+
+ useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]);
+ render();
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ vi.advanceTimersByTime(10000);
+ });
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
+
+ vi.useRealTimers();
+ });
+
+ it('clears interval on unmount', async () => {
+ vi.useFakeTimers();
+
+ const { unmount } = render();
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+
+ unmount();
+
+ await act(async () => {
+ vi.advanceTimersByTime(5000);
+ });
+
+ // Should not fetch again after unmount
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+
+ vi.useRealTimers();
+ });
+ });
+
+ describe('Manual Refresh', () => {
+ it('refreshes stats when Refresh Now button is clicked', async () => {
+ render();
+
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+
+ const refreshButton = screen.getByText('Refresh Now');
+ fireEvent.click(refreshButton);
+
+ await waitFor(() => {
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3);
+ expect(getVODStats).toHaveBeenCalledTimes(3);
+ });
+ });
+ });
+
+ describe('Connection Display', () => {
+ it('renders stream connection cards', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('stream-connection-card-channel-1')).toBeInTheDocument();
+ expect(screen.getByTestId('stream-connection-card-channel-2')).toBeInTheDocument();
+ });
+ });
+
+ it('renders VOD connection cards', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('vod-connection-card-vod-1')).toBeInTheDocument();
+ });
+ });
+
+ it('displays empty state when no connections', async () => {
+ getCombinedConnections.mockReturnValue([]);
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('No active connections')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('VOD Client Management', () => {
+ it('stops VOD client when stop button is clicked', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('stop-vod-client-client-1')).toBeInTheDocument();
+ });
+
+ const stopButton = screen.getByTestId('stop-vod-client-client-1');
+ fireEvent.click(stopButton);
+
+ await waitFor(() => {
+ expect(stopVODClient).toHaveBeenCalledWith('client-1');
+ });
+ });
+
+ it('refreshes VOD stats after stopping client', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(getVODStats).toHaveBeenCalledTimes(2);
+ });
+
+ const stopButton = await screen.findByTestId('stop-vod-client-client-1');
+ fireEvent.click(stopButton);
+
+ await waitFor(() => {
+ expect(getVODStats).toHaveBeenCalledTimes(3);
+ });
+ });
+ });
+
+ describe('Stats Processing', () => {
+ it('processes channel stats correctly', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(getStatsByChannelId).toHaveBeenCalledWith(
+ mockChannelStats,
+ expect.any(Object),
+ mockChannelsByUUID,
+ mockChannels,
+ mockStreamProfiles
+ );
+ });
+ });
+
+ it('updates clients based on processed stats', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(getClientStats).toHaveBeenCalledWith(mockProcessedChannelHistory);
+ });
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('handles fetchActiveChannelStats error gracefully', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ fetchActiveChannelStats.mockRejectedValue(new Error('API Error'));
+
+ render();
+
+ await waitFor(() => {
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Error fetching channel stats:',
+ expect.any(Error)
+ );
+ });
+
+ consoleError.mockRestore();
+ });
+
+ it('handles getVODStats error gracefully', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ getVODStats.mockRejectedValue(new Error('VOD API Error'));
+
+ render();
+
+ await waitFor(() => {
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Error fetching VOD stats:',
+ expect.any(Error)
+ );
+ });
+
+ consoleError.mockRestore();
+ });
+ });
+
+ describe('Connection Count Display', () => {
+ it('displays singular form for 1 stream', async () => {
+ getCombinedConnections.mockReturnValue([
+ { id: 1, type: 'stream', data: { id: 1, uuid: 'channel-1' } },
+ ]);
+ getStatsByChannelId.mockReturnValue({ 1: { id: 1, uuid: 'channel-1' } });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/1 stream/)).toBeInTheDocument();
+ });
+ });
+
+ it('displays plural form for multiple VOD connections', async () => {
+ const multiVODStats = {
+ vod_connections: [
+ { content_uuid: 'vod-1', connections: [{ client_id: 'c1' }] },
+ { content_uuid: 'vod-2', connections: [{ client_id: 'c2' }] },
+ ],
+ };
+ getVODStats.mockResolvedValue(multiVODStats);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/2 VOD connections/)).toBeInTheDocument();
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/pages/__tests__/Users.test.jsx b/frontend/src/pages/__tests__/Users.test.jsx
new file mode 100644
index 00000000..3ee63627
--- /dev/null
+++ b/frontend/src/pages/__tests__/Users.test.jsx
@@ -0,0 +1,58 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import UsersPage from '../Users';
+import useAuthStore from '../../store/auth';
+
+vi.mock('../../store/auth');
+vi.mock('../../components/tables/UsersTable', () => ({
+ default: () => UsersTable
+}));
+vi.mock('@mantine/core', () => ({
+ Box: ({ children, ...props }) => {children}
,
+}));
+
+describe('UsersPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders nothing when user is not authenticated', () => {
+ useAuthStore.mockReturnValue({ id: null });
+
+ const { container } = render();
+
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ expect(screen.queryByTestId('users-table')).not.toBeInTheDocument();
+ });
+
+ it('renders UsersTable when user is authenticated', () => {
+ useAuthStore.mockReturnValue({ id: 1, email: 'test@example.com' });
+
+ render();
+
+ expect(screen.getByTestId('users-table')).toBeInTheDocument();
+ });
+
+ it('handles user with id 0 as authenticated', () => {
+ useAuthStore.mockReturnValue({ id: 0 });
+
+ const { container } = render();
+
+ // id: 0 is falsy, so should render empty
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ });
+
+ it('switches from unauthenticated to authenticated state', () => {
+ useAuthStore.mockReturnValue({ id: null });
+
+ render();
+
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+
+ useAuthStore.mockReturnValue({ id: 1 });
+
+ render();
+
+ expect(screen.getByTestId('users-table')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/__tests__/VODs.test.jsx b/frontend/src/pages/__tests__/VODs.test.jsx
new file mode 100644
index 00000000..6e7c00ec
--- /dev/null
+++ b/frontend/src/pages/__tests__/VODs.test.jsx
@@ -0,0 +1,468 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import VODsPage from '../VODs';
+import useVODStore from '../../store/useVODStore';
+import {
+ filterCategoriesToEnabled,
+ getCategoryOptions,
+} from '../../utils/pages/VODsUtils.js';
+
+vi.mock('../../store/useVODStore');
+
+vi.mock('../../components/SeriesModal', () => ({
+ default: ({ opened, series, onClose }) =>
+ opened ? (
+
+
{series?.name}
+
+
+ ) : null
+}));
+vi.mock('../../components/VODModal', () => ({
+ default: ({ opened, vod, onClose }) =>
+ opened ? (
+
+ ) : null
+}));
+vi.mock('../../components/cards/VODCard', () => ({
+ default: ({ vod, onClick }) => (
+ onClick(vod)}>
+
{vod.name}
+
+ )
+}));
+vi.mock('../../components/cards/SeriesCard', () => ({
+ default: ({ series, onClick }) => (
+ onClick(series)}>
+
{series.name}
+
+ )
+}));
+
+vi.mock('@mantine/core', () => {
+ const gridComponent = ({ children, ...props }) => {children}
;
+ gridComponent.Col = ({ children, ...props }) => {children}
;
+
+ return {
+ Box: ({ children, ...props }) => {children}
,
+ Stack: ({ children, ...props }) => {children}
,
+ Group: ({ children, ...props }) => {children}
,
+ Flex: ({ children, ...props }) => {children}
,
+ Title: ({ children, ...props }) => {children}
,
+ TextInput: ({ value, onChange, placeholder, icon }) => (
+
+ {icon}
+
+
+ ),
+ Select: ({ value, onChange, data, label, placeholder }) => (
+
+ {label && }
+
+
+ ),
+ SegmentedControl: ({ value, onChange, data }) => (
+
+ {data.map((item) => (
+
+ ))}
+
+ ),
+ Pagination: ({ page, onChange, total }) => (
+
+
+ {page} of {total}
+
+
+ ),
+ Grid: gridComponent,
+ GridCol: gridComponent.Col,
+ Loader: () => Loading...
,
+ LoadingOverlay: ({ visible }) =>
+ visible ? Loading...
: null,
+ };
+});
+
+vi.mock('../../utils/pages/VODsUtils.js', () => {
+ return {
+ filterCategoriesToEnabled: vi.fn(),
+ getCategoryOptions: vi.fn(),
+ };
+});
+
+describe('VODsPage', () => {
+ const mockFetchContent = vi.fn();
+ const mockFetchCategories = vi.fn();
+ const mockSetFilters = vi.fn();
+ const mockSetPage = vi.fn();
+ const mockSetPageSize = vi.fn();
+
+ const defaultStoreState = {
+ currentPageContent: [],
+ categories: {},
+ filters: { type: 'all', search: '', category: '' },
+ currentPage: 1,
+ totalCount: 0,
+ pageSize: 12,
+ setFilters: mockSetFilters,
+ setPage: mockSetPage,
+ setPageSize: mockSetPageSize,
+ fetchContent: mockFetchContent,
+ fetchCategories: mockFetchCategories,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockFetchContent.mockResolvedValue();
+ mockFetchCategories.mockResolvedValue();
+ filterCategoriesToEnabled.mockReturnValue({});
+ getCategoryOptions.mockReturnValue([]);
+ useVODStore.mockImplementation((selector) => selector(defaultStoreState));
+ localStorage.clear();
+ });
+
+ it('renders the page title', async () => {
+ render();
+ await screen.findByText('Video on Demand');
+ });
+
+ it('fetches categories on mount', async () => {
+ render();
+ await waitFor(() => {
+ expect(mockFetchCategories).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('fetches content on mount', async () => {
+ render();
+ await waitFor(() => {
+ expect(mockFetchContent).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('displays loader during initial load', async () => {
+ render();
+ await screen.findByTestId('loader');
+ });
+
+ it('displays content after loading', async () => {
+ const stateWithContent = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Movie 1', contentType: 'movie' },
+ { id: 2, name: 'Series 1', contentType: 'series' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithContent));
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Movie 1')).toBeInTheDocument();
+ expect(screen.getByText('Series 1')).toBeInTheDocument();
+ });
+ });
+
+ it('renders VOD cards for movies', async () => {
+ const stateWithMovies = {
+ ...defaultStoreState,
+ currentPageContent: [{ id: 1, name: 'Movie 1', contentType: 'movie' }],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithMovies));
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('vod-card')).toBeInTheDocument();
+ });
+ });
+
+ it('renders series cards for series', async () => {
+ const stateWithSeries = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Series 1', contentType: 'series' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithSeries));
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('series-card')).toBeInTheDocument();
+ });
+ });
+
+ it('opens VOD modal when VOD card is clicked', async () => {
+ const stateWithMovies = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Test Movie', contentType: 'movie' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithMovies));
+
+ render();
+
+ await waitFor(() => {
+ fireEvent.click(screen.getByTestId('vod-card'));
+ });
+
+ expect(screen.getByTestId('vod-modal')).toBeInTheDocument();
+ expect(screen.getByTestId('vod-name')).toHaveTextContent('Test Movie');
+ });
+
+ it('opens series modal when series card is clicked', async () => {
+ const stateWithSeries = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Test Series', contentType: 'series' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithSeries));
+
+ render();
+
+ await waitFor(() => {
+ fireEvent.click(screen.getByTestId('series-card'));
+ });
+
+ expect(screen.getByTestId('series-modal')).toBeInTheDocument();
+ expect(screen.getByTestId('series-name')).toHaveTextContent('Test Series');
+ });
+
+ it('closes VOD modal when close button is clicked', async () => {
+ const stateWithMovies = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Test Movie', contentType: 'movie' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithMovies));
+
+ render();
+
+ await waitFor(() => {
+ fireEvent.click(screen.getByTestId('vod-card'));
+ });
+
+ fireEvent.click(screen.getByText('Close'));
+
+ expect(screen.queryByTestId('vod-modal')).not.toBeInTheDocument();
+ });
+
+ it('closes series modal when close button is clicked', async () => {
+ const stateWithSeries = {
+ ...defaultStoreState,
+ currentPageContent: [
+ { id: 1, name: 'Test Series', contentType: 'series' },
+ ],
+ };
+ useVODStore.mockImplementation((selector) => selector(stateWithSeries));
+
+ render();
+
+ await waitFor(() => {
+ fireEvent.click(screen.getByTestId('series-card'));
+ });
+
+ fireEvent.click(screen.getByText('Close'));
+
+ expect(screen.queryByTestId('series-modal')).not.toBeInTheDocument();
+ });
+
+ it('updates filters when search input changes', async () => {
+ render();
+
+ const searchInput = screen.getByPlaceholderText('Search VODs...');
+ fireEvent.change(searchInput, { target: { value: 'test search' } });
+
+ await waitFor(() => {
+ expect(mockSetFilters).toHaveBeenCalledWith({ search: 'test search' });
+ });
+ });
+
+ it('updates filters and resets page when type changes', async () => {
+ render();
+
+ const moviesButton = screen.getByText('Movies');
+ fireEvent.click(moviesButton);
+
+ await waitFor(() => {
+ expect(mockSetFilters).toHaveBeenCalledWith({
+ type: 'movies',
+ category: '',
+ });
+ expect(mockSetPage).toHaveBeenCalledWith(1);
+ });
+ });
+
+ it('updates filters and resets page when category changes', async () => {
+ getCategoryOptions.mockReturnValue([
+ { value: 'action', label: 'Action' },
+ ]);
+
+ render();
+
+ const categorySelect = screen.getByLabelText('Category');
+ fireEvent.change(categorySelect, { target: { value: 'action' } });
+
+ await waitFor(() => {
+ expect(mockSetFilters).toHaveBeenCalledWith({ category: 'action' });
+ expect(mockSetPage).toHaveBeenCalledWith(1);
+ });
+ });
+
+ it('updates page size and saves to localStorage', async () => {
+ render();
+
+ const pageSizeSelect = screen.getByLabelText('Page Size');
+ fireEvent.change(pageSizeSelect, { target: { value: '24' } });
+
+ await waitFor(() => {
+ expect(mockSetPageSize).toHaveBeenCalledWith(24);
+ expect(localStorage.getItem('vodsPageSize')).toBe('24');
+ });
+ });
+
+ it('loads page size from localStorage on mount', async () => {
+ localStorage.setItem('vodsPageSize', '48');
+
+ render();
+
+ await waitFor(() => {
+ expect(mockSetPageSize).toHaveBeenCalledWith(48);
+ });
+ });
+
+ it('displays pagination when total pages > 1', async () => {
+ const stateWithPagination = {
+ ...defaultStoreState,
+ currentPageContent: [{ id: 1, name: 'Movie 1', contentType: 'movie' }],
+ totalCount: 25,
+ pageSize: 12,
+ };
+ useVODStore.mockImplementation((selector) =>
+ selector(stateWithPagination)
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('pagination')).toBeInTheDocument();
+ });
+ });
+
+ it('does not display pagination when total pages <= 1', async () => {
+ const stateNoPagination = {
+ ...defaultStoreState,
+ currentPageContent: [{ id: 1, name: 'Movie 1', contentType: 'movie' }],
+ totalCount: 5,
+ pageSize: 12,
+ };
+ useVODStore.mockImplementation((selector) => selector(stateNoPagination));
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('pagination')).not.toBeInTheDocument();
+ });
+ });
+
+ it('changes page when pagination is clicked', async () => {
+ const stateWithPagination = {
+ ...defaultStoreState,
+ currentPageContent: [{ id: 1, name: 'Movie 1', contentType: 'movie' }],
+ totalCount: 25,
+ pageSize: 12,
+ currentPage: 1,
+ };
+ useVODStore.mockImplementation((selector) =>
+ selector(stateWithPagination)
+ );
+
+ render();
+
+ await waitFor(() => {
+ fireEvent.click(screen.getByText('Next'));
+ });
+
+ expect(mockSetPage).toHaveBeenCalledWith(2);
+ });
+
+ it('refetches content when filters change', async () => {
+ const { rerender } = render();
+
+ const updatedState = {
+ ...defaultStoreState,
+ filters: { type: 'movies', search: '', category: '' },
+ };
+ useVODStore.mockImplementation((selector) => selector(updatedState));
+
+ rerender();
+
+ await waitFor(() => {
+ expect(mockFetchContent).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('refetches content when page changes', async () => {
+ const { rerender } = render();
+
+ const updatedState = {
+ ...defaultStoreState,
+ currentPage: 2,
+ };
+ useVODStore.mockImplementation((selector) => selector(updatedState));
+
+ rerender();
+
+ await waitFor(() => {
+ expect(mockFetchContent).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('refetches content when page size changes', async () => {
+ const { rerender } = render();
+
+ const updatedState = {
+ ...defaultStoreState,
+ pageSize: 24,
+ };
+ useVODStore.mockImplementation((selector) => selector(updatedState));
+
+ rerender();
+
+ await waitFor(() => {
+ expect(mockFetchContent).toHaveBeenCalledTimes(2);
+ });
+ });
+});
diff --git a/frontend/src/pages/__tests__/guideUtils.test.js b/frontend/src/pages/__tests__/guideUtils.test.js
index 58a6d292..01bbe846 100644
--- a/frontend/src/pages/__tests__/guideUtils.test.js
+++ b/frontend/src/pages/__tests__/guideUtils.test.js
@@ -1,100 +1,1108 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
import dayjs from 'dayjs';
-import {
- PROGRAM_HEIGHT,
- EXPANDED_PROGRAM_HEIGHT,
- buildChannelIdMap,
- mapProgramsByChannel,
- computeRowHeights,
-} from '../guideUtils.js';
+import utc from 'dayjs/plugin/utc';
+import * as guideUtils from '../guideUtils';
+import * as dateTimeUtils from '../../utils/dateTimeUtils';
+import API from '../../api';
+
+dayjs.extend(utc);
+
+vi.mock('../../utils/dateTimeUtils', () => ({
+ convertToMs: vi.fn((time) => {
+ if (typeof time === 'number') return time;
+ return dayjs(time).valueOf();
+ }),
+ initializeTime: vi.fn((time) => {
+ if (typeof time === 'number') return dayjs(time);
+ return dayjs(time);
+ }),
+ startOfDay: vi.fn((time) => dayjs(time).startOf('day')),
+ isBefore: vi.fn((a, b) => dayjs(a).isBefore(dayjs(b))),
+ isAfter: vi.fn((a, b) => dayjs(a).isAfter(dayjs(b))),
+ isSame: vi.fn((a, b, unit) => dayjs(a).isSame(dayjs(b), unit)),
+ add: vi.fn((time, amount, unit) => dayjs(time).add(amount, unit)),
+ diff: vi.fn((a, b, unit) => dayjs(a).diff(dayjs(b), unit)),
+ format: vi.fn((time, formatStr) => dayjs(time).format(formatStr)),
+ getNow: vi.fn(() => dayjs()),
+ getNowMs: vi.fn(() => dayjs().valueOf()),
+ roundToNearest: vi.fn((time, minutes) => {
+ const m = dayjs(time).minute();
+ const rounded = Math.round(m / minutes) * minutes;
+ return dayjs(time).minute(rounded).second(0).millisecond(0);
+ }),
+}));
+
+vi.mock('../../api', () => ({
+ default: {
+ getGrid: vi.fn(),
+ createRecording: vi.fn(),
+ createSeriesRule: vi.fn(),
+ evaluateSeriesRules: vi.fn(),
+ deleteSeriesRule: vi.fn(),
+ listSeriesRules: vi.fn(),
+ },
+}));
describe('guideUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
describe('buildChannelIdMap', () => {
- it('maps tvg ids from epg records and falls back to channel uuid', () => {
+ it('should create map with channel UUIDs when no EPG data', () => {
const channels = [
- { id: 1, epg_data_id: 'epg-1', uuid: 'uuid-1' },
- { id: 2, epg_data_id: null, uuid: 'uuid-2' },
+ { id: 1, uuid: 'uuid-1', epg_data_id: null },
+ { id: 2, uuid: 'uuid-2', epg_data_id: null },
+ ];
+ const tvgsById = {};
+
+ const result = guideUtils.buildChannelIdMap(channels, tvgsById);
+
+ expect(result.get('uuid-1')).toEqual([1]);
+ expect(result.get('uuid-2')).toEqual([2]);
+ });
+
+ it('should use tvg_id from EPG data for regular sources', () => {
+ const channels = [
+ { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
];
const tvgsById = {
- 'epg-1': { tvg_id: 'alpha' },
+ 'epg-1': { tvg_id: 'tvg-123', epg_source: 'source-1' },
+ };
+ const epgs = {
+ 'source-1': { source_type: 'xmltv' },
};
- const map = buildChannelIdMap(channels, tvgsById);
+ const result = guideUtils.buildChannelIdMap(channels, tvgsById, epgs);
- expect(map.get('alpha')).toBe(1);
- expect(map.get('uuid-2')).toBe(2);
+ expect(result.get('tvg-123')).toEqual([1]);
+ });
+
+ it('should use channel UUID for dummy EPG sources', () => {
+ const channels = [
+ { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
+ ];
+ const tvgsById = {
+ 'epg-1': { tvg_id: 'tvg-123', epg_source: 'source-1' },
+ };
+ const epgs = {
+ 'source-1': { source_type: 'dummy' },
+ };
+
+ const result = guideUtils.buildChannelIdMap(channels, tvgsById, epgs);
+
+ expect(result.get('uuid-1')).toEqual([1]);
+ });
+
+ it('should group multiple channels with same tvg_id', () => {
+ const channels = [
+ { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
+ { id: 2, uuid: 'uuid-2', epg_data_id: 'epg-2' },
+ ];
+ const tvgsById = {
+ 'epg-1': { tvg_id: 'shared-tvg', epg_source: 'source-1' },
+ 'epg-2': { tvg_id: 'shared-tvg', epg_source: 'source-1' },
+ };
+ const epgs = {
+ 'source-1': { source_type: 'xmltv' },
+ };
+
+ const result = guideUtils.buildChannelIdMap(channels, tvgsById, epgs);
+
+ expect(result.get('shared-tvg')).toEqual([1, 2]);
+ });
+
+ it('should fall back to UUID when tvg_id is null', () => {
+ const channels = [
+ { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' },
+ ];
+ const tvgsById = {
+ 'epg-1': { tvg_id: null, epg_source: 'source-1' },
+ };
+ const epgs = {
+ 'source-1': { source_type: 'xmltv' },
+ };
+
+ const result = guideUtils.buildChannelIdMap(channels, tvgsById, epgs);
+
+ expect(result.get('uuid-1')).toEqual([1]);
});
});
describe('mapProgramsByChannel', () => {
- it('groups programs by channel and sorts them by start time', () => {
+ it('should return empty map when no programs', () => {
+ const channelIdByTvgId = new Map();
+
+ const result = guideUtils.mapProgramsByChannel([], channelIdByTvgId);
+
+ expect(result.size).toBe(0);
+ });
+
+ it('should return empty map when no channel mapping', () => {
+ const programs = [{ tvg_id: 'tvg-1' }];
+
+ const result = guideUtils.mapProgramsByChannel(programs, new Map());
+
+ expect(result.size).toBe(0);
+ });
+
+ it('should map programs to channels', () => {
+ const nowMs = 1000000;
+ dateTimeUtils.getNowMs.mockReturnValue(nowMs);
+
const programs = [
{
- id: 10,
- tvg_id: 'alpha',
- start_time: dayjs('2025-01-01T02:00:00Z').toISOString(),
- end_time: dayjs('2025-01-01T03:00:00Z').toISOString(),
- title: 'Late Show',
- },
- {
- id: 11,
- tvg_id: 'alpha',
- start_time: dayjs('2025-01-01T01:00:00Z').toISOString(),
- end_time: dayjs('2025-01-01T02:00:00Z').toISOString(),
- title: 'Evening News',
- },
- {
- id: 20,
- tvg_id: 'beta',
- start_time: dayjs('2025-01-01T00:00:00Z').toISOString(),
- end_time: dayjs('2025-01-01T01:00:00Z').toISOString(),
- title: 'Morning Show',
+ id: 1,
+ tvg_id: 'tvg-1',
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
},
];
+ const channelIdByTvgId = new Map([['tvg-1', [1]]]);
- const channelIdByTvgId = new Map([
- ['alpha', 1],
- ['beta', 2],
- ]);
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
- const map = mapProgramsByChannel(programs, channelIdByTvgId);
+ expect(result.get(1)).toHaveLength(1);
+ expect(result.get(1)[0]).toMatchObject({
+ id: 1,
+ tvg_id: 'tvg-1',
+ });
+ });
- expect(map.get(1)).toHaveLength(2);
- expect(map.get(1)?.map((item) => item.id)).toEqual([11, 10]);
- expect(map.get(2)).toHaveLength(1);
- expect(map.get(2)?.[0].startMs).toBeTypeOf('number');
- expect(map.get(2)?.[0].endMs).toBeTypeOf('number');
+ it('should precompute startMs and endMs', () => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000);
+ dateTimeUtils.convertToMs.mockImplementation((time) =>
+ typeof time === 'number' ? time : dayjs(time).valueOf()
+ );
+
+ const programs = [
+ {
+ id: 1,
+ tvg_id: 'tvg-1',
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ const channelIdByTvgId = new Map([['tvg-1', [1]]]);
+
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+
+ expect(result.get(1)[0]).toHaveProperty('startMs');
+ expect(result.get(1)[0]).toHaveProperty('endMs');
+ });
+
+ it('should mark program as live when now is between start and end', () => {
+ const startMs = 1000;
+ const endMs = 2000;
+ const nowMs = 1500;
+ dateTimeUtils.getNowMs.mockReturnValue(nowMs);
+
+ const programs = [
+ {
+ id: 1,
+ tvg_id: 'tvg-1',
+ startMs,
+ endMs,
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ const channelIdByTvgId = new Map([['tvg-1', [1]]]);
+
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+
+ expect(result.get(1)[0].isLive).toBe(true);
+ expect(result.get(1)[0].isPast).toBe(false);
+ });
+
+ it('should mark program as past when now is after end', () => {
+ const startMs = 1000;
+ const endMs = 2000;
+ const nowMs = 3000;
+ dateTimeUtils.getNowMs.mockReturnValue(nowMs);
+
+ const programs = [
+ {
+ id: 1,
+ tvg_id: 'tvg-1',
+ startMs,
+ endMs,
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ const channelIdByTvgId = new Map([['tvg-1', [1]]]);
+
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+
+ expect(result.get(1)[0].isLive).toBe(false);
+ expect(result.get(1)[0].isPast).toBe(true);
+ });
+
+ it('should add program to multiple channels with same tvg_id', () => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000);
+
+ const programs = [
+ {
+ id: 1,
+ tvg_id: 'tvg-1',
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]);
+
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+
+ expect(result.get(1)).toHaveLength(1);
+ expect(result.get(2)).toHaveLength(1);
+ expect(result.get(3)).toHaveLength(1);
+ });
+
+ it('should sort programs by start time', () => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000);
+
+ const programs = [
+ {
+ id: 2,
+ tvg_id: 'tvg-1',
+ startMs: 2000,
+ endMs: 3000,
+ start_time: '2024-01-15T11:00:00Z',
+ end_time: '2024-01-15T12:00:00Z',
+ },
+ {
+ id: 1,
+ tvg_id: 'tvg-1',
+ startMs: 1000,
+ endMs: 2000,
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ const channelIdByTvgId = new Map([['tvg-1', [1]]]);
+
+ const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId);
+
+ expect(result.get(1)[0].id).toBe(1);
+ expect(result.get(1)[1].id).toBe(2);
});
});
describe('computeRowHeights', () => {
- it('returns program heights with expanded rows when needed', () => {
- const filteredChannels = [
- { id: 1 },
- { id: 2 },
+ it('should return empty array when no channels', () => {
+ const result = guideUtils.computeRowHeights([], new Map(), null);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should return default height for all channels when none expanded', () => {
+ const channels = [{ id: 1 }, { id: 2 }];
+ const programsByChannelId = new Map();
+
+ const result = guideUtils.computeRowHeights(channels, programsByChannelId, null);
+
+ expect(result).toEqual([guideUtils.PROGRAM_HEIGHT, guideUtils.PROGRAM_HEIGHT]);
+ });
+
+ it('should return expanded height for channel with expanded program', () => {
+ const channels = [{ id: 1 }, { id: 2 }];
+ const programsByChannelId = new Map([
+ [1, [{ id: 'program-1' }]],
+ [2, [{ id: 'program-2' }]],
+ ]);
+
+ const result = guideUtils.computeRowHeights(channels, programsByChannelId, 'program-1');
+
+ expect(result).toEqual([guideUtils.EXPANDED_PROGRAM_HEIGHT, guideUtils.PROGRAM_HEIGHT]);
+ });
+
+ it('should use custom heights when provided', () => {
+ const channels = [{ id: 1 }];
+ const programsByChannelId = new Map([[1, [{ id: 'program-1' }]]]);
+ const customDefault = 100;
+ const customExpanded = 200;
+
+ const result = guideUtils.computeRowHeights(
+ channels,
+ programsByChannelId,
+ 'program-1',
+ customDefault,
+ customExpanded
+ );
+
+ expect(result).toEqual([customExpanded]);
+ });
+ });
+
+ describe('fetchPrograms', () => {
+ it('should fetch and transform programs', async () => {
+ const mockPrograms = [
+ {
+ id: 1,
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ },
+ ];
+ API.getGrid.mockResolvedValue(mockPrograms);
+ dateTimeUtils.convertToMs.mockReturnValue(1000);
+
+ const result = await guideUtils.fetchPrograms();
+
+ expect(API.getGrid).toHaveBeenCalledTimes(1);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toHaveProperty('startMs');
+ expect(result[0]).toHaveProperty('endMs');
+ });
+ });
+
+ describe('sortChannels', () => {
+ it('should sort channels by channel number', () => {
+ const channels = {
+ 1: { id: 1, channel_number: 3 },
+ 2: { id: 2, channel_number: 1 },
+ 3: { id: 3, channel_number: 2 },
+ };
+
+ const result = guideUtils.sortChannels(channels);
+
+ expect(result[0].channel_number).toBe(1);
+ expect(result[1].channel_number).toBe(2);
+ expect(result[2].channel_number).toBe(3);
+ });
+
+ it('should put channels without number at end', () => {
+ const channels = {
+ 1: { id: 1, channel_number: 2 },
+ 2: { id: 2, channel_number: null },
+ 3: { id: 3, channel_number: 1 },
+ };
+
+ const result = guideUtils.sortChannels(channels);
+
+ expect(result[0].channel_number).toBe(1);
+ expect(result[1].channel_number).toBe(2);
+ expect(result[2].channel_number).toBeNull();
+ });
+ });
+
+ describe('filterGuideChannels', () => {
+ it('should return all channels when no filters', () => {
+ const channels = [
+ { id: 1, name: 'Channel 1' },
+ { id: 2, name: 'Channel 2' },
];
- const programsByChannel = new Map([
- [1, [{ id: 10 }, { id: 11 }]],
- [2, [{ id: 20 }]],
- ]);
+ const result = guideUtils.filterGuideChannels(channels, '', 'all', 'all', {});
- const collapsed = computeRowHeights(
- filteredChannels,
- programsByChannel,
- null
- );
- expect(collapsed).toEqual([PROGRAM_HEIGHT, PROGRAM_HEIGHT]);
+ expect(result).toHaveLength(2);
+ });
- const expanded = computeRowHeights(
- filteredChannels,
- programsByChannel,
- 10
- );
- expect(expanded).toEqual([
- EXPANDED_PROGRAM_HEIGHT,
- PROGRAM_HEIGHT,
- ]);
+ it('should filter by search query', () => {
+ const channels = [
+ { id: 1, name: 'ESPN' },
+ { id: 2, name: 'CNN' },
+ ];
+
+ const result = guideUtils.filterGuideChannels(channels, 'espn', 'all', 'all', {});
+
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('ESPN');
+ });
+
+ it('should filter by channel group', () => {
+ const channels = [
+ { id: 1, name: 'Channel 1', channel_group_id: 1 },
+ { id: 2, name: 'Channel 2', channel_group_id: 2 },
+ ];
+
+ const result = guideUtils.filterGuideChannels(channels, '', '1', 'all', {});
+
+ expect(result).toHaveLength(1);
+ expect(result[0].channel_group_id).toBe(1);
+ });
+
+ it('should filter by profile with array of channels', () => {
+ const channels = [
+ { id: 1, name: 'Channel 1' },
+ { id: 2, name: 'Channel 2' },
+ ];
+ const profiles = {
+ profile1: {
+ channels: [
+ { id: 1, enabled: true },
+ { id: 2, enabled: false },
+ ],
+ },
+ };
+
+ const result = guideUtils.filterGuideChannels(channels, '', 'all', 'profile1', profiles);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe(1);
+ });
+
+ it('should filter by profile with Set of channels', () => {
+ const channels = [
+ { id: 1, name: 'Channel 1' },
+ { id: 2, name: 'Channel 2' },
+ ];
+ const profiles = {
+ profile1: {
+ channels: new Set([1]),
+ },
+ };
+
+ const result = guideUtils.filterGuideChannels(channels, '', 'all', 'profile1', profiles);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe(1);
+ });
+
+ it('should apply multiple filters together', () => {
+ const channels = [
+ { id: 1, name: 'ESPN', channel_group_id: 1 },
+ { id: 2, name: 'ESPN2', channel_group_id: 2 },
+ { id: 3, name: 'CNN', channel_group_id: 1 },
+ ];
+ const profiles = {
+ profile1: {
+ channels: [
+ { id: 1, enabled: true },
+ { id: 3, enabled: true },
+ ],
+ },
+ };
+
+ const result = guideUtils.filterGuideChannels(channels, 'espn', '1', 'profile1', profiles);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe(1);
+ });
+ });
+
+ describe('calculateEarliestProgramStart', () => {
+ it('should return default when no programs', () => {
+ const defaultStart = dayjs('2024-01-15T00:00:00Z');
+
+ const result = guideUtils.calculateEarliestProgramStart([], defaultStart);
+
+ expect(result).toBe(defaultStart);
+ });
+
+ it('should return earliest program start', () => {
+ dateTimeUtils.initializeTime.mockImplementation((time) => dayjs.utc(time));
+ dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)));
+
+ const programs = [
+ { start_time: '2024-01-15T12:00:00Z' },
+ { start_time: '2024-01-15T10:00:00Z' },
+ { start_time: '2024-01-15T14:00:00Z' },
+ ];
+ const defaultStart = dayjs.utc('2024-01-16T00:00:00Z');
+
+ const result = guideUtils.calculateEarliestProgramStart(programs, defaultStart);
+
+ expect(result.hour()).toBe(10);
+ });
+ });
+
+ describe('calculateLatestProgramEnd', () => {
+ it('should return default when no programs', () => {
+ const defaultEnd = dayjs('2024-01-16T00:00:00Z');
+
+ const result = guideUtils.calculateLatestProgramEnd([], defaultEnd);
+
+ expect(result).toBe(defaultEnd);
+ });
+
+ it('should return latest program end', () => {
+ dateTimeUtils.initializeTime.mockImplementation((time) => dayjs.utc(time));
+ dateTimeUtils.isAfter.mockImplementation((a, b) => dayjs(a).isAfter(dayjs(b)));
+
+ const programs = [
+ { end_time: '2024-01-15T12:00:00Z' },
+ { end_time: '2024-01-15T18:00:00Z' },
+ { end_time: '2024-01-15T14:00:00Z' },
+ ];
+ const defaultEnd = dayjs.utc('2024-01-15T00:00:00Z');
+
+ const result = guideUtils.calculateLatestProgramEnd(programs, defaultEnd);
+
+ expect(result.hour()).toBe(18);
+ });
+ });
+
+ describe('calculateStart', () => {
+ it('should return earliest when before default', () => {
+ const earliest = dayjs('2024-01-15T08:00:00Z');
+ const defaultStart = dayjs('2024-01-15T10:00:00Z');
+ dateTimeUtils.isBefore.mockReturnValue(true);
+
+ const result = guideUtils.calculateStart(earliest, defaultStart);
+
+ expect(result).toBe(earliest);
+ });
+
+ it('should return default when earliest is after', () => {
+ const earliest = dayjs('2024-01-15T12:00:00Z');
+ const defaultStart = dayjs('2024-01-15T10:00:00Z');
+ dateTimeUtils.isBefore.mockReturnValue(false);
+
+ const result = guideUtils.calculateStart(earliest, defaultStart);
+
+ expect(result).toBe(defaultStart);
+ });
+ });
+
+ describe('calculateEnd', () => {
+ it('should return latest when after default', () => {
+ const latest = dayjs('2024-01-16T02:00:00Z');
+ const defaultEnd = dayjs('2024-01-16T00:00:00Z');
+ dateTimeUtils.isAfter.mockReturnValue(true);
+
+ const result = guideUtils.calculateEnd(latest, defaultEnd);
+
+ expect(result).toBe(latest);
+ });
+
+ it('should return default when latest is before', () => {
+ const latest = dayjs('2024-01-15T22:00:00Z');
+ const defaultEnd = dayjs('2024-01-16T00:00:00Z');
+ dateTimeUtils.isAfter.mockReturnValue(false);
+
+ const result = guideUtils.calculateEnd(latest, defaultEnd);
+
+ expect(result).toBe(defaultEnd);
+ });
+ });
+
+ describe('mapChannelsById', () => {
+ it('should create map of channels by id', () => {
+ const channels = [
+ { id: 1, name: 'Channel 1' },
+ { id: 2, name: 'Channel 2' },
+ ];
+
+ const result = guideUtils.mapChannelsById(channels);
+
+ expect(result.get(1).name).toBe('Channel 1');
+ expect(result.get(2).name).toBe('Channel 2');
+ });
+ });
+
+ describe('mapRecordingsByProgramId', () => {
+ it('should return empty map for null recordings', () => {
+ const result = guideUtils.mapRecordingsByProgramId(null);
+
+ expect(result.size).toBe(0);
+ });
+
+ it('should map recordings by program id', () => {
+ const recordings = [
+ {
+ id: 1,
+ custom_properties: {
+ program: { id: 'program-1' },
+ },
+ },
+ {
+ id: 2,
+ custom_properties: {
+ program: { id: 'program-2' },
+ },
+ },
+ ];
+
+ const result = guideUtils.mapRecordingsByProgramId(recordings);
+
+ expect(result.get('program-1').id).toBe(1);
+ expect(result.get('program-2').id).toBe(2);
+ });
+
+ it('should skip recordings without program id', () => {
+ const recordings = [
+ {
+ id: 1,
+ custom_properties: {},
+ },
+ ];
+
+ const result = guideUtils.mapRecordingsByProgramId(recordings);
+
+ expect(result.size).toBe(0);
+ });
+ });
+
+ describe('formatTime', () => {
+ it('should return "Today" for today', () => {
+ const today = dayjs();
+ dateTimeUtils.getNow.mockReturnValue(today);
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.isSame.mockReturnValueOnce(true);
+
+ const result = guideUtils.formatTime(today, 'MM/DD');
+
+ expect(result).toBe('Today');
+ });
+
+ it('should return "Tomorrow" for tomorrow', () => {
+ const today = dayjs();
+ const tomorrow = today.add(1, 'day');
+ dateTimeUtils.getNow.mockReturnValue(today);
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.isSame.mockReturnValueOnce(false).mockReturnValueOnce(true);
+
+ const result = guideUtils.formatTime(tomorrow, 'MM/DD');
+
+ expect(result).toBe('Tomorrow');
+ });
+
+ it('should return day name within a week', () => {
+ const today = dayjs();
+ const future = today.add(3, 'day');
+ dateTimeUtils.getNow.mockReturnValue(today);
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.isSame.mockReturnValue(false);
+ dateTimeUtils.isBefore.mockReturnValue(true);
+ dateTimeUtils.format.mockReturnValue('Wednesday');
+
+ const result = guideUtils.formatTime(future, 'MM/DD');
+
+ expect(result).toBe('Wednesday');
+ });
+
+ it('should return formatted date beyond a week', () => {
+ const today = dayjs();
+ const future = today.add(10, 'day');
+ dateTimeUtils.getNow.mockReturnValue(today);
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.isSame.mockReturnValue(false);
+ dateTimeUtils.isBefore.mockReturnValue(false);
+ dateTimeUtils.format.mockReturnValue('01/25');
+
+ const result = guideUtils.formatTime(future, 'MM/DD');
+
+ expect(result).toBe('01/25');
+ });
+ });
+
+ describe('calculateHourTimeline', () => {
+ it('should generate hours between start and end', () => {
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const end = dayjs('2024-01-15T13:00:00Z');
+ dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.isSame.mockReturnValue(true);
+
+ const formatDayLabel = vi.fn((time) => 'Today');
+ const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel);
+
+ expect(result).toHaveLength(3);
+ expect(formatDayLabel).toHaveBeenCalledTimes(3);
+ });
+
+ it('should mark new day transitions', () => {
+ const start = dayjs('2024-01-15T23:00:00Z');
+ const end = dayjs('2024-01-16T02:00:00Z');
+ dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)));
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day'));
+ dateTimeUtils.isSame.mockImplementation((a, b, unit) => dayjs(a).isSame(dayjs(b), unit));
+
+ const formatDayLabel = vi.fn((time) => 'Day');
+ const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel);
+
+ expect(result[0].isNewDay).toBe(true);
+ });
+ });
+
+ describe('calculateNowPosition', () => {
+ it('should return -1 when now is before start', () => {
+ const now = dayjs('2024-01-15T09:00:00Z');
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const end = dayjs('2024-01-15T18:00:00Z');
+ dateTimeUtils.isBefore.mockReturnValue(true);
+
+ const result = guideUtils.calculateNowPosition(now, start, end);
+
+ expect(result).toBe(-1);
+ });
+
+ it('should return -1 when now is after end', () => {
+ const now = dayjs('2024-01-15T19:00:00Z');
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const end = dayjs('2024-01-15T18:00:00Z');
+ dateTimeUtils.isBefore.mockReturnValue(false);
+ dateTimeUtils.isAfter.mockReturnValue(true);
+
+ const result = guideUtils.calculateNowPosition(now, start, end);
+
+ expect(result).toBe(-1);
+ });
+
+ it('should calculate position when now is between start and end', () => {
+ const now = dayjs('2024-01-15T11:00:00Z');
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const end = dayjs('2024-01-15T18:00:00Z');
+ dateTimeUtils.isBefore.mockReturnValue(false);
+ dateTimeUtils.isAfter.mockReturnValue(false);
+ dateTimeUtils.diff.mockReturnValue(60);
+
+ const result = guideUtils.calculateNowPosition(now, start, end);
+
+ expect(result).toBeGreaterThan(0);
+ });
+ });
+
+ describe('calculateScrollPosition', () => {
+ it('should calculate scroll position for current time', () => {
+ const now = dayjs('2024-01-15T11:00:00Z');
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const rounded = dayjs('2024-01-15T11:00:00Z');
+ dateTimeUtils.roundToNearest.mockReturnValue(rounded);
+ dateTimeUtils.diff.mockReturnValue(60);
+
+ const result = guideUtils.calculateScrollPosition(now, start);
+
+ expect(result).toBeGreaterThanOrEqual(0);
+ });
+
+ it('should return 0 when calculated position is negative', () => {
+ const now = dayjs('2024-01-15T10:00:00Z');
+ const start = dayjs('2024-01-15T10:00:00Z');
+ const rounded = dayjs('2024-01-15T10:00:00Z');
+ dateTimeUtils.roundToNearest.mockReturnValue(rounded);
+ dateTimeUtils.diff.mockReturnValue(0);
+
+ const result = guideUtils.calculateScrollPosition(now, start);
+
+ expect(result).toBe(0);
+ });
+ });
+
+ describe('matchChannelByTvgId', () => {
+ it('should return null when no matching channel ids', () => {
+ const channelIdByTvgId = new Map();
+ const channelById = new Map();
+
+ const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
+
+ expect(result).toBeNull();
+ });
+
+ it('should return first matching channel', () => {
+ const channel = { id: 1, name: 'Channel 1' };
+ const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]);
+ const channelById = new Map([[1, channel]]);
+
+ const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
+
+ expect(result).toBe(channel);
+ });
+
+ it('should return null when channel not in channelById map', () => {
+ const channelIdByTvgId = new Map([['tvg-1', [999]]]);
+ const channelById = new Map();
+
+ const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1');
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('fetchRules', () => {
+ it('should fetch series rules from API', async () => {
+ const mockRules = [{ id: 1, tvg_id: 'tvg-1' }];
+ API.listSeriesRules.mockResolvedValue(mockRules);
+
+ const result = await guideUtils.fetchRules();
+
+ expect(API.listSeriesRules).toHaveBeenCalledTimes(1);
+ expect(result).toBe(mockRules);
+ });
+ });
+
+ describe('getRuleByProgram', () => {
+ it('should return null when no rules', () => {
+ const program = { tvg_id: 'tvg-1', title: 'Show' };
+
+ const result = guideUtils.getRuleByProgram(null, program);
+
+ expect(result).toBeUndefined();
+ });
+
+ it('should find rule by tvg_id without title', () => {
+ const rules = [{ tvg_id: 'tvg-1', title: null }];
+ const program = { tvg_id: 'tvg-1', title: 'Show' };
+
+ const result = guideUtils.getRuleByProgram(rules, program);
+
+ expect(result).toBe(rules[0]);
+ });
+
+ it('should find rule by tvg_id and title', () => {
+ const rules = [
+ { tvg_id: 'tvg-1', title: 'Show A' },
+ { tvg_id: 'tvg-1', title: 'Show B' },
+ ];
+ const program = { tvg_id: 'tvg-1', title: 'Show B' };
+
+ const result = guideUtils.getRuleByProgram(rules, program);
+
+ expect(result).toBe(rules[1]);
+ });
+
+ it('should handle string comparison for tvg_id', () => {
+ const rules = [{ tvg_id: 123, title: null }];
+ const program = { tvg_id: '123', title: 'Show' };
+
+ const result = guideUtils.getRuleByProgram(rules, program);
+
+ expect(result).toBe(rules[0]);
+ });
+ });
+
+ describe('createRecording', () => {
+ it('should create recording via API', async () => {
+ const channel = { id: 1 };
+ const program = {
+ start_time: '2024-01-15T10:00:00Z',
+ end_time: '2024-01-15T11:00:00Z',
+ };
+
+ await guideUtils.createRecording(channel, program);
+
+ expect(API.createRecording).toHaveBeenCalledWith({
+ channel: '1',
+ start_time: program.start_time,
+ end_time: program.end_time,
+ custom_properties: { program },
+ });
+ });
+ });
+
+ describe('createSeriesRule', () => {
+ it('should create series rule via API', async () => {
+ const program = { tvg_id: 'tvg-1', title: 'Show' };
+ const mode = 'all';
+
+ await guideUtils.createSeriesRule(program, mode);
+
+ expect(API.createSeriesRule).toHaveBeenCalledWith({
+ tvg_id: program.tvg_id,
+ mode,
+ title: program.title,
+ });
+ });
+ });
+
+ describe('evaluateSeriesRule', () => {
+ it('should evaluate series rule via API', async () => {
+ const program = { tvg_id: 'tvg-1' };
+
+ await guideUtils.evaluateSeriesRule(program);
+
+ expect(API.evaluateSeriesRules).toHaveBeenCalledWith(program.tvg_id);
+ });
+ });
+
+ describe('calculateLeftScrollPosition', () => {
+ it('should calculate left position using startMs', () => {
+ const program = {
+ startMs: dayjs.utc('2024-01-15T11:00:00Z').valueOf(),
+ };
+ const start = dayjs.utc('2024-01-15T10:00:00Z').valueOf();
+ dateTimeUtils.convertToMs.mockImplementation((time) => {
+ if (typeof time === 'number') return time;
+ return dayjs.utc(time).valueOf();
+ });
+
+ const result = guideUtils.calculateLeftScrollPosition(program, start);
+
+ expect(result).toBeGreaterThanOrEqual(0);
+ });
+
+ it('should calculate left position from start_time when no startMs', () => {
+ const program = {
+ start_time: '2024-01-15T10:30:00Z',
+ };
+ const start = '2024-01-15T10:00:00Z';
+ dateTimeUtils.convertToMs.mockImplementation((time) => dayjs(time).valueOf());
+
+ const result = guideUtils.calculateLeftScrollPosition(program, start);
+
+ expect(result).toBeGreaterThanOrEqual(0);
+ });
+ });
+
+ describe('calculateDesiredScrollPosition', () => {
+ it('should subtract 20 from left position', () => {
+ const result = guideUtils.calculateDesiredScrollPosition(100);
+
+ expect(result).toBe(80);
+ });
+
+ it('should return 0 when result would be negative', () => {
+ const result = guideUtils.calculateDesiredScrollPosition(10);
+
+ expect(result).toBe(0);
+ });
+ });
+
+ describe('calculateScrollPositionByTimeClick', () => {
+ it('should calculate scroll position from time click', () => {
+ const event = {
+ currentTarget: {
+ getBoundingClientRect: () => ({ left: 100, width: 450 }),
+ },
+ clientX: 325,
+ };
+ const clickedTime = dayjs('2024-01-15T10:00:00Z');
+ const start = dayjs('2024-01-15T09:00:00Z');
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.diff.mockReturnValue(60);
+
+ const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
+
+ expect(result).toBeGreaterThanOrEqual(0);
+ });
+
+ it('should snap to 15-minute increments', () => {
+ const event = {
+ currentTarget: {
+ getBoundingClientRect: () => ({ left: 0, width: 450 }),
+ },
+ clientX: 112.5,
+ };
+ const clickedTime = dayjs('2024-01-15T10:00:00Z');
+ const start = dayjs('2024-01-15T09:00:00Z');
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.diff.mockReturnValue(75);
+
+ guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
+
+ expect(dateTimeUtils.diff).toHaveBeenCalled();
+ });
+
+ it('should handle click at end of hour', () => {
+ const event = {
+ currentTarget: {
+ getBoundingClientRect: () => ({ left: 0, width: 450 }),
+ },
+ clientX: 450,
+ };
+ const clickedTime = dayjs('2024-01-15T10:00:00Z');
+ const start = dayjs('2024-01-15T09:00:00Z');
+ dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit));
+ dateTimeUtils.diff.mockReturnValue(120);
+
+ const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start);
+
+ expect(dateTimeUtils.add).toHaveBeenCalledWith(expect.anything(), 1, 'hour');
+ });
+ });
+
+ describe('getGroupOptions', () => {
+ it('should return only "All" when no channel groups', () => {
+ const result = guideUtils.getGroupOptions(null, []);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].value).toBe('all');
+ });
+
+ it('should include groups used by channels', () => {
+ const channelGroups = {
+ 1: { id: 1, name: 'Sports' },
+ 2: { id: 2, name: 'News' },
+ };
+ const channels = [
+ { id: 1, channel_group_id: 1 },
+ { id: 2, channel_group_id: 1 },
+ ];
+
+ const result = guideUtils.getGroupOptions(channelGroups, channels);
+
+ expect(result).toHaveLength(2);
+ expect(result[1].label).toBe('Sports');
+ });
+
+ it('should exclude groups not used by any channel', () => {
+ const channelGroups = {
+ 1: { id: 1, name: 'Sports' },
+ 2: { id: 2, name: 'News' },
+ };
+ const channels = [
+ { id: 1, channel_group_id: 1 },
+ ];
+
+ const result = guideUtils.getGroupOptions(channelGroups, channels);
+
+ expect(result).toHaveLength(2);
+ expect(result[1].label).toBe('Sports');
+ });
+
+ it('should sort groups alphabetically', () => {
+ const channelGroups = {
+ 1: { id: 1, name: 'Z Group' },
+ 2: { id: 2, name: 'A Group' },
+ 3: { id: 3, name: 'M Group' },
+ };
+ const channels = [
+ { id: 1, channel_group_id: 1 },
+ { id: 2, channel_group_id: 2 },
+ { id: 3, channel_group_id: 3 },
+ ];
+
+ const result = guideUtils.getGroupOptions(channelGroups, channels);
+
+ expect(result[1].label).toBe('A Group');
+ expect(result[2].label).toBe('M Group');
+ expect(result[3].label).toBe('Z Group');
+ });
+ });
+
+ describe('getProfileOptions', () => {
+ it('should return only "All" when no profiles', () => {
+ const result = guideUtils.getProfileOptions(null);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].value).toBe('all');
+ });
+
+ it('should include all profiles except id 0', () => {
+ const profiles = {
+ 0: { id: '0', name: 'All' },
+ 1: { id: '1', name: 'Profile 1' },
+ 2: { id: '2', name: 'Profile 2' },
+ };
+
+ const result = guideUtils.getProfileOptions(profiles);
+
+ expect(result).toHaveLength(3);
+ expect(result[1].label).toBe('Profile 1');
+ expect(result[2].label).toBe('Profile 2');
+ });
+ });
+
+ describe('deleteSeriesRuleByTvgId', () => {
+ it('should delete series rule via API', async () => {
+ await guideUtils.deleteSeriesRuleByTvgId('tvg-1');
+
+ expect(API.deleteSeriesRule).toHaveBeenCalledWith('tvg-1');
+ });
+ });
+
+ describe('evaluateSeriesRulesByTvgId', () => {
+ it('should evaluate series rules via API', async () => {
+ await guideUtils.evaluateSeriesRulesByTvgId('tvg-1');
+
+ expect(API.evaluateSeriesRules).toHaveBeenCalledWith('tvg-1');
});
});
});
diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js
new file mode 100644
index 00000000..0ce667bc
--- /dev/null
+++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js
@@ -0,0 +1,472 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import dayjs from 'dayjs';
+import utc from 'dayjs/plugin/utc';
+import timezone from 'dayjs/plugin/timezone';
+import * as dateTimeUtils from '../dateTimeUtils';
+import useSettingsStore from '../../store/settings';
+import useLocalStorage from '../../hooks/useLocalStorage';
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+vi.mock('../../store/settings');
+vi.mock('../../hooks/useLocalStorage');
+
+describe('dateTimeUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('convertToMs', () => {
+ it('should convert date to milliseconds', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.convertToMs(date);
+ expect(result).toBe(dayjs(date).valueOf());
+ });
+
+ it('should handle Date objects', () => {
+ const date = new Date('2024-01-15T10:30:00Z');
+ const result = dateTimeUtils.convertToMs(date);
+ expect(result).toBe(dayjs(date).valueOf());
+ });
+ });
+
+ describe('convertToSec', () => {
+ it('should convert date to unix timestamp', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.convertToSec(date);
+ expect(result).toBe(dayjs(date).unix());
+ });
+
+ it('should handle Date objects', () => {
+ const date = new Date('2024-01-15T10:30:00Z');
+ const result = dateTimeUtils.convertToSec(date);
+ expect(result).toBe(dayjs(date).unix());
+ });
+ });
+
+ describe('initializeTime', () => {
+ it('should create dayjs object from date string', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.initializeTime(date);
+ expect(result.format()).toBe(dayjs(date).format());
+ });
+
+ it('should handle Date objects', () => {
+ const date = new Date('2024-01-15T10:30:00Z');
+ const result = dateTimeUtils.initializeTime(date);
+ expect(result.format()).toBe(dayjs(date).format());
+ });
+ });
+
+ describe('startOfDay', () => {
+ it('should return start of day', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.startOfDay(date);
+ expect(result.hour()).toBe(0);
+ expect(result.minute()).toBe(0);
+ expect(result.second()).toBe(0);
+ });
+ });
+
+ describe('isBefore', () => {
+ it('should return true when first date is before second', () => {
+ const date1 = '2024-01-15T10:00:00Z';
+ const date2 = '2024-01-15T11:00:00Z';
+ expect(dateTimeUtils.isBefore(date1, date2)).toBe(true);
+ });
+
+ it('should return false when first date is after second', () => {
+ const date1 = '2024-01-15T11:00:00Z';
+ const date2 = '2024-01-15T10:00:00Z';
+ expect(dateTimeUtils.isBefore(date1, date2)).toBe(false);
+ });
+ });
+
+ describe('isAfter', () => {
+ it('should return true when first date is after second', () => {
+ const date1 = '2024-01-15T11:00:00Z';
+ const date2 = '2024-01-15T10:00:00Z';
+ expect(dateTimeUtils.isAfter(date1, date2)).toBe(true);
+ });
+
+ it('should return false when first date is before second', () => {
+ const date1 = '2024-01-15T10:00:00Z';
+ const date2 = '2024-01-15T11:00:00Z';
+ expect(dateTimeUtils.isAfter(date1, date2)).toBe(false);
+ });
+ });
+
+ describe('isSame', () => {
+ it('should return true when dates are same day', () => {
+ const date1 = '2024-01-15T10:00:00Z';
+ const date2 = '2024-01-15T11:00:00Z';
+ expect(dateTimeUtils.isSame(date1, date2)).toBe(true);
+ });
+
+ it('should return false when dates are different days', () => {
+ const date1 = '2024-01-15T10:00:00Z';
+ const date2 = '2024-01-16T10:00:00Z';
+ expect(dateTimeUtils.isSame(date1, date2)).toBe(false);
+ });
+
+ it('should accept unit parameter', () => {
+ const date1 = '2024-01-15T10:00:00Z';
+ const date2 = '2024-01-15T10:30:00Z';
+ expect(dateTimeUtils.isSame(date1, date2, 'hour')).toBe(true);
+ expect(dateTimeUtils.isSame(date1, date2, 'minute')).toBe(false);
+ });
+ });
+
+ describe('add', () => {
+ it('should add time to date', () => {
+ const date = dayjs.utc('2024-01-15T10:00:00Z');
+ const result = dateTimeUtils.add(date, 1, 'hour');
+ expect(result.hour()).toBe(11);
+ });
+
+ it('should handle different units', () => {
+ const date = '2024-01-15T10:00:00Z';
+ const dayResult = dateTimeUtils.add(date, 1, 'day');
+ expect(dayResult.date()).toBe(16);
+
+ const monthResult = dateTimeUtils.add(date, 1, 'month');
+ expect(monthResult.month()).toBe(1);
+ });
+ });
+
+ describe('subtract', () => {
+ it('should subtract time from date', () => {
+ const date = dayjs.utc('2024-01-15T10:00:00Z');
+ const result = dateTimeUtils.subtract(date, 1, 'hour');
+ expect(result.hour()).toBe(9);
+ });
+
+ it('should handle different units', () => {
+ const date = '2024-01-15T10:00:00Z';
+ const dayResult = dateTimeUtils.subtract(date, 1, 'day');
+ expect(dayResult.date()).toBe(14);
+ });
+ });
+
+ describe('diff', () => {
+ it('should calculate difference in milliseconds by default', () => {
+ const date1 = '2024-01-15T11:00:00Z';
+ const date2 = '2024-01-15T10:00:00Z';
+ const result = dateTimeUtils.diff(date1, date2);
+ expect(result).toBe(3600000);
+ });
+
+ it('should calculate difference in specified unit', () => {
+ const date1 = '2024-01-15T11:00:00Z';
+ const date2 = '2024-01-15T10:00:00Z';
+ expect(dateTimeUtils.diff(date1, date2, 'hour')).toBe(1);
+ expect(dateTimeUtils.diff(date1, date2, 'minute')).toBe(60);
+ });
+ });
+
+ describe('format', () => {
+ it('should format date with given format string', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.format(date, 'YYYY-MM-DD');
+ expect(result).toMatch(/2024-01-15/);
+ });
+
+ it('should handle time formatting', () => {
+ const date = '2024-01-15T10:30:00Z';
+ const result = dateTimeUtils.format(date, 'HH:mm');
+ expect(result).toMatch(/\d{2}:\d{2}/);
+ });
+ });
+
+ describe('getNow', () => {
+ it('should return current time as dayjs object', () => {
+ const result = dateTimeUtils.getNow();
+ expect(result.isValid()).toBe(true);
+ });
+ });
+
+ describe('toFriendlyDuration', () => {
+ it('should convert duration to human readable format', () => {
+ const result = dateTimeUtils.toFriendlyDuration(60, 'minutes');
+ expect(result).toBe('an hour');
+ });
+
+ it('should handle different units', () => {
+ const result = dateTimeUtils.toFriendlyDuration(2, 'hours');
+ expect(result).toBe('2 hours');
+ });
+ });
+
+ describe('fromNow', () => {
+ it('should return relative time from now', () => {
+ const pastDate = dayjs().subtract(1, 'hour').toISOString();
+ const result = dateTimeUtils.fromNow(pastDate);
+ expect(result).toMatch(/ago/);
+ });
+ });
+
+ describe('getNowMs', () => {
+ it('should return current time in milliseconds', () => {
+ const result = dateTimeUtils.getNowMs();
+ expect(typeof result).toBe('number');
+ expect(result).toBeGreaterThan(0);
+ });
+ });
+
+ describe('roundToNearest', () => {
+ it('should round to nearest 15 minutes', () => {
+ const date = dayjs('2024-01-15T10:17:00Z');
+ const result = dateTimeUtils.roundToNearest(date, 15);
+ expect(result.minute()).toBe(15);
+ });
+
+ it('should round up when past halfway point', () => {
+ const date = dayjs('2024-01-15T10:23:00Z');
+ const result = dateTimeUtils.roundToNearest(date, 15);
+ expect(result.minute()).toBe(30);
+ });
+
+ it('should handle rounding to next hour', () => {
+ const date = dayjs.utc('2024-01-15T10:53:00Z');
+ const result = dateTimeUtils.roundToNearest(date, 15);
+ expect(result.hour()).toBe(11);
+ expect(result.minute()).toBe(0);
+ });
+
+ it('should handle different minute intervals', () => {
+ const date = dayjs('2024-01-15T10:20:00Z');
+ const result = dateTimeUtils.roundToNearest(date, 30);
+ expect(result.minute()).toBe(30);
+ });
+ });
+
+ describe('useUserTimeZone', () => {
+ it('should return time zone from local storage', () => {
+ useLocalStorage.mockReturnValue(['America/New_York', vi.fn()]);
+ useSettingsStore.mockReturnValue({});
+
+ const { result } = renderHook(() => dateTimeUtils.useUserTimeZone());
+
+ expect(result.current).toBe('America/New_York');
+ });
+
+ it('should update time zone from settings', () => {
+ const setTimeZone = vi.fn();
+ useLocalStorage.mockReturnValue(['America/New_York', setTimeZone]);
+ useSettingsStore.mockReturnValue({
+ 'system-time-zone': { value: 'America/Los_Angeles' }
+ });
+
+ renderHook(() => dateTimeUtils.useUserTimeZone());
+
+ expect(setTimeZone).toHaveBeenCalledWith('America/Los_Angeles');
+ });
+ });
+
+ describe('useTimeHelpers', () => {
+ beforeEach(() => {
+ useLocalStorage.mockReturnValue(['America/New_York', vi.fn()]);
+ useSettingsStore.mockReturnValue({});
+ });
+
+ it('should return time zone, toUserTime, and userNow', () => {
+ const { result } = renderHook(() => dateTimeUtils.useTimeHelpers());
+
+ expect(result.current).toHaveProperty('timeZone');
+ expect(result.current).toHaveProperty('toUserTime');
+ expect(result.current).toHaveProperty('userNow');
+ });
+
+ it('should convert value to user time zone', () => {
+ const { result } = renderHook(() => dateTimeUtils.useTimeHelpers());
+ const date = '2024-01-15T10:00:00Z';
+
+ const converted = result.current.toUserTime(date);
+
+ expect(converted.isValid()).toBe(true);
+ });
+
+ it('should return null for null value', () => {
+ const { result } = renderHook(() => dateTimeUtils.useTimeHelpers());
+
+ const converted = result.current.toUserTime(null);
+
+ expect(converted.isValid()).toBe(false);
+ });
+
+ it('should handle timezone conversion errors', () => {
+ const { result } = renderHook(() => dateTimeUtils.useTimeHelpers());
+ const date = '2024-01-15T10:00:00Z';
+
+ const converted = result.current.toUserTime(date);
+
+ expect(converted.isValid()).toBe(true);
+ });
+
+ it('should return current time in user timezone', () => {
+ const { result } = renderHook(() => dateTimeUtils.useTimeHelpers());
+
+ const now = result.current.userNow();
+
+ expect(now.isValid()).toBe(true);
+ });
+ });
+
+ describe('RECURRING_DAY_OPTIONS', () => {
+ it('should have 7 day options', () => {
+ expect(dateTimeUtils.RECURRING_DAY_OPTIONS).toHaveLength(7);
+ });
+
+ it('should start with Sunday', () => {
+ expect(dateTimeUtils.RECURRING_DAY_OPTIONS[0]).toEqual({ value: 6, label: 'Sun' });
+ });
+
+ it('should include all weekdays', () => {
+ const labels = dateTimeUtils.RECURRING_DAY_OPTIONS.map(opt => opt.label);
+ expect(labels).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']);
+ });
+ });
+
+ describe('useDateTimeFormat', () => {
+ it('should return 12h format and mdy date format by default', () => {
+ useLocalStorage.mockReturnValueOnce(['12h', vi.fn()]).mockReturnValueOnce(['mdy', vi.fn()]);
+
+ const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
+
+ expect(result.current).toEqual(['h:mma', 'MMM D']);
+ });
+
+ it('should return 24h format when set', () => {
+ useLocalStorage.mockReturnValueOnce(['24h', vi.fn()]).mockReturnValueOnce(['mdy', vi.fn()]);
+
+ const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
+
+ expect(result.current[0]).toBe('HH:mm');
+ });
+
+ it('should return dmy date format when set', () => {
+ useLocalStorage.mockReturnValueOnce(['12h', vi.fn()]).mockReturnValueOnce(['dmy', vi.fn()]);
+
+ const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat());
+
+ expect(result.current[1]).toBe('D MMM');
+ });
+ });
+
+ describe('toTimeString', () => {
+ it('should return 00:00 for null value', () => {
+ expect(dateTimeUtils.toTimeString(null)).toBe('00:00');
+ });
+
+ it('should parse HH:mm format', () => {
+ expect(dateTimeUtils.toTimeString('14:30')).toBe('14:30');
+ });
+
+ it('should parse HH:mm:ss format', () => {
+ const result = dateTimeUtils.toTimeString('14:30:45');
+ expect(result).toMatch(/14:30/);
+ });
+
+ it('should return original string for unparseable format', () => {
+ expect(dateTimeUtils.toTimeString('2:30 PM')).toBe('2:30 PM');
+ });
+
+ it('should return original string for invalid format', () => {
+ expect(dateTimeUtils.toTimeString('invalid')).toBe('invalid');
+ });
+
+ it('should handle Date objects', () => {
+ const date = new Date('2024-01-15T14:30:00Z');
+ const result = dateTimeUtils.toTimeString(date);
+ expect(result).toMatch(/\d{2}:\d{2}/);
+ });
+
+ it('should return 00:00 for invalid Date', () => {
+ expect(dateTimeUtils.toTimeString(new Date('invalid'))).toBe('00:00');
+ });
+ });
+
+ describe('parseDate', () => {
+ it('should return null for null value', () => {
+ expect(dateTimeUtils.parseDate(null)).toBeNull();
+ });
+
+ it('should parse YYYY-MM-DD format', () => {
+ const result = dateTimeUtils.parseDate('2024-01-15');
+ expect(result).toBeInstanceOf(Date);
+ expect(result?.getFullYear()).toBe(2024);
+ });
+
+ it('should parse ISO 8601 format', () => {
+ const result = dateTimeUtils.parseDate('2024-01-15T10:30:00Z');
+ expect(result).toBeInstanceOf(Date);
+ });
+
+ it('should return null for invalid date', () => {
+ expect(dateTimeUtils.parseDate('invalid')).toBeNull();
+ });
+ });
+
+ describe('buildTimeZoneOptions', () => {
+ it('should return array of timezone options', () => {
+ const result = dateTimeUtils.buildTimeZoneOptions();
+ expect(Array.isArray(result)).toBe(true);
+ expect(result.length).toBeGreaterThan(0);
+ });
+
+ it('should format timezone with offset', () => {
+ const result = dateTimeUtils.buildTimeZoneOptions();
+ expect(result[0]).toHaveProperty('value');
+ expect(result[0]).toHaveProperty('label');
+ expect(result[0].label).toMatch(/UTC[+-]\d{2}:\d{2}/);
+ });
+
+ it('should sort by offset then name', () => {
+ const result = dateTimeUtils.buildTimeZoneOptions();
+ for (let i = 1; i < result.length; i++) {
+ expect(result[i].numericOffset).toBeGreaterThanOrEqual(result[i - 1].numericOffset);
+ }
+ });
+
+ it('should include DST information when applicable', () => {
+ const result = dateTimeUtils.buildTimeZoneOptions();
+ const dstZone = result.find(opt => opt.label.includes('DST range'));
+ expect(dstZone).toBeDefined();
+ });
+
+ it('should add preferred zone if not in list', () => {
+ const preferredZone = 'Custom/Zone';
+ const result = dateTimeUtils.buildTimeZoneOptions(preferredZone);
+ const found = result.find(opt => opt.value === preferredZone);
+ expect(found).toBeDefined();
+ });
+
+ it('should not duplicate existing zones', () => {
+ const result = dateTimeUtils.buildTimeZoneOptions('UTC');
+ const utcOptions = result.filter(opt => opt.value === 'UTC');
+ expect(utcOptions).toHaveLength(1);
+ });
+ });
+
+ describe('getDefaultTimeZone', () => {
+ it('should return system timezone', () => {
+ const result = dateTimeUtils.getDefaultTimeZone();
+ expect(typeof result).toBe('string');
+ expect(result.length).toBeGreaterThan(0);
+ });
+
+ it('should return UTC on error', () => {
+ const originalDateTimeFormat = Intl.DateTimeFormat;
+ Intl.DateTimeFormat = vi.fn(() => {
+ throw new Error('Test error');
+ });
+
+ const result = dateTimeUtils.getDefaultTimeZone();
+ expect(result).toBe('UTC');
+
+ Intl.DateTimeFormat = originalDateTimeFormat;
+ });
+ });
+});
diff --git a/frontend/src/utils/__tests__/networkUtils.test.js b/frontend/src/utils/__tests__/networkUtils.test.js
new file mode 100644
index 00000000..bb820589
--- /dev/null
+++ b/frontend/src/utils/__tests__/networkUtils.test.js
@@ -0,0 +1,144 @@
+import { describe, it, expect } from 'vitest';
+import * as networkUtils from '../networkUtils';
+
+describe('networkUtils', () => {
+ describe('IPV4_CIDR_REGEX', () => {
+ it('should match valid IPv4 CIDR notation', () => {
+ expect(networkUtils.IPV4_CIDR_REGEX.test('192.168.1.0/24')).toBe(true);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('10.0.0.0/8')).toBe(true);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('172.16.0.0/12')).toBe(true);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('0.0.0.0/0')).toBe(true);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('255.255.255.255/32')).toBe(true);
+ });
+
+ it('should not match invalid IPv4 CIDR notation', () => {
+ expect(networkUtils.IPV4_CIDR_REGEX.test('192.168.1.0')).toBe(false);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('192.168.1.0/33')).toBe(false);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('256.168.1.0/24')).toBe(false);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('192.168/24')).toBe(false);
+ expect(networkUtils.IPV4_CIDR_REGEX.test('invalid')).toBe(false);
+ });
+
+ it('should not match IPv6 addresses', () => {
+ expect(networkUtils.IPV4_CIDR_REGEX.test('2001:db8::/32')).toBe(false);
+ });
+ });
+
+ describe('IPV6_CIDR_REGEX', () => {
+ it('should match valid IPv6 CIDR notation', () => {
+ expect(networkUtils.IPV6_CIDR_REGEX.test('2001:db8::/32')).toBe(true);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('fe80::/10')).toBe(true);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('::/0')).toBe(true);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('2001:0db8:85a3:0000:0000:8a2e:0370:7334/64')).toBe(true);
+ });
+
+ it('should match compressed IPv6 CIDR notation', () => {
+ expect(networkUtils.IPV6_CIDR_REGEX.test('2001:db8::1/128')).toBe(true);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('::1/128')).toBe(true);
+ });
+
+ it('should match IPv6 with embedded IPv4', () => {
+ expect(networkUtils.IPV6_CIDR_REGEX.test('::ffff:192.168.1.1/96')).toBe(true);
+ });
+
+ it('should not match invalid IPv6 CIDR notation', () => {
+ expect(networkUtils.IPV6_CIDR_REGEX.test('2001:db8::')).toBe(false);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('2001:db8::/129')).toBe(false);
+ expect(networkUtils.IPV6_CIDR_REGEX.test('invalid/64')).toBe(false);
+ });
+
+ it('should not match IPv4 addresses', () => {
+ expect(networkUtils.IPV6_CIDR_REGEX.test('192.168.1.0/24')).toBe(false);
+ });
+ });
+
+ describe('formatBytes', () => {
+ it('should return "0 Bytes" for zero bytes', () => {
+ expect(networkUtils.formatBytes(0)).toBe('0 Bytes');
+ });
+
+ it('should format bytes correctly', () => {
+ expect(networkUtils.formatBytes(100)).toBe('100.00 Bytes');
+ expect(networkUtils.formatBytes(500)).toBe('500.00 Bytes');
+ });
+
+ it('should format kilobytes correctly', () => {
+ expect(networkUtils.formatBytes(1024)).toBe('1.00 KB');
+ expect(networkUtils.formatBytes(2048)).toBe('2.00 KB');
+ expect(networkUtils.formatBytes(1536)).toBe('1.50 KB');
+ });
+
+ it('should format megabytes correctly', () => {
+ expect(networkUtils.formatBytes(1048576)).toBe('1.00 MB');
+ expect(networkUtils.formatBytes(2097152)).toBe('2.00 MB');
+ expect(networkUtils.formatBytes(5242880)).toBe('5.00 MB');
+ });
+
+ it('should format gigabytes correctly', () => {
+ expect(networkUtils.formatBytes(1073741824)).toBe('1.00 GB');
+ expect(networkUtils.formatBytes(2147483648)).toBe('2.00 GB');
+ });
+
+ it('should format terabytes correctly', () => {
+ expect(networkUtils.formatBytes(1099511627776)).toBe('1.00 TB');
+ });
+
+ it('should format large numbers', () => {
+ expect(networkUtils.formatBytes(1125899906842624)).toBe('1.00 PB');
+ });
+
+ it('should handle decimal values', () => {
+ const result = networkUtils.formatBytes(1536);
+ expect(result).toMatch(/1\.50 KB/);
+ });
+
+ it('should always show two decimal places', () => {
+ const result = networkUtils.formatBytes(1024);
+ expect(result).toBe('1.00 KB');
+ });
+ });
+
+ describe('formatSpeed', () => {
+ it('should return "0 Bytes" for zero speed', () => {
+ expect(networkUtils.formatSpeed(0)).toBe('0 Bytes');
+ });
+
+ it('should format bits per second correctly', () => {
+ expect(networkUtils.formatSpeed(100)).toBe('100.00 bps');
+ expect(networkUtils.formatSpeed(500)).toBe('500.00 bps');
+ });
+
+ it('should format kilobits per second correctly', () => {
+ expect(networkUtils.formatSpeed(1024)).toBe('1.00 Kbps');
+ expect(networkUtils.formatSpeed(2048)).toBe('2.00 Kbps');
+ expect(networkUtils.formatSpeed(1536)).toBe('1.50 Kbps');
+ });
+
+ it('should format megabits per second correctly', () => {
+ expect(networkUtils.formatSpeed(1048576)).toBe('1.00 Mbps');
+ expect(networkUtils.formatSpeed(2097152)).toBe('2.00 Mbps');
+ expect(networkUtils.formatSpeed(10485760)).toBe('10.00 Mbps');
+ });
+
+ it('should format gigabits per second correctly', () => {
+ expect(networkUtils.formatSpeed(1073741824)).toBe('1.00 Gbps');
+ expect(networkUtils.formatSpeed(2147483648)).toBe('2.00 Gbps');
+ });
+
+ it('should handle decimal values', () => {
+ const result = networkUtils.formatSpeed(1536);
+ expect(result).toMatch(/1\.50 Kbps/);
+ });
+
+ it('should always show two decimal places', () => {
+ const result = networkUtils.formatSpeed(1024);
+ expect(result).toBe('1.00 Kbps');
+ });
+
+ it('should use speed units not byte units', () => {
+ const result = networkUtils.formatSpeed(1024);
+ expect(result).not.toContain('KB');
+ expect(result).toContain('Kbps');
+ });
+ });
+});
diff --git a/frontend/src/utils/__tests__/notificationUtils.test.js b/frontend/src/utils/__tests__/notificationUtils.test.js
new file mode 100644
index 00000000..bfea55d8
--- /dev/null
+++ b/frontend/src/utils/__tests__/notificationUtils.test.js
@@ -0,0 +1,145 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { notifications } from '@mantine/notifications';
+import * as notificationUtils from '../notificationUtils';
+
+vi.mock('@mantine/notifications', () => ({
+ notifications: {
+ show: vi.fn(),
+ update: vi.fn(),
+ },
+}));
+
+describe('notificationUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('showNotification', () => {
+ it('should call notifications.show with notification object', () => {
+ const notificationObject = {
+ title: 'Test Title',
+ message: 'Test message',
+ color: 'blue',
+ };
+
+ notificationUtils.showNotification(notificationObject);
+
+ expect(notifications.show).toHaveBeenCalledWith(notificationObject);
+ expect(notifications.show).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return the result from notifications.show', () => {
+ const mockReturnValue = 'notification-id-123';
+ notifications.show.mockReturnValue(mockReturnValue);
+
+ const result = notificationUtils.showNotification({ message: 'test' });
+
+ expect(result).toBe(mockReturnValue);
+ });
+
+ it('should handle notification with all properties', () => {
+ const notificationObject = {
+ id: 'custom-id',
+ title: 'Success',
+ message: 'Operation completed',
+ color: 'green',
+ autoClose: 5000,
+ withCloseButton: true,
+ };
+
+ notificationUtils.showNotification(notificationObject);
+
+ expect(notifications.show).toHaveBeenCalledWith(notificationObject);
+ });
+
+ it('should handle minimal notification object', () => {
+ const notificationObject = {
+ message: 'Simple message',
+ };
+
+ notificationUtils.showNotification(notificationObject);
+
+ expect(notifications.show).toHaveBeenCalledWith(notificationObject);
+ });
+ });
+
+ describe('updateNotification', () => {
+ it('should call notifications.update with id and notification object', () => {
+ const notificationId = 'notification-123';
+ const notificationObject = {
+ title: 'Updated Title',
+ message: 'Updated message',
+ color: 'green',
+ };
+
+ notificationUtils.updateNotification(notificationId, notificationObject);
+
+ expect(notifications.update).toHaveBeenCalledWith(notificationId, notificationObject);
+ expect(notifications.update).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return the result from notifications.update', () => {
+ const mockReturnValue = { success: true };
+ notifications.update.mockReturnValue(mockReturnValue);
+
+ const result = notificationUtils.updateNotification('id', { message: 'test' });
+
+ expect(result).toBe(mockReturnValue);
+ });
+
+ it('should handle loading to success transition', () => {
+ const notificationId = 'loading-notification';
+ const updateObject = {
+ title: 'Success',
+ message: 'Operation completed successfully',
+ color: 'green',
+ loading: false,
+ };
+
+ notificationUtils.updateNotification(notificationId, updateObject);
+
+ expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
+ });
+
+ it('should handle loading to error transition', () => {
+ const notificationId = 'loading-notification';
+ const updateObject = {
+ title: 'Error',
+ message: 'Operation failed',
+ color: 'red',
+ loading: false,
+ };
+
+ notificationUtils.updateNotification(notificationId, updateObject);
+
+ expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
+ });
+
+ it('should handle partial updates', () => {
+ const notificationId = 'notification-123';
+ const updateObject = {
+ color: 'yellow',
+ };
+
+ notificationUtils.updateNotification(notificationId, updateObject);
+
+ expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject);
+ });
+
+ it('should handle empty notification id', () => {
+ const notificationObject = { message: 'test' };
+
+ notificationUtils.updateNotification('', notificationObject);
+
+ expect(notifications.update).toHaveBeenCalledWith('', notificationObject);
+ });
+
+ it('should handle null notification id', () => {
+ const notificationObject = { message: 'test' };
+
+ notificationUtils.updateNotification(null, notificationObject);
+
+ expect(notifications.update).toHaveBeenCalledWith(null, notificationObject);
+ });
+ });
+});
diff --git a/frontend/src/utils/cards/__tests__/PluginCardUtils.test.js b/frontend/src/utils/cards/__tests__/PluginCardUtils.test.js
new file mode 100644
index 00000000..a6074a4a
--- /dev/null
+++ b/frontend/src/utils/cards/__tests__/PluginCardUtils.test.js
@@ -0,0 +1,158 @@
+import { describe, it, expect } from 'vitest';
+import {
+ getConfirmationDetails,
+} from '../PluginCardUtils';
+
+describe('PluginCardUtils', () => {
+ describe('getConfirmationDetails', () => {
+ it('requires confirmation when action.confirm is true', () => {
+ const action = { label: 'Test Action', confirm: true };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result).toEqual({
+ requireConfirm: true,
+ confirmTitle: 'Run Test Action?',
+ confirmMessage: 'You\'re about to run "Test Action" from "Test Plugin".',
+ });
+ });
+
+ it('does not require confirmation when action.confirm is false', () => {
+ const action = { label: 'Test Action', confirm: false };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(false);
+ });
+
+ it('uses custom title and message from action.confirm object', () => {
+ const action = {
+ label: 'Test Action',
+ confirm: {
+ required: true,
+ title: 'Custom Title',
+ message: 'Custom message',
+ },
+ };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result).toEqual({
+ requireConfirm: true,
+ confirmTitle: 'Custom Title',
+ confirmMessage: 'Custom message',
+ });
+ });
+
+ it('requires confirmation when action.confirm.required is not explicitly false', () => {
+ const action = {
+ label: 'Test Action',
+ confirm: {
+ title: 'Custom Title',
+ },
+ };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('does not require confirmation when action.confirm.required is false', () => {
+ const action = {
+ label: 'Test Action',
+ confirm: {
+ required: false,
+ title: 'Custom Title',
+ },
+ };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(false);
+ });
+
+ it('uses confirm field from plugin when action.confirm is undefined', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: true }],
+ };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('uses settings value over field default', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: false }],
+ };
+ const settings = { confirm: true };
+ const result = getConfirmationDetails(action, plugin, settings);
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('uses field default when settings value is undefined', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: true }],
+ };
+ const settings = {};
+ const result = getConfirmationDetails(action, plugin, settings);
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('does not require confirmation when no confirm configuration exists', () => {
+ const action = { label: 'Test Action' };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(false);
+ });
+
+ it('handles plugin without fields array', () => {
+ const action = { label: 'Test Action' };
+ const plugin = { name: 'Test Plugin' };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(false);
+ });
+
+ it('handles null or undefined settings', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: true }],
+ };
+ const result = getConfirmationDetails(action, plugin, null);
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('converts truthy confirm field values to boolean', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: 1 }],
+ };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(true);
+ });
+
+ it('handles confirm field with null default', () => {
+ const action = { label: 'Test Action' };
+ const plugin = {
+ name: 'Test Plugin',
+ fields: [{ id: 'confirm', default: null }],
+ };
+ const result = getConfirmationDetails(action, plugin, {});
+
+ expect(result.requireConfirm).toBe(false);
+ });
+ });
+});
diff --git a/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js b/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js
new file mode 100644
index 00000000..3410c596
--- /dev/null
+++ b/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js
@@ -0,0 +1,390 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ removeRecording,
+ getPosterUrl,
+ getShowVideoUrl,
+ runComSkip,
+ deleteRecordingById,
+ deleteSeriesAndRule,
+ getRecordingUrl,
+ getSeasonLabel,
+ getSeriesInfo,
+} from '../RecordingCardUtils';
+import API from '../../../api';
+import useChannelsStore from '../../../store/channels';
+
+vi.mock('../../../api');
+vi.mock('../../../store/channels');
+
+describe('RecordingCardUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('removeRecording', () => {
+ let mockRemoveRecording;
+ let mockFetchRecordings;
+
+ beforeEach(() => {
+ mockRemoveRecording = vi.fn();
+ mockFetchRecordings = vi.fn();
+ useChannelsStore.getState = vi.fn(() => ({
+ removeRecording: mockRemoveRecording,
+ fetchRecordings: mockFetchRecordings,
+ }));
+ });
+
+ it('optimistically removes recording from store', () => {
+ API.deleteRecording.mockResolvedValue();
+
+ removeRecording('recording-1');
+
+ expect(mockRemoveRecording).toHaveBeenCalledWith('recording-1');
+ });
+
+ it('calls API to delete recording', () => {
+ API.deleteRecording.mockResolvedValue();
+
+ removeRecording('recording-1');
+
+ expect(API.deleteRecording).toHaveBeenCalledWith('recording-1');
+ });
+
+ it('handles optimistic removal error', () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation();
+ mockRemoveRecording.mockImplementation(() => {
+ throw new Error('Store error');
+ });
+ API.deleteRecording.mockResolvedValue();
+
+ removeRecording('recording-1');
+
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to optimistically remove recording',
+ expect.any(Error)
+ );
+ consoleError.mockRestore();
+ });
+
+ it('refetches recordings when API delete fails', async () => {
+ API.deleteRecording.mockRejectedValue(new Error('Delete failed'));
+
+ removeRecording('recording-1');
+
+ await vi.waitFor(() => {
+ expect(mockFetchRecordings).toHaveBeenCalled();
+ });
+ });
+
+ it('handles fetch error after failed delete', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation();
+ API.deleteRecording.mockRejectedValue(new Error('Delete failed'));
+ mockFetchRecordings.mockImplementation(() => {
+ throw new Error('Fetch error');
+ });
+
+ removeRecording('recording-1');
+
+ await vi.waitFor(() => {
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to refresh recordings after delete',
+ expect.any(Error)
+ );
+ });
+ consoleError.mockRestore();
+ });
+ });
+
+ describe('getPosterUrl', () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('returns logo URL when posterLogoId is provided', () => {
+ vi.stubEnv('DEV', false);
+ const result = getPosterUrl('logo-123', {}, '');
+
+ expect(result).toBe('/api/channels/logos/logo-123/cache/');
+ });
+
+ it('returns custom poster_url when no posterLogoId', () => {
+ vi.stubEnv('DEV', false);
+ const customProps = { poster_url: '/custom/poster.jpg' };
+ const result = getPosterUrl(null, customProps, '');
+
+ expect(result).toBe('/custom/poster.jpg');
+ });
+
+ it('returns posterUrl when no posterLogoId or custom poster_url', () => {
+ vi.stubEnv('DEV', false);
+ const result = getPosterUrl(null, {}, '/fallback/poster.jpg');
+
+ expect(result).toBe('/fallback/poster.jpg');
+ });
+
+ it('returns default logo when no parameters provided', () => {
+ vi.stubEnv('DEV', false);
+ const result = getPosterUrl(null, {}, '');
+
+ expect(result).toBe('/logo.png');
+ });
+
+ it('prepends dev server URL in dev mode for relative paths', () => {
+ vi.stubEnv('DEV', true);
+ const result = getPosterUrl(null, {}, '/poster.jpg');
+
+ expect(result).toMatch(/^https?:\/\/.*:5656\/poster\.jpg$/);
+ });
+
+ it('does not prepend dev URL for absolute URLs', () => {
+ vi.stubEnv('DEV', true);
+ const result = getPosterUrl(null, {}, 'https://example.com/poster.jpg');
+
+ expect(result).toBe('https://example.com/poster.jpg');
+ });
+ });
+
+ describe('getShowVideoUrl', () => {
+ it('returns proxy URL for channel', () => {
+ const channel = { uuid: 'channel-123' };
+ const result = getShowVideoUrl(channel, 'production');
+
+ expect(result).toBe('/proxy/ts/stream/channel-123');
+ });
+
+ it('prepends dev server URL in dev mode', () => {
+ const channel = { uuid: 'channel-123' };
+ const result = getShowVideoUrl(channel, 'dev');
+
+ expect(result).toMatch(/^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123$/);
+ });
+ });
+
+ describe('runComSkip', () => {
+ it('calls API runComskip with recording id', async () => {
+ API.runComskip.mockResolvedValue();
+ const recording = { id: 'recording-1' };
+
+ await runComSkip(recording);
+
+ expect(API.runComskip).toHaveBeenCalledWith('recording-1');
+ });
+ });
+
+ describe('deleteRecordingById', () => {
+ it('calls API deleteRecording with id', async () => {
+ API.deleteRecording.mockResolvedValue();
+
+ await deleteRecordingById('recording-1');
+
+ expect(API.deleteRecording).toHaveBeenCalledWith('recording-1');
+ });
+ });
+
+ describe('deleteSeriesAndRule', () => {
+ it('removes series recordings and deletes series rule', async () => {
+ API.bulkRemoveSeriesRecordings.mockResolvedValue();
+ API.deleteSeriesRule.mockResolvedValue();
+ const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' };
+
+ await deleteSeriesAndRule(seriesInfo);
+
+ expect(API.bulkRemoveSeriesRecordings).toHaveBeenCalledWith({
+ tvg_id: 'series-123',
+ title: 'Test Series',
+ scope: 'title',
+ });
+ expect(API.deleteSeriesRule).toHaveBeenCalledWith('series-123');
+ });
+
+ it('does nothing when tvg_id is not provided', async () => {
+ const seriesInfo = { title: 'Test Series' };
+
+ await deleteSeriesAndRule(seriesInfo);
+
+ expect(API.bulkRemoveSeriesRecordings).not.toHaveBeenCalled();
+ expect(API.deleteSeriesRule).not.toHaveBeenCalled();
+ });
+
+ it('handles bulk remove error gracefully', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation();
+ API.bulkRemoveSeriesRecordings.mockRejectedValue(new Error('Bulk remove failed'));
+ API.deleteSeriesRule.mockResolvedValue();
+ const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' };
+
+ await deleteSeriesAndRule(seriesInfo);
+
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to remove series recordings',
+ expect.any(Error)
+ );
+ expect(API.deleteSeriesRule).toHaveBeenCalled();
+ consoleError.mockRestore();
+ });
+
+ it('handles delete rule error gracefully', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation();
+ API.bulkRemoveSeriesRecordings.mockResolvedValue();
+ API.deleteSeriesRule.mockRejectedValue(new Error('Delete rule failed'));
+ const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' };
+
+ await deleteSeriesAndRule(seriesInfo);
+
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to delete series rule',
+ expect.any(Error)
+ );
+ consoleError.mockRestore();
+ });
+ });
+
+ describe('getRecordingUrl', () => {
+ it('returns file_url when available', () => {
+ const customProps = { file_url: '/recordings/file.mp4' };
+ const result = getRecordingUrl(customProps, 'production');
+
+ expect(result).toBe('/recordings/file.mp4');
+ });
+
+ it('returns output_file_url when file_url is not available', () => {
+ const customProps = { output_file_url: '/output/file.mp4' };
+ const result = getRecordingUrl(customProps, 'production');
+
+ expect(result).toBe('/output/file.mp4');
+ });
+
+ it('prefers file_url over output_file_url', () => {
+ const customProps = {
+ file_url: '/recordings/file.mp4',
+ output_file_url: '/output/file.mp4',
+ };
+ const result = getRecordingUrl(customProps, 'production');
+
+ expect(result).toBe('/recordings/file.mp4');
+ });
+
+ it('prepends dev server URL in dev mode for relative paths', () => {
+ const customProps = { file_url: '/recordings/file.mp4' };
+ const result = getRecordingUrl(customProps, 'dev');
+
+ expect(result).toMatch(/^https?:\/\/.*:5656\/recordings\/file\.mp4$/);
+ });
+
+ it('does not prepend dev URL for absolute URLs', () => {
+ const customProps = { file_url: 'https://example.com/file.mp4' };
+ const result = getRecordingUrl(customProps, 'dev');
+
+ expect(result).toBe('https://example.com/file.mp4');
+ });
+
+ it('returns undefined when no file URL is available', () => {
+ const result = getRecordingUrl({}, 'production');
+
+ expect(result).toBeUndefined();
+ });
+
+ it('handles null customProps', () => {
+ const result = getRecordingUrl(null, 'production');
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('getSeasonLabel', () => {
+ it('returns formatted season and episode label', () => {
+ const result = getSeasonLabel(1, 5, null);
+
+ expect(result).toBe('S01E05');
+ });
+
+ it('pads single digit season and episode numbers', () => {
+ const result = getSeasonLabel(2, 3, null);
+
+ expect(result).toBe('S02E03');
+ });
+
+ it('handles multi-digit season and episode numbers', () => {
+ const result = getSeasonLabel(12, 34, null);
+
+ expect(result).toBe('S12E34');
+ });
+
+ it('returns onscreen value when season or episode is missing', () => {
+ const result = getSeasonLabel(null, 5, 'Episode 5');
+
+ expect(result).toBe('Episode 5');
+ });
+
+ it('returns onscreen value when only episode is missing', () => {
+ const result = getSeasonLabel(1, null, 'Special');
+
+ expect(result).toBe('Special');
+ });
+
+ it('returns null when no season, episode, or onscreen provided', () => {
+ const result = getSeasonLabel(null, null, null);
+
+ expect(result).toBeNull();
+ });
+
+ it('returns formatted label even when onscreen is provided', () => {
+ const result = getSeasonLabel(1, 5, 'Episode 5');
+
+ expect(result).toBe('S01E05');
+ });
+ });
+
+ describe('getSeriesInfo', () => {
+ it('extracts tvg_id and title from program', () => {
+ const customProps = {
+ program: { tvg_id: 'series-123', title: 'Test Series' },
+ };
+ const result = getSeriesInfo(customProps);
+
+ expect(result).toEqual({
+ tvg_id: 'series-123',
+ title: 'Test Series',
+ });
+ });
+
+ it('handles missing program object', () => {
+ const customProps = {};
+ const result = getSeriesInfo(customProps);
+
+ expect(result).toEqual({
+ tvg_id: undefined,
+ title: undefined,
+ });
+ });
+
+ it('handles null customProps', () => {
+ const result = getSeriesInfo(null);
+
+ expect(result).toEqual({
+ tvg_id: undefined,
+ title: undefined,
+ });
+ });
+
+ it('handles undefined customProps', () => {
+ const result = getSeriesInfo(undefined);
+
+ expect(result).toEqual({
+ tvg_id: undefined,
+ title: undefined,
+ });
+ });
+
+ it('handles partial program data', () => {
+ const customProps = {
+ program: { tvg_id: 'series-123' },
+ };
+ const result = getSeriesInfo(customProps);
+
+ expect(result).toEqual({
+ tvg_id: 'series-123',
+ title: undefined,
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js
new file mode 100644
index 00000000..f48f1c1c
--- /dev/null
+++ b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js
@@ -0,0 +1,301 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as StreamConnectionCardUtils from '../StreamConnectionCardUtils';
+import API from '../../../api.js';
+import * as dateTimeUtils from '../../dateTimeUtils.js';
+
+vi.mock('../../../api.js');
+vi.mock('../../dateTimeUtils.js');
+
+describe('StreamConnectionCardUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getBufferingSpeedThreshold', () => {
+ it('should return parsed buffering_speed from proxy settings', () => {
+ const proxySetting = {
+ value: JSON.stringify({ buffering_speed: 2.5 })
+ };
+ expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(2.5);
+ });
+
+ it('should return 1.0 for invalid JSON', () => {
+ const proxySetting = { value: 'invalid json' };
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(1.0);
+ expect(consoleSpy).toHaveBeenCalled();
+ consoleSpy.mockRestore();
+ });
+
+ it('should return 1.0 when buffering_speed is not a number', () => {
+ const proxySetting = {
+ value: JSON.stringify({ buffering_speed: 'not a number' })
+ };
+ expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(1.0);
+ });
+
+ it('should return 1.0 when proxySetting is null', () => {
+ expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(null)).toBe(1.0);
+ });
+
+ it('should return 1.0 when value is missing', () => {
+ expect(StreamConnectionCardUtils.getBufferingSpeedThreshold({})).toBe(1.0);
+ });
+ });
+
+ describe('getStartDate', () => {
+ it('should calculate start date from uptime in seconds', () => {
+ const uptime = 3600; // 1 hour
+ const result = StreamConnectionCardUtils.getStartDate(uptime);
+ expect(typeof result).toBe('string');
+ expect(result.length).toBeGreaterThan(0);
+ });
+
+ it('should handle zero uptime', () => {
+ const result = StreamConnectionCardUtils.getStartDate(0);
+ expect(typeof result).toBe('string');
+ });
+ });
+
+ describe('getM3uAccountsMap', () => {
+ it('should create map from m3u accounts array', () => {
+ const m3uAccounts = [
+ { id: 1, name: 'Account 1' },
+ { id: 2, name: 'Account 2' }
+ ];
+ const result = StreamConnectionCardUtils.getM3uAccountsMap(m3uAccounts);
+ expect(result).toEqual({ 1: 'Account 1', 2: 'Account 2' });
+ });
+
+ it('should handle accounts without id', () => {
+ const m3uAccounts = [
+ { name: 'Account 1' },
+ { id: 2, name: 'Account 2' }
+ ];
+ const result = StreamConnectionCardUtils.getM3uAccountsMap(m3uAccounts);
+ expect(result).toEqual({ 2: 'Account 2' });
+ });
+
+ it('should return empty object for null input', () => {
+ expect(StreamConnectionCardUtils.getM3uAccountsMap(null)).toEqual({});
+ });
+
+ it('should return empty object for non-array input', () => {
+ expect(StreamConnectionCardUtils.getM3uAccountsMap({})).toEqual({});
+ });
+ });
+
+ describe('getChannelStreams', () => {
+ it('should call API.getChannelStreams with channelId', async () => {
+ const mockStreams = [{ id: 1, name: 'Stream 1' }];
+ API.getChannelStreams.mockResolvedValue(mockStreams);
+
+ const result = await StreamConnectionCardUtils.getChannelStreams(123);
+
+ expect(API.getChannelStreams).toHaveBeenCalledWith(123);
+ expect(result).toEqual(mockStreams);
+ });
+ });
+
+ describe('getMatchingStreamByUrl', () => {
+ it('should find stream when channelUrl includes stream url', () => {
+ const streamData = [
+ { id: 1, url: 'http://example.com/stream1' },
+ { id: 2, url: 'http://example.com/stream2' }
+ ];
+ const result = StreamConnectionCardUtils.getMatchingStreamByUrl(
+ streamData,
+ 'http://example.com/stream1/playlist.m3u8'
+ );
+ expect(result).toEqual(streamData[0]);
+ });
+
+ it('should find stream when stream url includes channelUrl', () => {
+ const streamData = [
+ { id: 1, url: 'http://example.com/stream1/playlist.m3u8' }
+ ];
+ const result = StreamConnectionCardUtils.getMatchingStreamByUrl(
+ streamData,
+ 'http://example.com/stream1'
+ );
+ expect(result).toEqual(streamData[0]);
+ });
+
+ it('should return undefined when no match found', () => {
+ const streamData = [{ id: 1, url: 'http://example.com/stream1' }];
+ const result = StreamConnectionCardUtils.getMatchingStreamByUrl(
+ streamData,
+ 'http://different.com/stream'
+ );
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('getSelectedStream', () => {
+ it('should find stream by id as string', () => {
+ const streams = [
+ { id: 1, name: 'Stream 1' },
+ { id: 2, name: 'Stream 2' }
+ ];
+ const result = StreamConnectionCardUtils.getSelectedStream(streams, '2');
+ expect(result).toEqual(streams[1]);
+ });
+
+ it('should return undefined when stream not found', () => {
+ const streams = [{ id: 1, name: 'Stream 1' }];
+ const result = StreamConnectionCardUtils.getSelectedStream(streams, '99');
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('switchStream', () => {
+ it('should call API.switchStream with channel_id and streamId', () => {
+ const channel = { channel_id: 123 };
+ API.switchStream.mockResolvedValue({ success: true });
+
+ StreamConnectionCardUtils.switchStream(channel, 456);
+
+ expect(API.switchStream).toHaveBeenCalledWith(123, 456);
+ });
+ });
+
+ describe('connectedAccessor', () => {
+ it('should format connected_since correctly', () => {
+ const mockNow = new Date('2024-01-01T12:00:00');
+ const mockConnectedTime = new Date('2024-01-01T10:00:00');
+
+ dateTimeUtils.getNow.mockReturnValue(mockNow);
+ dateTimeUtils.subtract.mockReturnValue(mockConnectedTime);
+ dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00');
+
+ const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
+ const result = accessor({ connected_since: 7200 });
+
+ expect(dateTimeUtils.subtract).toHaveBeenCalledWith(mockNow, 7200, 'second');
+ expect(dateTimeUtils.format).toHaveBeenCalledWith(mockConnectedTime, 'MM/DD/YYYY HH:mm:ss');
+ expect(result).toBe('01/01/2024 10:00:00');
+ });
+
+ it('should fallback to connected_at when connected_since is missing', () => {
+ const mockTime = new Date('2024-01-01T10:00:00');
+
+ dateTimeUtils.initializeTime.mockReturnValue(mockTime);
+ dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00');
+
+ const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
+ const result = accessor({ connected_at: 1704103200 });
+
+ expect(dateTimeUtils.initializeTime).toHaveBeenCalledWith(1704103200000);
+ expect(result).toBe('01/01/2024 10:00:00');
+ });
+
+ it('should return Unknown when no time data available', () => {
+ const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY');
+ const result = accessor({});
+ expect(result).toBe('Unknown');
+ });
+ });
+
+ describe('durationAccessor', () => {
+ it('should format connected_since duration', () => {
+ dateTimeUtils.toFriendlyDuration.mockReturnValue('2h 30m');
+
+ const accessor = StreamConnectionCardUtils.durationAccessor();
+ const result = accessor({ connected_since: 9000 });
+
+ expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(9000, 'seconds');
+ expect(result).toBe('2h 30m');
+ });
+
+ it('should fallback to connection_duration', () => {
+ dateTimeUtils.toFriendlyDuration.mockReturnValue('1h 15m');
+
+ const accessor = StreamConnectionCardUtils.durationAccessor();
+ const result = accessor({ connection_duration: 4500 });
+
+ expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(4500, 'seconds');
+ expect(result).toBe('1h 15m');
+ });
+
+ it('should return - when no duration data available', () => {
+ const accessor = StreamConnectionCardUtils.durationAccessor();
+ const result = accessor({});
+ expect(result).toBe('-');
+ });
+ });
+
+ describe('getLogoUrl', () => {
+ it('should return cache_url from logos map when logoId exists', () => {
+ const logos = {
+ 'logo-123': { cache_url: '/api/logos/logo-123/cache/' }
+ };
+ const result = StreamConnectionCardUtils.getLogoUrl('logo-123', logos, null);
+ expect(result).toBe('/api/logos/logo-123/cache/');
+ });
+
+ it('should fallback to previewedStream logo_url when logoId not in map', () => {
+ const previewedStream = { logo_url: 'http://example.com/logo.png' };
+ const result = StreamConnectionCardUtils.getLogoUrl('logo-456', {}, previewedStream);
+ expect(result).toBe('http://example.com/logo.png');
+ });
+
+ it('should return null when no logo available', () => {
+ const result = StreamConnectionCardUtils.getLogoUrl(null, {}, null);
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('getStreamsByIds', () => {
+ it('should call API.getStreamsByIds with array containing streamId', async () => {
+ const mockStreams = [{ id: 123, name: 'Stream' }];
+ API.getStreamsByIds.mockResolvedValue(mockStreams);
+
+ const result = await StreamConnectionCardUtils.getStreamsByIds(123);
+
+ expect(API.getStreamsByIds).toHaveBeenCalledWith([123]);
+ expect(result).toEqual(mockStreams);
+ });
+ });
+
+ describe('getStreamOptions', () => {
+ it('should format stream options with account names from map', () => {
+ const streams = [
+ { id: 1, name: 'Stream 1', m3u_account: 100 },
+ { id: 2, name: 'Stream 2', m3u_account: 200 }
+ ];
+ const accountsMap = { 100: 'Premium Account', 200: 'Basic Account' };
+
+ const result = StreamConnectionCardUtils.getStreamOptions(streams, accountsMap);
+
+ expect(result).toEqual([
+ { value: '1', label: 'Stream 1 [Premium Account]' },
+ { value: '2', label: 'Stream 2 [Basic Account]' }
+ ]);
+ });
+
+ it('should use default M3U label when account not in map', () => {
+ const streams = [{ id: 1, name: 'Stream 1', m3u_account: 999 }];
+
+ const result = StreamConnectionCardUtils.getStreamOptions(streams, {});
+
+ expect(result[0].label).toBe('Stream 1 [M3U #999]');
+ });
+
+ it('should handle streams without name', () => {
+ const streams = [{ id: 5, m3u_account: 100 }];
+ const accountsMap = { 100: 'Account' };
+
+ const result = StreamConnectionCardUtils.getStreamOptions(streams, accountsMap);
+
+ expect(result[0].label).toBe('Stream #5 [Account]');
+ });
+
+ it('should handle streams without m3u_account', () => {
+ const streams = [{ id: 1, name: 'Stream 1' }];
+
+ const result = StreamConnectionCardUtils.getStreamOptions(streams, {});
+
+ expect(result[0].label).toBe('Stream 1 [Unknown M3U]');
+ });
+ });
+});
diff --git a/frontend/src/utils/cards/__tests__/VODCardUtils.test.js b/frontend/src/utils/cards/__tests__/VODCardUtils.test.js
new file mode 100644
index 00000000..b9ada55c
--- /dev/null
+++ b/frontend/src/utils/cards/__tests__/VODCardUtils.test.js
@@ -0,0 +1,90 @@
+import { describe, it, expect } from 'vitest';
+import * as VODCardUtils from '../VODCardUtils';
+
+describe('VODCardUtils', () => {
+ describe('formatDuration', () => {
+ it('should format duration with hours and minutes', () => {
+ const result = VODCardUtils.formatDuration(3661); // 1h 1m 1s
+ expect(result).toBe('1h 1m');
+ });
+
+ it('should format duration with minutes and seconds when less than an hour', () => {
+ const result = VODCardUtils.formatDuration(125); // 2m 5s
+ expect(result).toBe('2m 5s');
+ });
+
+ it('should format duration with only minutes when seconds are zero', () => {
+ const result = VODCardUtils.formatDuration(120); // 2m 0s
+ expect(result).toBe('2m 0s');
+ });
+
+ it('should format duration with only seconds when less than a minute', () => {
+ const result = VODCardUtils.formatDuration(45);
+ expect(result).toBe('0m 45s');
+ });
+
+ it('should handle multiple hours correctly', () => {
+ const result = VODCardUtils.formatDuration(7325); // 2h 2m 5s
+ expect(result).toBe('2h 2m');
+ });
+
+ it('should return empty string for zero seconds', () => {
+ const result = VODCardUtils.formatDuration(0);
+ expect(result).toBe('');
+ });
+
+ it('should return empty string for null', () => {
+ const result = VODCardUtils.formatDuration(null);
+ expect(result).toBe('');
+ });
+
+ it('should return empty string for undefined', () => {
+ const result = VODCardUtils.formatDuration(undefined);
+ expect(result).toBe('');
+ });
+ });
+
+ describe('getSeasonLabel', () => {
+ it('should format season and episode numbers with padding', () => {
+ const vod = { season_number: 1, episode_number: 5 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('S01E05');
+ });
+
+ it('should format double-digit season and episode numbers', () => {
+ const vod = { season_number: 12, episode_number: 23 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('S12E23');
+ });
+
+ it('should return empty string when season_number is missing', () => {
+ const vod = { episode_number: 5 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('');
+ });
+
+ it('should return empty string when episode_number is missing', () => {
+ const vod = { season_number: 1 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('');
+ });
+
+ it('should return empty string when both are missing', () => {
+ const vod = {};
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('');
+ });
+
+ it('should handle season_number of zero', () => {
+ const vod = { season_number: 0, episode_number: 1 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('');
+ });
+
+ it('should handle episode_number of zero', () => {
+ const vod = { season_number: 1, episode_number: 0 };
+ const result = VODCardUtils.getSeasonLabel(vod);
+ expect(result).toBe('');
+ });
+ });
+});
diff --git a/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js
new file mode 100644
index 00000000..9765daf3
--- /dev/null
+++ b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js
@@ -0,0 +1,323 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as VodConnectionCardUtils from '../VodConnectionCardUtils';
+import * as dateTimeUtils from '../../dateTimeUtils.js';
+
+vi.mock('../../dateTimeUtils.js');
+
+describe('VodConnectionCardUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('formatDuration', () => {
+ it('should format duration with hours and minutes when hours > 0', () => {
+ const result = VodConnectionCardUtils.formatDuration(3661); // 1h 1m 1s
+ expect(result).toBe('1h 1m');
+ });
+
+ it('should format duration with only minutes when less than an hour', () => {
+ const result = VodConnectionCardUtils.formatDuration(125); // 2m 5s
+ expect(result).toBe('2m');
+ });
+
+ it('should format duration with 0 minutes when less than 60 seconds', () => {
+ const result = VodConnectionCardUtils.formatDuration(45);
+ expect(result).toBe('0m');
+ });
+
+ it('should handle multiple hours correctly', () => {
+ const result = VodConnectionCardUtils.formatDuration(7325); // 2h 2m 5s
+ expect(result).toBe('2h 2m');
+ });
+
+ it('should return Unknown for zero seconds', () => {
+ const result = VodConnectionCardUtils.formatDuration(0);
+ expect(result).toBe('Unknown');
+ });
+
+ it('should return Unknown for null', () => {
+ const result = VodConnectionCardUtils.formatDuration(null);
+ expect(result).toBe('Unknown');
+ });
+
+ it('should return Unknown for undefined', () => {
+ const result = VodConnectionCardUtils.formatDuration(undefined);
+ expect(result).toBe('Unknown');
+ });
+ });
+
+ describe('formatTime', () => {
+ it('should format time with hours when hours > 0', () => {
+ const result = VodConnectionCardUtils.formatTime(3665); // 1:01:05
+ expect(result).toBe('1:01:05');
+ });
+
+ it('should format time without hours when less than an hour', () => {
+ const result = VodConnectionCardUtils.formatTime(125); // 2:05
+ expect(result).toBe('2:05');
+ });
+
+ it('should pad minutes and seconds with zeros', () => {
+ const result = VodConnectionCardUtils.formatTime(3605); // 1:00:05
+ expect(result).toBe('1:00:05');
+ });
+
+ it('should handle only seconds', () => {
+ const result = VodConnectionCardUtils.formatTime(45); // 0:45
+ expect(result).toBe('0:45');
+ });
+
+ it('should return 0:00 for zero seconds', () => {
+ const result = VodConnectionCardUtils.formatTime(0);
+ expect(result).toBe('0:00');
+ });
+
+ it('should return 0:00 for null', () => {
+ const result = VodConnectionCardUtils.formatTime(null);
+ expect(result).toBe('0:00');
+ });
+
+ it('should return 0:00 for undefined', () => {
+ const result = VodConnectionCardUtils.formatTime(undefined);
+ expect(result).toBe('0:00');
+ });
+ });
+
+ describe('getMovieDisplayTitle', () => {
+ it('should return content_name from vodContent', () => {
+ const vodContent = { content_name: 'The Matrix' };
+ const result = VodConnectionCardUtils.getMovieDisplayTitle(vodContent);
+ expect(result).toBe('The Matrix');
+ });
+ });
+
+ describe('getEpisodeDisplayTitle', () => {
+ it('should format title with season and episode numbers', () => {
+ const metadata = {
+ series_name: 'Breaking Bad',
+ season_number: 1,
+ episode_number: 5
+ };
+ const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
+ expect(result).toBe('Breaking Bad - S01E05');
+ });
+
+ it('should pad single-digit season and episode numbers', () => {
+ const metadata = {
+ series_name: 'The Office',
+ season_number: 3,
+ episode_number: 9
+ };
+ const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
+ expect(result).toBe('The Office - S03E09');
+ });
+
+ it('should use S?? when season_number is missing', () => {
+ const metadata = {
+ series_name: 'Lost',
+ episode_number: 5
+ };
+ const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
+ expect(result).toBe('Lost - S??E05');
+ });
+
+ it('should use E?? when episode_number is missing', () => {
+ const metadata = {
+ series_name: 'Friends',
+ season_number: 2
+ };
+ const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata);
+ expect(result).toBe('Friends - S02E??');
+ });
+ });
+
+ describe('getMovieSubtitle', () => {
+ it('should return array with genre when present', () => {
+ const metadata = { genre: 'Action' };
+ const result = VodConnectionCardUtils.getMovieSubtitle(metadata);
+ expect(result).toEqual(['Action']);
+ });
+
+ it('should return empty array when genre is missing', () => {
+ const metadata = {};
+ const result = VodConnectionCardUtils.getMovieSubtitle(metadata);
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('getEpisodeSubtitle', () => {
+ it('should return array with episode_name when present', () => {
+ const metadata = { episode_name: 'Pilot' };
+ const result = VodConnectionCardUtils.getEpisodeSubtitle(metadata);
+ expect(result).toEqual(['Pilot']);
+ });
+
+ it('should return array with Episode when episode_name is missing', () => {
+ const metadata = {};
+ const result = VodConnectionCardUtils.getEpisodeSubtitle(metadata);
+ expect(result).toEqual(['Episode']);
+ });
+ });
+
+ describe('calculateProgress', () => {
+ beforeEach(() => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000); // 1000 seconds
+ });
+
+ it('should calculate progress from last_seek_percentage', () => {
+ const connection = {
+ last_seek_percentage: 50,
+ last_seek_timestamp: 990 // 10 seconds ago
+ };
+ const result = VodConnectionCardUtils.calculateProgress(connection, 200);
+
+ expect(result.currentTime).toBe(110); // 50% of 200 = 100, plus 10 elapsed
+ expect(result.percentage).toBeCloseTo(55);
+ expect(result.totalTime).toBe(200);
+ });
+
+ it('should cap currentTime at duration when seeking', () => {
+ const connection = {
+ last_seek_percentage: 95,
+ last_seek_timestamp: 900 // 100 seconds ago
+ };
+ const result = VodConnectionCardUtils.calculateProgress(connection, 200);
+
+ expect(result.currentTime).toBe(200); // Capped at duration
+ expect(result.percentage).toBe(100);
+ });
+
+ it('should fallback to position_seconds when seek data unavailable', () => {
+ const connection = {
+ position_seconds: 75
+ };
+ const result = VodConnectionCardUtils.calculateProgress(connection, 200);
+
+ expect(result.currentTime).toBe(75);
+ expect(result.percentage).toBe(37.5);
+ expect(result.totalTime).toBe(200);
+ });
+
+ it('should return zero progress when no connection data', () => {
+ const result = VodConnectionCardUtils.calculateProgress(null, 200);
+
+ expect(result.currentTime).toBe(0);
+ expect(result.percentage).toBe(0);
+ expect(result.totalTime).toBe(200);
+ });
+
+ it('should return zero progress when duration is missing', () => {
+ const connection = { position_seconds: 50 };
+ const result = VodConnectionCardUtils.calculateProgress(connection, null);
+
+ expect(result.currentTime).toBe(0);
+ expect(result.percentage).toBe(0);
+ expect(result.totalTime).toBe(0);
+ });
+
+ it('should ensure currentTime is not negative', () => {
+ const connection = {
+ last_seek_percentage: 10,
+ last_seek_timestamp: 2000 // In the future somehow
+ };
+ const result = VodConnectionCardUtils.calculateProgress(connection, 200);
+
+ expect(result.currentTime).toBeGreaterThanOrEqual(0);
+ });
+ });
+
+ describe('calculateConnectionDuration', () => {
+ it('should use duration from connection when available', () => {
+ dateTimeUtils.toFriendlyDuration.mockReturnValue('1h 30m');
+ const connection = { duration: 5400 };
+
+ const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+
+ expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(5400, 'seconds');
+ expect(result).toBe('1h 30m');
+ });
+
+ it('should calculate duration from client_id timestamp when duration missing', () => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000);
+ dateTimeUtils.toFriendlyDuration.mockReturnValue('45m');
+
+ const connection = { client_id: 'vod_900000_abc' };
+ const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+
+ expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(100, 'seconds');
+ expect(result).toBe('45m');
+ });
+
+ it('should return Unknown duration when no data available', () => {
+ const connection = {};
+ const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+
+ expect(result).toBe('Unknown duration');
+ });
+
+ it('should return Unknown duration when client_id is invalid format', () => {
+ const connection = { client_id: 'invalid_format' };
+ const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+
+ expect(result).toBe('Unknown duration');
+ });
+
+ it('should handle parsing errors gracefully', () => {
+ dateTimeUtils.getNowMs.mockReturnValue(1000000);
+ dateTimeUtils.toFriendlyDuration.mockReturnValue('45m');
+
+ const connection = { client_id: 'vod_invalid_abc' };
+ const result = VodConnectionCardUtils.calculateConnectionDuration(connection);
+
+ // If parseInt fails, the code should still handle it
+ expect(result).toBe('45m'); // or 'Unknown duration' depending on implementation
+ });
+ });
+
+ describe('calculateConnectionStartTime', () => {
+ it('should format connected_at timestamp when available', () => {
+ dateTimeUtils.format.mockReturnValue('01/15/2024 14:30:00');
+
+ const connection = { connected_at: 1705329000 };
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
+
+ expect(dateTimeUtils.format).toHaveBeenCalledWith(1705329000000, 'MM/DD/YYYY HH:mm:ss');
+ expect(result).toBe('01/15/2024 14:30:00');
+ });
+
+ it('should calculate start time from client_id when connected_at missing', () => {
+ dateTimeUtils.format.mockReturnValue('01/15/2024 13:00:00');
+
+ const connection = { client_id: 'vod_1705323600000_abc' };
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
+
+ expect(dateTimeUtils.format).toHaveBeenCalledWith(1705323600000, 'MM/DD/YYYY HH:mm:ss');
+ expect(result).toBe('01/15/2024 13:00:00');
+ });
+
+ it('should return Unknown when no timestamp data available', () => {
+ const connection = {};
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
+
+ expect(result).toBe('Unknown');
+ });
+
+ it('should return Unknown when client_id is invalid format', () => {
+ const connection = { client_id: 'invalid_format' };
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
+
+ expect(result).toBe('Unknown');
+ });
+
+ it('should handle parsing errors gracefully', () => {
+ dateTimeUtils.format.mockReturnValue('01/15/2024 13:00:00');
+
+ const connection = { client_id: 'vod_notanumber_abc' };
+ const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY');
+
+ // If parseInt succeeds on any number, format will be called
+ expect(result).toBe('01/15/2024 13:00:00'); // or 'Unknown' depending on implementation
+ });
+
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js
new file mode 100644
index 00000000..af85dce4
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js
@@ -0,0 +1,633 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as RecordingDetailsModalUtils from '../RecordingDetailsModalUtils';
+import dayjs from 'dayjs';
+
+describe('RecordingDetailsModalUtils', () => {
+ describe('getStatRows', () => {
+ it('should return all stats when all values are present', () => {
+ const stats = {
+ video_codec: 'H.264',
+ resolution: '1920x1080',
+ width: 1920,
+ height: 1080,
+ source_fps: 30,
+ video_bitrate: 5000,
+ audio_codec: 'AAC',
+ audio_channels: 2,
+ sample_rate: 48000,
+ audio_bitrate: 128
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Video Codec', 'H.264'],
+ ['Resolution', '1920x1080'],
+ ['FPS', 30],
+ ['Video Bitrate', '5000 kb/s'],
+ ['Audio Codec', 'AAC'],
+ ['Audio Channels', 2],
+ ['Sample Rate', '48000 Hz'],
+ ['Audio Bitrate', '128 kb/s']
+ ]);
+ });
+
+ it('should use width x height when resolution is not present', () => {
+ const stats = {
+ width: 1280,
+ height: 720
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Resolution', '1280x720']
+ ]);
+ });
+
+ it('should prefer resolution over width/height', () => {
+ const stats = {
+ resolution: '1920x1080',
+ width: 1280,
+ height: 720
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Resolution', '1920x1080']
+ ]);
+ });
+
+ it('should filter out null values', () => {
+ const stats = {
+ video_codec: 'H.264',
+ resolution: null,
+ source_fps: 30
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Video Codec', 'H.264'],
+ ['FPS', 30]
+ ]);
+ });
+
+ it('should filter out undefined values', () => {
+ const stats = {
+ video_codec: 'H.264',
+ source_fps: undefined,
+ audio_codec: 'AAC'
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Video Codec', 'H.264'],
+ ['Audio Codec', 'AAC']
+ ]);
+ });
+
+ it('should filter out empty strings', () => {
+ const stats = {
+ video_codec: '',
+ audio_codec: 'AAC'
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Audio Codec', 'AAC']
+ ]);
+ });
+
+ it('should handle missing width or height gracefully', () => {
+ const stats = {
+ width: 1920,
+ video_codec: 'H.264'
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Video Codec', 'H.264']
+ ]);
+ });
+
+ it('should format bitrates correctly', () => {
+ const stats = {
+ video_bitrate: 2500,
+ audio_bitrate: 192
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Video Bitrate', '2500 kb/s'],
+ ['Audio Bitrate', '192 kb/s']
+ ]);
+ });
+
+ it('should format sample rate correctly', () => {
+ const stats = {
+ sample_rate: 44100
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([
+ ['Sample Rate', '44100 Hz']
+ ]);
+ });
+
+ it('should return empty array when no valid stats', () => {
+ const stats = {
+ video_codec: null,
+ resolution: undefined,
+ source_fps: ''
+ };
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle empty stats object', () => {
+ const stats = {};
+
+ const result = RecordingDetailsModalUtils.getStatRows(stats);
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('getRating', () => {
+ it('should return rating from customProps', () => {
+ const customProps = { rating: 'TV-MA' };
+ const program = null;
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('TV-MA');
+ });
+
+ it('should return rating_value when rating is not present', () => {
+ const customProps = { rating_value: 'PG-13' };
+ const program = null;
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('PG-13');
+ });
+
+ it('should prefer rating over rating_value', () => {
+ const customProps = { rating: 'TV-MA', rating_value: 'PG-13' };
+ const program = null;
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('TV-MA');
+ });
+
+ it('should return rating from program custom_properties', () => {
+ const customProps = {};
+ const program = {
+ custom_properties: { rating: 'TV-14' }
+ };
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('TV-14');
+ });
+
+ it('should prefer customProps rating over program rating', () => {
+ const customProps = { rating: 'TV-MA' };
+ const program = {
+ custom_properties: { rating: 'TV-14' }
+ };
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('TV-MA');
+ });
+
+ it('should prefer rating_value over program rating', () => {
+ const customProps = { rating_value: 'PG-13' };
+ const program = {
+ custom_properties: { rating: 'TV-14' }
+ };
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBe('PG-13');
+ });
+
+ it('should return undefined when no rating is available', () => {
+ const customProps = {};
+ const program = { custom_properties: {} };
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBeUndefined();
+ });
+
+ it('should handle null program', () => {
+ const customProps = {};
+ const program = null;
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBeNull();
+ });
+
+ it('should handle program without custom_properties', () => {
+ const customProps = {};
+ const program = { title: 'Test' };
+
+ const result = RecordingDetailsModalUtils.getRating(customProps, program);
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('getUpcomingEpisodes', () => {
+ let toUserTime;
+ let userNow;
+
+ beforeEach(() => {
+ const baseTime = dayjs('2024-01-01T12:00:00');
+ toUserTime = vi.fn((time) => dayjs(time));
+ userNow = vi.fn(() => baseTime);
+ });
+
+ it('should return empty array when not a series group', () => {
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ false,
+ [],
+ {},
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should return empty array when allRecordings is empty', () => {
+ const program = { tvg_id: 'test', title: 'Test Show' };
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ [],
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should filter recordings by tvg_id and title', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ },
+ {
+ start_time: '2024-01-02T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show2', title: 'Other Show' }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].custom_properties.program.tvg_id).toBe('show1');
+ });
+
+ it('should filter out past recordings', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2023-12-31T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ },
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].start_time).toBe('2024-01-02T12:00:00');
+ });
+
+ it('should deduplicate by season and episode', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ season: 1,
+ episode: 5,
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ },
+ {
+ start_time: '2024-01-02T18:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ season: 1,
+ episode: 5,
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should deduplicate by onscreen episode', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ onscreen_episode: 'S01E05',
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ },
+ {
+ start_time: '2024-01-02T18:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ onscreen_episode: 's01e05',
+ program: { tvg_id: 'show1', title: 'Test Show' }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should deduplicate by program sub_title', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: {
+ tvg_id: 'show1',
+ title: 'Test Show',
+ sub_title: 'The Beginning'
+ }
+ }
+ },
+ {
+ start_time: '2024-01-02T18:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: {
+ tvg_id: 'show1',
+ title: 'Test Show',
+ sub_title: 'The Beginning'
+ }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should deduplicate by program id', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 123 }
+ }
+ },
+ {
+ start_time: '2024-01-02T18:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 123 }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should sort by start time ascending', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-03T12:00:00',
+ end_time: '2024-01-03T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 3 }
+ }
+ },
+ {
+ start_time: '2024-01-02T12:00:00',
+ end_time: '2024-01-02T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 1 }
+ }
+ },
+ {
+ start_time: '2024-01-04T12:00:00',
+ end_time: '2024-01-04T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 4 }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(3);
+ expect(result[0].start_time).toBe('2024-01-02T12:00:00');
+ expect(result[1].start_time).toBe('2024-01-03T12:00:00');
+ expect(result[2].start_time).toBe('2024-01-04T12:00:00');
+ });
+
+ it('should handle allRecordings as object', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = {
+ rec1: {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Test Show', id: 1 }
+ }
+ }
+ };
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should handle case-insensitive title matching', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'test show' }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should prefer season/episode from program custom_properties', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: {
+ tvg_id: 'show1',
+ title: 'Test Show',
+ custom_properties: { season: 2, episode: 3 }
+ }
+ }
+ },
+ {
+ start_time: '2024-01-02T18:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: {
+ tvg_id: 'show1',
+ title: 'Test Show',
+ custom_properties: { season: 2, episode: 3 }
+ }
+ }
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toHaveLength(1);
+ });
+
+ it('should handle missing custom_properties', () => {
+ const program = { tvg_id: 'show1', title: 'Test Show' };
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ channel: 'ch1'
+ }
+ ];
+
+ const result = RecordingDetailsModalUtils.getUpcomingEpisodes(
+ true,
+ recordings,
+ program,
+ toUserTime,
+ userNow
+ );
+
+ expect(result).toEqual([]);
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js
new file mode 100644
index 00000000..e2cb95fd
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js
@@ -0,0 +1,533 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as RecurringRuleModalUtils from '../RecurringRuleModalUtils';
+import API from '../../../api.js';
+import dayjs from 'dayjs';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ updateRecurringRule: vi.fn(),
+ deleteRecurringRule: vi.fn()
+ }
+}));
+
+describe('RecurringRuleModalUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getChannelOptions', () => {
+ it('should return sorted channel options by channel number', () => {
+ const channels = {
+ ch1: { id: 1, channel_number: '10', name: 'ABC' },
+ ch2: { id: 2, channel_number: '5', name: 'NBC' },
+ ch3: { id: 3, channel_number: '15', name: 'CBS' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result).toEqual([
+ { value: '2', label: 'NBC' },
+ { value: '1', label: 'ABC' },
+ { value: '3', label: 'CBS' }
+ ]);
+ });
+
+ it('should sort alphabetically by name when channel numbers are equal', () => {
+ const channels = {
+ ch1: { id: 1, channel_number: '10', name: 'ZBC' },
+ ch2: { id: 2, channel_number: '10', name: 'ABC' },
+ ch3: { id: 3, channel_number: '10', name: 'MBC' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result).toEqual([
+ { value: '2', label: 'ABC' },
+ { value: '3', label: 'MBC' },
+ { value: '1', label: 'ZBC' }
+ ]);
+ });
+
+ it('should handle missing channel numbers', () => {
+ const channels = {
+ ch1: { id: 1, name: 'ABC' },
+ ch2: { id: 2, channel_number: '5', name: 'NBC' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result).toEqual([
+ { value: '1', label: 'ABC' },
+ { value: '2', label: 'NBC' }
+ ]);
+ });
+
+ it('should use fallback label when name is missing', () => {
+ const channels = {
+ ch1: { id: 1, channel_number: '10' },
+ ch2: { id: 2, channel_number: '5', name: '' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result).toEqual([
+ { value: '2', label: 'Channel 2' },
+ { value: '1', label: 'Channel 1' }
+ ]);
+ });
+
+ it('should handle empty channels object', () => {
+ const result = RecurringRuleModalUtils.getChannelOptions({});
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle null channels', () => {
+ const result = RecurringRuleModalUtils.getChannelOptions(null);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle undefined channels', () => {
+ const result = RecurringRuleModalUtils.getChannelOptions(undefined);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should convert channel id to string value', () => {
+ const channels = {
+ ch1: { id: 123, channel_number: '10', name: 'ABC' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result[0].value).toBe('123');
+ expect(typeof result[0].value).toBe('string');
+ });
+
+ it('should handle non-numeric channel numbers', () => {
+ const channels = {
+ ch1: { id: 1, channel_number: 'HD1', name: 'ABC' },
+ ch2: { id: 2, channel_number: '5', name: 'NBC' }
+ };
+
+ const result = RecurringRuleModalUtils.getChannelOptions(channels);
+
+ expect(result).toHaveLength(2);
+ });
+ });
+
+ describe('getUpcomingOccurrences', () => {
+ let toUserTime;
+ let userNow;
+
+ beforeEach(() => {
+ const baseTime = dayjs('2024-01-01T12:00:00');
+ toUserTime = vi.fn((time) => dayjs(time));
+ userNow = vi.fn(() => baseTime);
+ });
+
+ it('should filter recordings by rule id and future start time', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ {
+ start_time: '2024-01-03T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ {
+ start_time: '2024-01-04T12:00:00',
+ custom_properties: { rule: { id: 2 } }
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toHaveLength(2);
+ expect(result[0].custom_properties.rule.id).toBe(1);
+ expect(result[1].custom_properties.rule.id).toBe(1);
+ });
+
+ it('should exclude past recordings', () => {
+ const recordings = [
+ {
+ start_time: '2023-12-31T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].start_time).toBe('2024-01-02T12:00:00');
+ });
+
+ it('should sort by start time ascending', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-04T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ {
+ start_time: '2024-01-03T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toHaveLength(3);
+ expect(result[0].start_time).toBe('2024-01-02T12:00:00');
+ expect(result[1].start_time).toBe('2024-01-03T12:00:00');
+ expect(result[2].start_time).toBe('2024-01-04T12:00:00');
+ });
+
+ it('should handle recordings as object', () => {
+ const recordings = {
+ rec1: {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ },
+ rec2: {
+ start_time: '2024-01-03T12:00:00',
+ custom_properties: { rule: { id: 1 } }
+ }
+ };
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toHaveLength(2);
+ });
+
+ it('should handle empty recordings array', () => {
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ [],
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle null recordings', () => {
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ null,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle recordings without custom_properties', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00'
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle recordings without rule', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: {}
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle recordings with null rule', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-02T12:00:00',
+ custom_properties: { rule: null }
+ }
+ ];
+
+ const result = RecurringRuleModalUtils.getUpcomingOccurrences(
+ recordings,
+ userNow,
+ 1,
+ toUserTime
+ );
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('updateRecurringRule', () => {
+ it('should call API with formatted values', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: ['1', '3', '5'],
+ start_time: '14:30',
+ end_time: '16:00',
+ start_date: '2024-01-01',
+ end_date: '2024-12-31',
+ rule_name: 'My Rule',
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [1, 3, 5],
+ start_time: '14:30',
+ end_time: '16:00',
+ start_date: '2024-01-01',
+ end_date: '2024-12-31',
+ name: 'My Rule',
+ enabled: true
+ });
+ });
+
+ it('should convert days_of_week to numbers', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: ['0', '6'],
+ start_time: '10:00',
+ end_time: '11:00',
+ enabled: false
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [0, 6],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: '',
+ enabled: false
+ });
+ });
+
+ it('should handle empty days_of_week', async () => {
+ const values = {
+ channel_id: '5',
+ start_time: '10:00',
+ end_time: '11:00',
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: '',
+ enabled: true
+ });
+ });
+
+ it('should format dates correctly', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: dayjs('2024-06-15'),
+ end_date: dayjs('2024-12-25'),
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: '2024-06-15',
+ end_date: '2024-12-25',
+ name: '',
+ enabled: true
+ });
+ });
+
+ it('should handle null dates', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: '',
+ enabled: true
+ });
+ });
+
+ it('should trim rule name', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ rule_name: ' Trimmed Name ',
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: 'Trimmed Name',
+ enabled: true
+ });
+ });
+
+ it('should handle missing rule_name', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ enabled: true
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: '',
+ enabled: true
+ });
+ });
+
+ it('should convert enabled to boolean', async () => {
+ const values = {
+ channel_id: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ enabled: 'true'
+ };
+
+ await RecurringRuleModalUtils.updateRecurringRule(1, values);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ channel: '5',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: null,
+ end_date: null,
+ name: '',
+ enabled: true
+ });
+ });
+ });
+
+ describe('deleteRecurringRuleById', () => {
+ it('should call API deleteRecurringRule with rule id', async () => {
+ await RecurringRuleModalUtils.deleteRecurringRuleById(123);
+
+ expect(API.deleteRecurringRule).toHaveBeenCalledWith(123);
+ expect(API.deleteRecurringRule).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle string rule id', async () => {
+ await RecurringRuleModalUtils.deleteRecurringRuleById('456');
+
+ expect(API.deleteRecurringRule).toHaveBeenCalledWith('456');
+ });
+ });
+
+ describe('updateRecurringRuleEnabled', () => {
+ it('should call API updateRecurringRule with enabled true', async () => {
+ await RecurringRuleModalUtils.updateRecurringRuleEnabled(1, true);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ enabled: true
+ });
+ });
+
+ it('should call API updateRecurringRule with enabled false', async () => {
+ await RecurringRuleModalUtils.updateRecurringRuleEnabled(1, false);
+
+ expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
+ enabled: false
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js
new file mode 100644
index 00000000..38d36c9b
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js
@@ -0,0 +1,92 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as DvrSettingsFormUtils from '../DvrSettingsFormUtils';
+import API from '../../../../api.js';
+
+vi.mock('../../../../api.js');
+
+describe('DvrSettingsFormUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getComskipConfig', () => {
+ it('should call API.getComskipConfig and return result', async () => {
+ const mockConfig = {
+ enabled: true,
+ custom_path: '/path/to/comskip'
+ };
+ API.getComskipConfig.mockResolvedValue(mockConfig);
+
+ const result = await DvrSettingsFormUtils.getComskipConfig();
+
+ expect(API.getComskipConfig).toHaveBeenCalledWith();
+ expect(result).toEqual(mockConfig);
+ });
+
+ it('should handle API errors', async () => {
+ const error = new Error('API Error');
+ API.getComskipConfig.mockRejectedValue(error);
+
+ await expect(DvrSettingsFormUtils.getComskipConfig()).rejects.toThrow('API Error');
+ });
+ });
+
+ describe('uploadComskipIni', () => {
+ it('should call API.uploadComskipIni with file and return result', async () => {
+ const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' });
+ const mockResponse = { success: true };
+ API.uploadComskipIni.mockResolvedValue(mockResponse);
+
+ const result = await DvrSettingsFormUtils.uploadComskipIni(mockFile);
+
+ expect(API.uploadComskipIni).toHaveBeenCalledWith(mockFile);
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle API errors', async () => {
+ const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' });
+ const error = new Error('Upload failed');
+ API.uploadComskipIni.mockRejectedValue(error);
+
+ await expect(DvrSettingsFormUtils.uploadComskipIni(mockFile)).rejects.toThrow('Upload failed');
+ });
+ });
+
+ describe('getDvrSettingsFormInitialValues', () => {
+ it('should return initial values with all DVR settings', () => {
+ const result = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
+
+ expect(result).toEqual({
+ 'dvr-tv-template': '',
+ 'dvr-movie-template': '',
+ 'dvr-tv-fallback-template': '',
+ 'dvr-movie-fallback-template': '',
+ 'dvr-comskip-enabled': false,
+ 'dvr-comskip-custom-path': '',
+ 'dvr-pre-offset-minutes': 0,
+ 'dvr-post-offset-minutes': 0,
+ });
+ });
+
+ it('should return a new object each time', () => {
+ const result1 = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
+ const result2 = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+
+ it('should have correct default types', () => {
+ const result = DvrSettingsFormUtils.getDvrSettingsFormInitialValues();
+
+ expect(typeof result['dvr-tv-template']).toBe('string');
+ expect(typeof result['dvr-movie-template']).toBe('string');
+ expect(typeof result['dvr-tv-fallback-template']).toBe('string');
+ expect(typeof result['dvr-movie-fallback-template']).toBe('string');
+ expect(typeof result['dvr-comskip-enabled']).toBe('boolean');
+ expect(typeof result['dvr-comskip-custom-path']).toBe('string');
+ expect(typeof result['dvr-pre-offset-minutes']).toBe('number');
+ expect(typeof result['dvr-post-offset-minutes']).toBe('number');
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js
new file mode 100644
index 00000000..d924b430
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js
@@ -0,0 +1,132 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as NetworkAccessFormUtils from '../NetworkAccessFormUtils';
+import * as constants from '../../../../constants.js';
+
+vi.mock('../../../../constants.js', () => ({
+ NETWORK_ACCESS_OPTIONS: {}
+}));
+
+vi.mock('../../../networkUtils.js', () => ({
+ IPV4_CIDR_REGEX: /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/,
+ IPV6_CIDR_REGEX: /^([0-9a-fA-F:]+)\/\d{1,3}$/
+}));
+
+describe('NetworkAccessFormUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getNetworkAccessFormInitialValues', () => {
+ it('should return initial values for all network access options', () => {
+ vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
+ 'network-access-admin': 'Admin Access',
+ 'network-access-api': 'API Access',
+ 'network-access-streaming': 'Streaming Access'
+ };
+
+ const result = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
+
+ expect(result).toEqual({
+ 'network-access-admin': '0.0.0.0/0,::/0',
+ 'network-access-api': '0.0.0.0/0,::/0',
+ 'network-access-streaming': '0.0.0.0/0,::/0'
+ });
+ });
+
+ it('should return empty object when NETWORK_ACCESS_OPTIONS is empty', () => {
+ vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {};
+
+ const result = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
+
+ expect(result).toEqual({});
+ });
+
+ it('should return a new object each time', () => {
+ vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
+ 'network-access-admin': 'Admin Access'
+ };
+
+ const result1 = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
+ const result2 = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+ });
+
+ describe('getNetworkAccessFormValidation', () => {
+ beforeEach(() => {
+ vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
+ 'network-access-admin': 'Admin Access',
+ 'network-access-api': 'API Access'
+ };
+ });
+
+ it('should return validation functions for all network access options', () => {
+ const result = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+
+ expect(Object.keys(result)).toEqual(['network-access-admin', 'network-access-api']);
+ expect(typeof result['network-access-admin']).toBe('function');
+ expect(typeof result['network-access-api']).toBe('function');
+ });
+
+ it('should validate valid IPv4 CIDR ranges', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('192.168.1.0/24')).toBeNull();
+ expect(validator('10.0.0.0/8')).toBeNull();
+ expect(validator('0.0.0.0/0')).toBeNull();
+ });
+
+ it('should validate valid IPv6 CIDR ranges', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('2001:db8::/32')).toBeNull();
+ expect(validator('::/0')).toBeNull();
+ });
+
+ it('should validate multiple CIDR ranges separated by commas', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('192.168.1.0/24,10.0.0.0/8')).toBeNull();
+ expect(validator('0.0.0.0/0,::/0')).toBeNull();
+ expect(validator('192.168.1.0/24,2001:db8::/32')).toBeNull();
+ });
+
+ it('should return error for invalid IPv4 CIDR ranges', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('192.168.1.256.1/24')).toBe('Invalid CIDR range');
+ expect(validator('invalid')).toBe('Invalid CIDR range');
+ expect(validator('192.168.1.0/256')).toBe('Invalid CIDR range');
+ });
+
+ it('should return error when any CIDR in comma-separated list is invalid', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('192.168.1.0/24,invalid')).toBe('Invalid CIDR range');
+ expect(validator('invalid,192.168.1.0/24')).toBe('Invalid CIDR range');
+ expect(validator('192.168.1.0/24,10.0.0.0/8,invalid')).toBe('Invalid CIDR range');
+ });
+
+ it('should handle empty strings', () => {
+ const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+ const validator = validation['network-access-admin'];
+
+ expect(validator('')).toBe('Invalid CIDR range');
+ });
+
+ it('should return empty object when NETWORK_ACCESS_OPTIONS is empty', () => {
+ vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {};
+
+ const result = NetworkAccessFormUtils.getNetworkAccessFormValidation();
+
+ expect(result).toEqual({});
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js
new file mode 100644
index 00000000..d6fe3008
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js
@@ -0,0 +1,83 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as ProxySettingsFormUtils from '../ProxySettingsFormUtils';
+import * as constants from '../../../../constants.js';
+
+vi.mock('../../../../constants.js', () => ({
+ PROXY_SETTINGS_OPTIONS: {}
+}));
+
+describe('ProxySettingsFormUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getProxySettingsFormInitialValues', () => {
+ it('should return initial values for all proxy settings options', () => {
+ vi.mocked(constants).PROXY_SETTINGS_OPTIONS = {
+ 'proxy-buffering-timeout': 'Buffering Timeout',
+ 'proxy-buffering-speed': 'Buffering Speed',
+ 'proxy-redis-chunk-ttl': 'Redis Chunk TTL'
+ };
+
+ const result = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
+
+ expect(result).toEqual({
+ 'proxy-buffering-timeout': '',
+ 'proxy-buffering-speed': '',
+ 'proxy-redis-chunk-ttl': ''
+ });
+ });
+
+ it('should return empty object when PROXY_SETTINGS_OPTIONS is empty', () => {
+ vi.mocked(constants).PROXY_SETTINGS_OPTIONS = {};
+
+ const result = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
+
+ expect(result).toEqual({});
+ });
+
+ it('should return a new object each time', () => {
+ vi.mocked(constants).PROXY_SETTINGS_OPTIONS = {
+ 'proxy-setting': 'Proxy Setting'
+ };
+
+ const result1 = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
+ const result2 = ProxySettingsFormUtils.getProxySettingsFormInitialValues();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+ });
+
+ describe('getProxySettingDefaults', () => {
+ it('should return default proxy settings', () => {
+ const result = ProxySettingsFormUtils.getProxySettingDefaults();
+
+ expect(result).toEqual({
+ buffering_timeout: 15,
+ buffering_speed: 1.0,
+ redis_chunk_ttl: 60,
+ channel_shutdown_delay: 0,
+ channel_init_grace_period: 5,
+ });
+ });
+
+ it('should return a new object each time', () => {
+ const result1 = ProxySettingsFormUtils.getProxySettingDefaults();
+ const result2 = ProxySettingsFormUtils.getProxySettingDefaults();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+
+ it('should have correct default types', () => {
+ const result = ProxySettingsFormUtils.getProxySettingDefaults();
+
+ expect(typeof result.buffering_timeout).toBe('number');
+ expect(typeof result.buffering_speed).toBe('number');
+ expect(typeof result.redis_chunk_ttl).toBe('number');
+ expect(typeof result.channel_shutdown_delay).toBe('number');
+ expect(typeof result.channel_init_grace_period).toBe('number');
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js
new file mode 100644
index 00000000..8b22e768
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js
@@ -0,0 +1,106 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as StreamSettingsFormUtils from '../StreamSettingsFormUtils';
+import { isNotEmpty } from '@mantine/form';
+
+vi.mock('@mantine/form', () => ({
+ isNotEmpty: vi.fn((message) => message)
+}));
+
+describe('StreamSettingsFormUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getStreamSettingsFormInitialValues', () => {
+ it('should return initial values with correct defaults', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+
+ expect(result).toEqual({
+ 'default-user-agent': '',
+ 'default-stream-profile': '',
+ 'preferred-region': '',
+ 'auto-import-mapped-files': true,
+ 'm3u-hash-key': []
+ });
+ });
+
+ it('should return boolean true for auto-import-mapped-files', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+
+ expect(result['auto-import-mapped-files']).toBe(true);
+ expect(typeof result['auto-import-mapped-files']).toBe('boolean');
+ });
+
+ it('should return empty array for m3u-hash-key', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+
+ expect(result['m3u-hash-key']).toEqual([]);
+ expect(Array.isArray(result['m3u-hash-key'])).toBe(true);
+ });
+
+ it('should return a new object each time', () => {
+ const result1 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+ const result2 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+
+ it('should return a new array instance for m3u-hash-key each time', () => {
+ const result1 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+ const result2 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues();
+
+ expect(result1['m3u-hash-key']).not.toBe(result2['m3u-hash-key']);
+ });
+ });
+
+ describe('getStreamSettingsFormValidation', () => {
+ it('should return validation functions for required fields', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(Object.keys(result)).toEqual([
+ 'default-user-agent',
+ 'default-stream-profile',
+ 'preferred-region'
+ ]);
+ });
+
+ it('should use isNotEmpty validator for default-user-agent', () => {
+ StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(isNotEmpty).toHaveBeenCalledWith('Select a user agent');
+ });
+
+ it('should use isNotEmpty validator for default-stream-profile', () => {
+ StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(isNotEmpty).toHaveBeenCalledWith('Select a stream profile');
+ });
+
+ it('should use isNotEmpty validator for preferred-region', () => {
+ StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(isNotEmpty).toHaveBeenCalledWith('Select a region');
+ });
+
+ it('should not include validation for auto-import-mapped-files', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(result).not.toHaveProperty('auto-import-mapped-files');
+ });
+
+ it('should not include validation for m3u-hash-key', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(result).not.toHaveProperty('m3u-hash-key');
+ });
+
+ it('should return correct validation error messages', () => {
+ const result = StreamSettingsFormUtils.getStreamSettingsFormValidation();
+
+ expect(result['default-user-agent']).toBe('Select a user agent');
+ expect(result['default-stream-profile']).toBe('Select a stream profile');
+ expect(result['preferred-region']).toBe('Select a region');
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js
new file mode 100644
index 00000000..a2722194
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js
@@ -0,0 +1,35 @@
+import { describe, it, expect } from 'vitest';
+import * as SystemSettingsFormUtils from '../SystemSettingsFormUtils';
+
+describe('SystemSettingsFormUtils', () => {
+ describe('getSystemSettingsFormInitialValues', () => {
+ it('should return initial values with correct defaults', () => {
+ const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+
+ expect(result).toEqual({
+ 'max-system-events': 100
+ });
+ });
+
+ it('should return number value for max-system-events', () => {
+ const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+
+ expect(result['max-system-events']).toBe(100);
+ expect(typeof result['max-system-events']).toBe('number');
+ });
+
+ it('should return a new object each time', () => {
+ const result1 = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+ const result2 = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+
+ expect(result1).toEqual(result2);
+ expect(result1).not.toBe(result2);
+ });
+
+ it('should have max-system-events property', () => {
+ const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues();
+
+ expect(result).toHaveProperty('max-system-events');
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js
new file mode 100644
index 00000000..c5471edc
--- /dev/null
+++ b/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js
@@ -0,0 +1,147 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as UiSettingsFormUtils from '../UiSettingsFormUtils';
+import * as SettingsUtils from '../../../pages/SettingsUtils.js';
+
+vi.mock('../../../pages/SettingsUtils.js', () => ({
+ createSetting: vi.fn(),
+ updateSetting: vi.fn()
+}));
+
+describe('UiSettingsFormUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('saveTimeZoneSetting', () => {
+ it('should update existing setting when id is present', async () => {
+ const tzValue = 'America/New_York';
+ const settings = {
+ 'system-time-zone': {
+ id: 123,
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'UTC'
+ }
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.updateSetting).toHaveBeenCalledTimes(1);
+ expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({
+ id: 123,
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'America/New_York'
+ });
+ expect(SettingsUtils.createSetting).not.toHaveBeenCalled();
+ });
+
+ it('should create new setting when existing setting has no id', async () => {
+ const tzValue = 'Europe/London';
+ const settings = {
+ 'system-time-zone': {
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'UTC'
+ }
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
+ expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'Europe/London'
+ });
+ expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
+ });
+
+ it('should create new setting when system-time-zone does not exist', async () => {
+ const tzValue = 'Asia/Tokyo';
+ const settings = {};
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
+ expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'Asia/Tokyo'
+ });
+ expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
+ });
+
+ it('should create new setting when system-time-zone is null', async () => {
+ const tzValue = 'Pacific/Auckland';
+ const settings = {
+ 'system-time-zone': null
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
+ expect(SettingsUtils.createSetting).toHaveBeenCalledWith({
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'Pacific/Auckland'
+ });
+ expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
+ });
+
+ it('should create new setting when id is undefined', async () => {
+ const tzValue = 'America/Los_Angeles';
+ const settings = {
+ 'system-time-zone': {
+ id: undefined,
+ key: 'system-time-zone',
+ value: 'UTC'
+ }
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1);
+ expect(SettingsUtils.updateSetting).not.toHaveBeenCalled();
+ });
+
+ it('should preserve existing properties when updating', async () => {
+ const tzValue = 'UTC';
+ const settings = {
+ 'system-time-zone': {
+ id: 456,
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'America/New_York',
+ extraProp: 'should be preserved'
+ }
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({
+ id: 456,
+ key: 'system-time-zone',
+ name: 'System Time Zone',
+ value: 'UTC',
+ extraProp: 'should be preserved'
+ });
+ });
+
+ it('should handle empty string timezone value', async () => {
+ const tzValue = '';
+ const settings = {
+ 'system-time-zone': {
+ id: 789
+ }
+ };
+
+ await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings);
+
+ expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({
+ id: 789,
+ value: ''
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/pages/__tests__/DVRUtils.test.js b/frontend/src/utils/pages/__tests__/DVRUtils.test.js
new file mode 100644
index 00000000..9c5bb15f
--- /dev/null
+++ b/frontend/src/utils/pages/__tests__/DVRUtils.test.js
@@ -0,0 +1,539 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as DVRUtils from '../DVRUtils';
+import dayjs from 'dayjs';
+
+describe('DVRUtils', () => {
+ describe('categorizeRecordings', () => {
+ let toUserTime;
+ let now;
+
+ beforeEach(() => {
+ const baseTime = dayjs('2024-01-01T12:00:00');
+ toUserTime = vi.fn((time) => dayjs(time));
+ now = baseTime;
+ });
+
+ it('should categorize in-progress recordings', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress).toHaveLength(1);
+ expect(result.inProgress[0].id).toBe(1);
+ expect(result.upcoming).toHaveLength(0);
+ expect(result.completed).toHaveLength(0);
+ });
+
+ it('should categorize upcoming recordings', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ expect(result.upcoming[0].id).toBe(1);
+ expect(result.inProgress).toHaveLength(0);
+ expect(result.completed).toHaveLength(0);
+ });
+
+ it('should categorize completed recordings by status', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T10:00:00',
+ end_time: '2024-01-01T11:00:00',
+ channel: 'ch1',
+ custom_properties: { status: 'completed' }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.completed).toHaveLength(1);
+ expect(result.completed[0].id).toBe(1);
+ expect(result.inProgress).toHaveLength(0);
+ expect(result.upcoming).toHaveLength(0);
+ });
+
+ it('should categorize interrupted recordings as completed', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: { status: 'interrupted' }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.completed).toHaveLength(1);
+ expect(result.inProgress).toHaveLength(0);
+ expect(result.upcoming).toHaveLength(0);
+ });
+
+ it('should categorize past recordings without status as completed', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T09:00:00',
+ end_time: '2024-01-01T10:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.completed).toHaveLength(1);
+ expect(result.inProgress).toHaveLength(0);
+ expect(result.upcoming).toHaveLength(0);
+ });
+
+ it('should deduplicate in-progress by program id', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { id: 100 }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: { id: 100 }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress).toHaveLength(1);
+ });
+
+ it('should deduplicate in-progress by channel+slot when no program id', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress).toHaveLength(1);
+ });
+
+ it('should not deduplicate different channels', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: { title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress).toHaveLength(2);
+ });
+
+ it('should sort in-progress by start_time descending', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T10:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1',
+ custom_properties: { program: { id: 1 } }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T11:30:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch2',
+ custom_properties: { program: { id: 2 } }
+ },
+ {
+ id: 3,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch3',
+ custom_properties: { program: { id: 3 } }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress[0].id).toBe(2);
+ expect(result.inProgress[1].id).toBe(3);
+ expect(result.inProgress[2].id).toBe(1);
+ });
+
+ it('should group upcoming by series and keep first episode', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T15:00:00',
+ end_time: '2024-01-01T16:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 3,
+ start_time: '2024-01-01T16:00:00',
+ end_time: '2024-01-01T17:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ expect(result.upcoming[0].id).toBe(1);
+ expect(result.upcoming[0]._group_count).toBe(3);
+ });
+
+ it('should group upcoming case-insensitively by title', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T15:00:00',
+ end_time: '2024-01-01T16:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'show a' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ expect(result.upcoming[0]._group_count).toBe(2);
+ });
+
+ it('should not group upcoming with different tvg_id', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T15:00:00',
+ end_time: '2024-01-01T16:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show2', title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(2);
+ expect(result.upcoming[0]._group_count).toBe(1);
+ expect(result.upcoming[1]._group_count).toBe(1);
+ });
+
+ it('should sort upcoming by start_time ascending', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T16:00:00',
+ end_time: '2024-01-01T17:00:00',
+ channel: 'ch1',
+ custom_properties: { program: { id: 1, tvg_id: 'show1', title: 'Show A' } }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch2',
+ custom_properties: { program: { id: 2, tvg_id: 'show2', title: 'Show B' } }
+ },
+ {
+ id: 3,
+ start_time: '2024-01-01T15:00:00',
+ end_time: '2024-01-01T16:00:00',
+ channel: 'ch3',
+ custom_properties: { program: { id: 3, tvg_id: 'show3', title: 'Show C' } }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming[0].id).toBe(2);
+ expect(result.upcoming[1].id).toBe(3);
+ expect(result.upcoming[2].id).toBe(1);
+ });
+
+
+ it('should sort completed by end_time descending', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T08:00:00',
+ end_time: '2024-01-01T09:00:00',
+ channel: 'ch1',
+ custom_properties: { status: 'completed' }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T10:00:00',
+ end_time: '2024-01-01T11:00:00',
+ channel: 'ch2',
+ custom_properties: { status: 'completed' }
+ },
+ {
+ id: 3,
+ start_time: '2024-01-01T09:00:00',
+ end_time: '2024-01-01T10:00:00',
+ channel: 'ch3',
+ custom_properties: { status: 'completed' }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.completed[0].id).toBe(2);
+ expect(result.completed[1].id).toBe(3);
+ expect(result.completed[2].id).toBe(1);
+ });
+
+ it('should handle recordings as object', () => {
+ const recordings = {
+ rec1: {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ };
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ });
+
+ it('should handle empty recordings array', () => {
+ const result = DVRUtils.categorizeRecordings([], toUserTime, now);
+
+ expect(result.inProgress).toEqual([]);
+ expect(result.upcoming).toEqual([]);
+ expect(result.completed).toEqual([]);
+ });
+
+ it('should handle null recordings', () => {
+ const result = DVRUtils.categorizeRecordings(null, toUserTime, now);
+
+ expect(result.inProgress).toEqual([]);
+ expect(result.upcoming).toEqual([]);
+ expect(result.completed).toEqual([]);
+ });
+
+ it('should deduplicate by recording id', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ },
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ });
+
+ it('should handle recordings without custom_properties', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T11:00:00',
+ end_time: '2024-01-01T13:00:00',
+ channel: 'ch1'
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.inProgress).toHaveLength(1);
+ });
+
+ it('should handle recordings without program', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ expect(result.upcoming[0]._group_count).toBe(1);
+ });
+
+ it('should handle recording without id', () => {
+ const recordings = [
+ {
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {}
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ });
+
+ it('should deduplicate upcoming by program id before grouping', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { id: 100, tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 2,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch2',
+ custom_properties: {
+ program: { id: 100, tvg_id: 'show1', title: 'Show A' }
+ }
+ },
+ {
+ id: 3,
+ start_time: '2024-01-01T15:00:00',
+ end_time: '2024-01-01T16:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { id: 101, tvg_id: 'show1', title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming).toHaveLength(1);
+ expect(result.upcoming[0]._group_count).toBe(2);
+ });
+
+ it('should preserve _group_count property on grouped recordings', () => {
+ const recordings = [
+ {
+ id: 1,
+ start_time: '2024-01-01T14:00:00',
+ end_time: '2024-01-01T15:00:00',
+ channel: 'ch1',
+ custom_properties: {
+ program: { tvg_id: 'show1', title: 'Show A' }
+ }
+ }
+ ];
+
+ const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now);
+
+ expect(result.upcoming[0]._group_count).toBe(1);
+ });
+ });
+});
diff --git a/frontend/src/utils/pages/__tests__/PluginsUtils.test.js b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js
new file mode 100644
index 00000000..5d305290
--- /dev/null
+++ b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js
@@ -0,0 +1,269 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as PluginsUtils from '../PluginsUtils';
+import API from '../../../api.js';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ updatePluginSettings: vi.fn(),
+ runPluginAction: vi.fn(),
+ setPluginEnabled: vi.fn(),
+ importPlugin: vi.fn(),
+ deletePlugin: vi.fn()
+ }
+}));
+
+describe('PluginsUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('updatePluginSettings', () => {
+ it('should call API updatePluginSettings with key and settings', async () => {
+ const key = 'test-plugin';
+ const settings = { option1: 'value1', option2: true };
+
+ await PluginsUtils.updatePluginSettings(key, settings);
+
+ expect(API.updatePluginSettings).toHaveBeenCalledWith(key, settings);
+ expect(API.updatePluginSettings).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return API response', async () => {
+ const key = 'test-plugin';
+ const settings = { enabled: true };
+ const mockResponse = { success: true };
+
+ API.updatePluginSettings.mockResolvedValue(mockResponse);
+
+ const result = await PluginsUtils.updatePluginSettings(key, settings);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle empty settings object', async () => {
+ const key = 'test-plugin';
+ const settings = {};
+
+ await PluginsUtils.updatePluginSettings(key, settings);
+
+ expect(API.updatePluginSettings).toHaveBeenCalledWith(key, {});
+ });
+
+ it('should handle null settings', async () => {
+ const key = 'test-plugin';
+ const settings = null;
+
+ await PluginsUtils.updatePluginSettings(key, settings);
+
+ expect(API.updatePluginSettings).toHaveBeenCalledWith(key, null);
+ });
+
+ it('should propagate API errors', async () => {
+ const key = 'test-plugin';
+ const settings = { enabled: true };
+ const error = new Error('API error');
+
+ API.updatePluginSettings.mockRejectedValue(error);
+
+ await expect(PluginsUtils.updatePluginSettings(key, settings)).rejects.toThrow('API error');
+ });
+ });
+
+ describe('runPluginAction', () => {
+ it('should call API runPluginAction with key and actionId', async () => {
+ const key = 'test-plugin';
+ const actionId = 'refresh-data';
+
+ await PluginsUtils.runPluginAction(key, actionId);
+
+ expect(API.runPluginAction).toHaveBeenCalledWith(key, actionId);
+ expect(API.runPluginAction).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return API response', async () => {
+ const key = 'test-plugin';
+ const actionId = 'sync';
+ const mockResponse = { status: 'completed' };
+
+ API.runPluginAction.mockResolvedValue(mockResponse);
+
+ const result = await PluginsUtils.runPluginAction(key, actionId);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle numeric actionId', async () => {
+ const key = 'test-plugin';
+ const actionId = 123;
+
+ await PluginsUtils.runPluginAction(key, actionId);
+
+ expect(API.runPluginAction).toHaveBeenCalledWith(key, 123);
+ });
+
+ it('should propagate API errors', async () => {
+ const key = 'test-plugin';
+ const actionId = 'invalid-action';
+ const error = new Error('Action not found');
+
+ API.runPluginAction.mockRejectedValue(error);
+
+ await expect(PluginsUtils.runPluginAction(key, actionId)).rejects.toThrow('Action not found');
+ });
+ });
+
+ describe('setPluginEnabled', () => {
+ it('should call API setPluginEnabled with key and next value', async () => {
+ const key = 'test-plugin';
+ const next = true;
+
+ await PluginsUtils.setPluginEnabled(key, next);
+
+ expect(API.setPluginEnabled).toHaveBeenCalledWith(key, true);
+ expect(API.setPluginEnabled).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle false value', async () => {
+ const key = 'test-plugin';
+ const next = false;
+
+ await PluginsUtils.setPluginEnabled(key, next);
+
+ expect(API.setPluginEnabled).toHaveBeenCalledWith(key, false);
+ });
+
+ it('should return API response', async () => {
+ const key = 'test-plugin';
+ const next = true;
+ const mockResponse = { enabled: true };
+
+ API.setPluginEnabled.mockResolvedValue(mockResponse);
+
+ const result = await PluginsUtils.setPluginEnabled(key, next);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle truthy values', async () => {
+ const key = 'test-plugin';
+ const next = 'yes';
+
+ await PluginsUtils.setPluginEnabled(key, next);
+
+ expect(API.setPluginEnabled).toHaveBeenCalledWith(key, 'yes');
+ });
+
+ it('should handle falsy values', async () => {
+ const key = 'test-plugin';
+ const next = 0;
+
+ await PluginsUtils.setPluginEnabled(key, next);
+
+ expect(API.setPluginEnabled).toHaveBeenCalledWith(key, 0);
+ });
+
+ it('should propagate API errors', async () => {
+ const key = 'test-plugin';
+ const next = true;
+ const error = new Error('Plugin not found');
+
+ API.setPluginEnabled.mockRejectedValue(error);
+
+ await expect(PluginsUtils.setPluginEnabled(key, next)).rejects.toThrow('Plugin not found');
+ });
+ });
+
+ describe('importPlugin', () => {
+ it('should call API importPlugin with importFile', async () => {
+ const importFile = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+
+ await PluginsUtils.importPlugin(importFile);
+
+ expect(API.importPlugin).toHaveBeenCalledWith(importFile);
+ expect(API.importPlugin).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return API response', async () => {
+ const importFile = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ const mockResponse = { key: 'imported-plugin', success: true };
+
+ API.importPlugin.mockResolvedValue(mockResponse);
+
+ const result = await PluginsUtils.importPlugin(importFile);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle string file path', async () => {
+ const importFile = '/path/to/plugin.zip';
+
+ await PluginsUtils.importPlugin(importFile);
+
+ expect(API.importPlugin).toHaveBeenCalledWith(importFile);
+ });
+
+ it('should handle FormData', async () => {
+ const formData = new FormData();
+ formData.append('file', new File(['content'], 'plugin.zip'));
+
+ await PluginsUtils.importPlugin(formData);
+
+ expect(API.importPlugin).toHaveBeenCalledWith(formData);
+ });
+
+ it('should propagate API errors', async () => {
+ const importFile = new File(['content'], 'plugin.zip', { type: 'application/zip' });
+ const error = new Error('Invalid plugin format');
+
+ API.importPlugin.mockRejectedValue(error);
+
+ await expect(PluginsUtils.importPlugin(importFile)).rejects.toThrow('Invalid plugin format');
+ });
+ });
+
+ describe('deletePluginByKey', () => {
+ it('should call API deletePlugin with key', () => {
+ const key = 'test-plugin';
+
+ PluginsUtils.deletePluginByKey(key);
+
+ expect(API.deletePlugin).toHaveBeenCalledWith(key);
+ expect(API.deletePlugin).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return API response', () => {
+ const key = 'test-plugin';
+ const mockResponse = { success: true };
+
+ API.deletePlugin.mockReturnValue(mockResponse);
+
+ const result = PluginsUtils.deletePluginByKey(key);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should handle numeric key', () => {
+ const key = 123;
+
+ PluginsUtils.deletePluginByKey(key);
+
+ expect(API.deletePlugin).toHaveBeenCalledWith(123);
+ });
+
+ it('should handle empty string key', () => {
+ const key = '';
+
+ PluginsUtils.deletePluginByKey(key);
+
+ expect(API.deletePlugin).toHaveBeenCalledWith('');
+ });
+
+ it('should handle null key', () => {
+ const key = null;
+
+ PluginsUtils.deletePluginByKey(key);
+
+ expect(API.deletePlugin).toHaveBeenCalledWith(null);
+ });
+ });
+});
diff --git a/frontend/src/utils/pages/__tests__/SettingsUtils.test.js b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js
new file mode 100644
index 00000000..9bf20b13
--- /dev/null
+++ b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js
@@ -0,0 +1,558 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as SettingsUtils from '../SettingsUtils';
+import API from '../../../api.js';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ checkSetting: vi.fn(),
+ updateSetting: vi.fn(),
+ createSetting: vi.fn(),
+ rehashStreams: vi.fn()
+ }
+}));
+
+describe('SettingsUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('checkSetting', () => {
+ it('should call API checkSetting with values', async () => {
+ const values = { key: 'test-setting', value: 'test-value' };
+
+ await SettingsUtils.checkSetting(values);
+
+ expect(API.checkSetting).toHaveBeenCalledWith(values);
+ expect(API.checkSetting).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return API response', async () => {
+ const values = { key: 'test-setting', value: 'test-value' };
+ const mockResponse = { valid: true };
+
+ API.checkSetting.mockResolvedValue(mockResponse);
+
+ const result = await SettingsUtils.checkSetting(values);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should propagate API errors', async () => {
+ const values = { key: 'test-setting', value: 'test-value' };
+ const error = new Error('API error');
+
+ API.checkSetting.mockRejectedValue(error);
+
+ await expect(SettingsUtils.checkSetting(values)).rejects.toThrow('API error');
+ });
+ });
+
+ describe('updateSetting', () => {
+ it('should call API updateSetting with values', async () => {
+ const values = { id: 1, key: 'test-setting', value: 'new-value' };
+
+ await SettingsUtils.updateSetting(values);
+
+ expect(API.updateSetting).toHaveBeenCalledWith(values);
+ expect(API.updateSetting).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return API response', async () => {
+ const values = { id: 1, key: 'test-setting', value: 'new-value' };
+ const mockResponse = { id: 1, value: 'new-value' };
+
+ API.updateSetting.mockResolvedValue(mockResponse);
+
+ const result = await SettingsUtils.updateSetting(values);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should propagate API errors', async () => {
+ const values = { id: 1, key: 'test-setting', value: 'new-value' };
+ const error = new Error('Update failed');
+
+ API.updateSetting.mockRejectedValue(error);
+
+ await expect(SettingsUtils.updateSetting(values)).rejects.toThrow('Update failed');
+ });
+ });
+
+ describe('createSetting', () => {
+ it('should call API createSetting with values', async () => {
+ const values = { key: 'new-setting', name: 'New Setting', value: 'value' };
+
+ await SettingsUtils.createSetting(values);
+
+ expect(API.createSetting).toHaveBeenCalledWith(values);
+ expect(API.createSetting).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return API response', async () => {
+ const values = { key: 'new-setting', name: 'New Setting', value: 'value' };
+ const mockResponse = { id: 1, ...values };
+
+ API.createSetting.mockResolvedValue(mockResponse);
+
+ const result = await SettingsUtils.createSetting(values);
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should propagate API errors', async () => {
+ const values = { key: 'new-setting', name: 'New Setting', value: 'value' };
+ const error = new Error('Create failed');
+
+ API.createSetting.mockRejectedValue(error);
+
+ await expect(SettingsUtils.createSetting(values)).rejects.toThrow('Create failed');
+ });
+ });
+
+ describe('rehashStreams', () => {
+ it('should call API rehashStreams', async () => {
+ await SettingsUtils.rehashStreams();
+
+ expect(API.rehashStreams).toHaveBeenCalledWith();
+ expect(API.rehashStreams).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return API response', async () => {
+ const mockResponse = { success: true };
+
+ API.rehashStreams.mockResolvedValue(mockResponse);
+
+ const result = await SettingsUtils.rehashStreams();
+
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should propagate API errors', async () => {
+ const error = new Error('Rehash failed');
+
+ API.rehashStreams.mockRejectedValue(error);
+
+ await expect(SettingsUtils.rehashStreams()).rejects.toThrow('Rehash failed');
+ });
+ });
+
+ describe('saveChangedSettings', () => {
+ it('should update existing settings', async () => {
+ const settings = {
+ 'setting-1': { id: 1, key: 'setting-1', value: 'old-value' }
+ };
+ const changedSettings = {
+ 'setting-1': 'new-value'
+ };
+
+ API.updateSetting.mockResolvedValue({ id: 1, value: 'new-value' });
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.updateSetting).toHaveBeenCalledWith({
+ id: 1,
+ key: 'setting-1',
+ value: 'new-value'
+ });
+ });
+
+ it('should create new settings when not in settings object', async () => {
+ const settings = {};
+ const changedSettings = {
+ 'new-setting': 'value'
+ };
+
+ API.createSetting.mockResolvedValue({ id: 1, key: 'new-setting', value: 'value' });
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.createSetting).toHaveBeenCalledWith({
+ key: 'new-setting',
+ name: 'new setting',
+ value: 'value'
+ });
+ });
+
+ it('should create new settings when existing has no id', async () => {
+ const settings = {
+ 'setting-1': { key: 'setting-1', value: 'old-value' }
+ };
+ const changedSettings = {
+ 'setting-1': 'new-value'
+ };
+
+ API.createSetting.mockResolvedValue({ id: 1, key: 'setting-1', value: 'new-value' });
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.createSetting).toHaveBeenCalledWith({
+ key: 'setting-1',
+ name: 'setting 1',
+ value: 'new-value'
+ });
+ });
+
+ it('should replace hyphens with spaces in name', async () => {
+ const settings = {};
+ const changedSettings = {
+ 'multi-word-setting': 'value'
+ };
+
+ API.createSetting.mockResolvedValue({ id: 1 });
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.createSetting).toHaveBeenCalledWith({
+ key: 'multi-word-setting',
+ name: 'multi word setting',
+ value: 'value'
+ });
+ });
+
+ it('should throw error when update fails', async () => {
+ const settings = {
+ 'setting-1': { id: 1, key: 'setting-1', value: 'old-value' }
+ };
+ const changedSettings = {
+ 'setting-1': 'new-value'
+ };
+
+ API.updateSetting.mockResolvedValue(undefined);
+
+ await expect(
+ SettingsUtils.saveChangedSettings(settings, changedSettings)
+ ).rejects.toThrow('Failed to update setting');
+ });
+
+ it('should throw error when create fails', async () => {
+ const settings = {};
+ const changedSettings = {
+ 'new-setting': 'value'
+ };
+
+ API.createSetting.mockResolvedValue(undefined);
+
+ await expect(
+ SettingsUtils.saveChangedSettings(settings, changedSettings)
+ ).rejects.toThrow('Failed to create setting');
+ });
+
+ it('should process multiple changed settings', async () => {
+ const settings = {
+ 'setting-1': { id: 1, key: 'setting-1', value: 'old-value-1' },
+ 'setting-2': { id: 2, key: 'setting-2', value: 'old-value-2' }
+ };
+ const changedSettings = {
+ 'setting-1': 'new-value-1',
+ 'setting-2': 'new-value-2',
+ 'setting-3': 'new-value-3'
+ };
+
+ API.updateSetting.mockResolvedValue({ success: true });
+ API.createSetting.mockResolvedValue({ success: true });
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.updateSetting).toHaveBeenCalledTimes(2);
+ expect(API.createSetting).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle empty changedSettings', async () => {
+ const settings = {
+ 'setting-1': { id: 1, key: 'setting-1', value: 'value' }
+ };
+ const changedSettings = {};
+
+ await SettingsUtils.saveChangedSettings(settings, changedSettings);
+
+ expect(API.updateSetting).not.toHaveBeenCalled();
+ expect(API.createSetting).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('getChangedSettings', () => {
+ it('should detect changed values', () => {
+ const values = {
+ 'setting-1': 'new-value'
+ };
+ const settings = {
+ 'setting-1': { id: 1, key: 'setting-1', value: 'old-value' }
+ };
+
+ const result = SettingsUtils.getChangedSettings(values, settings);
+
+ expect(result).toEqual({
+ 'setting-1': 'new-value'
+ });
+ });
+
+ it('should include new settings not in settings object', () => {
+ const values = {
+ 'new-setting': 'value'
+ };
+ const settings = {};
+
+ const result = SettingsUtils.getChangedSettings(values, settings);
+
+ expect(result).toEqual({
+ 'new-setting': 'value'
+ });
+ });
+
+ it('should skip unchanged values', () => {
+ const values = {
+ 'setting-1': 'same-value'
+ };
+ const settings = {
+ 'setting-1': { id: 1, key: 'setting-1', value: 'same-value' }
+ };
+
+ const result = SettingsUtils.getChangedSettings(values, settings);
+
+ expect(result).toEqual({});
+ });
+
+ it('should convert array values to comma-separated strings', () => {
+ const values = {
+ 'm3u-hash-key': ['key1', 'key2', 'key3']
+ };
+ const settings = {
+ 'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: 'old-value' }
+ };
+
+ const result = SettingsUtils.getChangedSettings(values, settings);
+
+ expect(result).toEqual({
+ 'm3u-hash-key': 'key1,key2,key3'
+ });
+ });
+
+ it('should skip empty string values', () => {
+ const values = {
+ 'setting-1': '',
+ 'setting-2': 'value'
+ };
+ const settings = {};
+
+ const result = SettingsUtils.getChangedSettings(values, settings);
+
+ expect(result).toEqual({
+ 'setting-2': 'value'
+ });
+ });
+
+ it('should skip empty array values', () => {
+ const values = {
+ 'setting-1': [],
+ 'setting-2': ['value']
+ };
+ const settings = {};
+
+ const result = SettingsUtils.getChangedSettings(values, settings);
+
+ expect(result).toEqual({
+ 'setting-2': 'value'
+ });
+ });
+
+ it('should convert non-string values to strings', () => {
+ const values = {
+ 'setting-1': 123,
+ 'setting-2': true,
+ 'setting-3': false
+ };
+ const settings = {};
+
+ const result = SettingsUtils.getChangedSettings(values, settings);
+
+ expect(result).toEqual({
+ 'setting-1': '123',
+ 'setting-2': 'true',
+ 'setting-3': 'false'
+ });
+ });
+
+ it('should compare string values correctly', () => {
+ const values = {
+ 'setting-1': 'value',
+ 'setting-2': 123
+ };
+ const settings = {
+ 'setting-1': { id: 1, key: 'setting-1', value: 'value' },
+ 'setting-2': { id: 2, key: 'setting-2', value: 123 }
+ };
+
+ const result = SettingsUtils.getChangedSettings(values, settings);
+
+ expect(result).toEqual({});
+ });
+ });
+
+ describe('parseSettings', () => {
+ it('should convert string "true" to boolean true', () => {
+ const settings = {
+ 'setting-1': { id: 1, key: 'setting-1', value: 'true' }
+ };
+
+ const result = SettingsUtils.parseSettings(settings);
+
+ expect(result).toEqual({
+ 'setting-1': true
+ });
+ });
+
+ it('should convert string "false" to boolean false', () => {
+ const settings = {
+ 'setting-1': { id: 1, key: 'setting-1', value: 'false' }
+ };
+
+ const result = SettingsUtils.parseSettings(settings);
+
+ expect(result).toEqual({
+ 'setting-1': false
+ });
+ });
+
+ it('should parse m3u-hash-key as array', () => {
+ const settings = {
+ 'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: 'key1,key2,key3' }
+ };
+
+ const result = SettingsUtils.parseSettings(settings);
+
+ expect(result).toEqual({
+ 'm3u-hash-key': ['key1', 'key2', 'key3']
+ });
+ });
+
+ it('should filter empty strings from m3u-hash-key array', () => {
+ const settings = {
+ 'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: 'key1,,key2,' }
+ };
+
+ const result = SettingsUtils.parseSettings(settings);
+
+ expect(result).toEqual({
+ 'm3u-hash-key': ['key1', 'key2']
+ });
+ });
+
+ it('should return empty array for empty m3u-hash-key', () => {
+ const settings = {
+ 'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: '' }
+ };
+
+ const result = SettingsUtils.parseSettings(settings);
+
+ expect(result).toEqual({
+ 'm3u-hash-key': []
+ });
+ });
+
+ it('should return empty array for null m3u-hash-key', () => {
+ const settings = {
+ 'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: null }
+ };
+
+ const result = SettingsUtils.parseSettings(settings);
+
+ expect(result).toEqual({
+ 'm3u-hash-key': []
+ });
+ });
+
+ it('should parse dvr-pre-offset-minutes as integer', () => {
+ const settings = {
+ 'dvr-pre-offset-minutes': { id: 1, key: 'dvr-pre-offset-minutes', value: '5' }
+ };
+
+ const result = SettingsUtils.parseSettings(settings);
+
+ expect(result).toEqual({
+ 'dvr-pre-offset-minutes': 5
+ });
+ });
+
+ it('should parse dvr-post-offset-minutes as integer', () => {
+ const settings = {
+ 'dvr-post-offset-minutes': { id: 1, key: 'dvr-post-offset-minutes', value: '10' }
+ };
+
+ const result = SettingsUtils.parseSettings(settings);
+
+ expect(result).toEqual({
+ 'dvr-post-offset-minutes': 10
+ });
+ });
+
+ it('should default offset minutes to 0 for empty string', () => {
+ const settings = {
+ 'dvr-pre-offset-minutes': { id: 1, key: 'dvr-pre-offset-minutes', value: '' },
+ 'dvr-post-offset-minutes': { id: 2, key: 'dvr-post-offset-minutes', value: '' }
+ };
+
+ const result = SettingsUtils.parseSettings(settings);
+
+ expect(result).toEqual({
+ 'dvr-pre-offset-minutes': 0,
+ 'dvr-post-offset-minutes': 0
+ });
+ });
+
+ it('should default offset minutes to 0 for NaN', () => {
+ const settings = {
+ 'dvr-pre-offset-minutes': { id: 1, key: 'dvr-pre-offset-minutes', value: 'invalid' },
+ 'dvr-post-offset-minutes': { id: 2, key: 'dvr-post-offset-minutes', value: 'abc' }
+ };
+
+ const result = SettingsUtils.parseSettings(settings);
+
+ expect(result).toEqual({
+ 'dvr-pre-offset-minutes': 0,
+ 'dvr-post-offset-minutes': 0
+ });
+ });
+
+ it('should keep other values unchanged', () => {
+ const settings = {
+ 'setting-1': { id: 1, key: 'setting-1', value: 'test-value' },
+ 'setting-2': { id: 2, key: 'setting-2', value: 123 }
+ };
+
+ const result = SettingsUtils.parseSettings(settings);
+
+ expect(result).toEqual({
+ 'setting-1': 'test-value',
+ 'setting-2': 123
+ });
+ });
+
+ it('should handle empty settings object', () => {
+ const result = SettingsUtils.parseSettings({});
+
+ expect(result).toEqual({});
+ });
+
+ it('should process multiple settings with mixed types', () => {
+ const settings = {
+ 'enabled': { id: 1, key: 'enabled', value: 'true' },
+ 'disabled': { id: 2, key: 'disabled', value: 'false' },
+ 'm3u-hash-key': { id: 3, key: 'm3u-hash-key', value: 'key1,key2' },
+ 'dvr-pre-offset-minutes': { id: 4, key: 'dvr-pre-offset-minutes', value: '5' },
+ 'dvr-post-offset-minutes': { id: 5, key: 'dvr-post-offset-minutes', value: '10' },
+ 'other-setting': { id: 6, key: 'other-setting', value: 'value' }
+ };
+
+ const result = SettingsUtils.parseSettings(settings);
+
+ expect(result).toEqual({
+ 'enabled': true,
+ 'disabled': false,
+ 'm3u-hash-key': ['key1', 'key2'],
+ 'dvr-pre-offset-minutes': 5,
+ 'dvr-post-offset-minutes': 10,
+ 'other-setting': 'value'
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/pages/__tests__/StatsUtils.test.js b/frontend/src/utils/pages/__tests__/StatsUtils.test.js
new file mode 100644
index 00000000..ccd422b1
--- /dev/null
+++ b/frontend/src/utils/pages/__tests__/StatsUtils.test.js
@@ -0,0 +1,654 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as StatsUtils from '../StatsUtils';
+import API from '../../../api.js';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ stopChannel: vi.fn(),
+ stopClient: vi.fn(),
+ stopVODClient: vi.fn(),
+ fetchActiveChannelStats: vi.fn(),
+ getVODStats: vi.fn()
+ }
+}));
+
+describe('StatsUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('stopChannel', () => {
+ it('should call API stopChannel with id', async () => {
+ const id = 'channel-123';
+
+ await StatsUtils.stopChannel(id);
+
+ expect(API.stopChannel).toHaveBeenCalledWith('channel-123');
+ expect(API.stopChannel).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle numeric id', async () => {
+ const id = 123;
+
+ await StatsUtils.stopChannel(id);
+
+ expect(API.stopChannel).toHaveBeenCalledWith(123);
+ });
+
+ it('should propagate API errors', async () => {
+ const id = 'channel-123';
+ const error = new Error('Failed to stop channel');
+
+ API.stopChannel.mockRejectedValue(error);
+
+ await expect(StatsUtils.stopChannel(id)).rejects.toThrow('Failed to stop channel');
+ });
+ });
+
+ describe('stopClient', () => {
+ it('should call API stopClient with channelId and clientId', async () => {
+ const channelId = 'channel-123';
+ const clientId = 'client-456';
+
+ await StatsUtils.stopClient(channelId, clientId);
+
+ expect(API.stopClient).toHaveBeenCalledWith('channel-123', 'client-456');
+ expect(API.stopClient).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle numeric ids', async () => {
+ const channelId = 123;
+ const clientId = 456;
+
+ await StatsUtils.stopClient(channelId, clientId);
+
+ expect(API.stopClient).toHaveBeenCalledWith(123, 456);
+ });
+
+ it('should propagate API errors', async () => {
+ const channelId = 'channel-123';
+ const clientId = 'client-456';
+ const error = new Error('Failed to stop client');
+
+ API.stopClient.mockRejectedValue(error);
+
+ await expect(StatsUtils.stopClient(channelId, clientId)).rejects.toThrow('Failed to stop client');
+ });
+ });
+
+ describe('stopVODClient', () => {
+ it('should call API stopVODClient with clientId', async () => {
+ const clientId = 'vod-client-123';
+
+ await StatsUtils.stopVODClient(clientId);
+
+ expect(API.stopVODClient).toHaveBeenCalledWith('vod-client-123');
+ expect(API.stopVODClient).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle numeric clientId', async () => {
+ const clientId = 123;
+
+ await StatsUtils.stopVODClient(clientId);
+
+ expect(API.stopVODClient).toHaveBeenCalledWith(123);
+ });
+
+ it('should propagate API errors', async () => {
+ const clientId = 'vod-client-123';
+ const error = new Error('Failed to stop VOD client');
+
+ API.stopVODClient.mockRejectedValue(error);
+
+ await expect(StatsUtils.stopVODClient(clientId)).rejects.toThrow('Failed to stop VOD client');
+ });
+ });
+
+ describe('fetchActiveChannelStats', () => {
+ it('should call API fetchActiveChannelStats', async () => {
+ const mockStats = { channels: [] };
+
+ API.fetchActiveChannelStats.mockResolvedValue(mockStats);
+
+ const result = await StatsUtils.fetchActiveChannelStats();
+
+ expect(API.fetchActiveChannelStats).toHaveBeenCalledWith();
+ expect(API.fetchActiveChannelStats).toHaveBeenCalledTimes(1);
+ expect(result).toEqual(mockStats);
+ });
+
+ it('should propagate API errors', async () => {
+ const error = new Error('Failed to fetch stats');
+
+ API.fetchActiveChannelStats.mockRejectedValue(error);
+
+ await expect(StatsUtils.fetchActiveChannelStats()).rejects.toThrow('Failed to fetch stats');
+ });
+ });
+
+ describe('getVODStats', () => {
+ it('should call API getVODStats', async () => {
+ const mockStats = [{ content_type: 'movie', connections: [] }];
+
+ API.getVODStats.mockResolvedValue(mockStats);
+
+ const result = await StatsUtils.getVODStats();
+
+ expect(API.getVODStats).toHaveBeenCalledWith();
+ expect(API.getVODStats).toHaveBeenCalledTimes(1);
+ expect(result).toEqual(mockStats);
+ });
+
+ it('should propagate API errors', async () => {
+ const error = new Error('Failed to fetch VOD stats');
+
+ API.getVODStats.mockRejectedValue(error);
+
+ await expect(StatsUtils.getVODStats()).rejects.toThrow('Failed to fetch VOD stats');
+ });
+ });
+
+ describe('getCombinedConnections', () => {
+ it('should combine channel history and VOD connections', () => {
+ const channelHistory = {
+ 'ch1': { channel_id: 'ch1', uptime: 100 }
+ };
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: [
+ { client_id: 'client1', connected_at: 50 }
+ ]
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections(channelHistory, vodConnections);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].type).toBe('stream');
+ expect(result[1].type).toBe('vod');
+ });
+
+ it('should sort by sortKey descending (newest first)', () => {
+ const channelHistory = {
+ 'ch1': { channel_id: 'ch1', uptime: 50 }
+ };
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: [
+ { client_id: 'client1', connected_at: 100 }
+ ]
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections(channelHistory, vodConnections);
+
+ expect(result[0].sortKey).toBe(100);
+ expect(result[1].sortKey).toBe(50);
+ });
+
+ it('should flatten VOD connections to individual cards', () => {
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: [
+ { client_id: 'client1', connected_at: 100 },
+ { client_id: 'client2', connected_at: 200 }
+ ]
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections({}, vodConnections);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].data.connections).toHaveLength(1);
+ expect(result[0].data.connection_count).toBe(1);
+ expect(result[0].data.individual_connection.client_id).toBe('client2');
+ expect(result[1].data.individual_connection.client_id).toBe('client1');
+ });
+
+ it('should create unique IDs for VOD items', () => {
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: [
+ { client_id: 'client1', connected_at: 100 },
+ { client_id: 'client2', connected_at: 200 }
+ ]
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections({}, vodConnections);
+
+ expect(result[0].id).toBe('movie-uuid1-client2-1');
+ expect(result[1].id).toBe('movie-uuid1-client1-0');
+ });
+
+ it('should use uptime for stream sortKey', () => {
+ const channelHistory = {
+ 'ch1': { channel_id: 'ch1', uptime: 150 }
+ };
+
+ const result = StatsUtils.getCombinedConnections(channelHistory, []);
+
+ expect(result[0].sortKey).toBe(150);
+ });
+
+ it('should default to 0 for missing uptime', () => {
+ const channelHistory = {
+ 'ch1': { channel_id: 'ch1' }
+ };
+
+ const result = StatsUtils.getCombinedConnections(channelHistory, []);
+
+ expect(result[0].sortKey).toBe(0);
+ });
+
+ it('should use connected_at for VOD sortKey', () => {
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: [
+ { client_id: 'client1', connected_at: 250 }
+ ]
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections({}, vodConnections);
+
+ expect(result[0].sortKey).toBe(250);
+ });
+
+ it('should handle empty connections array', () => {
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: []
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections({}, vodConnections);
+
+ expect(result).toHaveLength(0);
+ });
+
+ it('should handle empty inputs', () => {
+ const result = StatsUtils.getCombinedConnections({}, []);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle null connections', () => {
+ const vodConnections = [
+ {
+ content_type: 'movie',
+ content_uuid: 'uuid1',
+ connections: null
+ }
+ ];
+
+ const result = StatsUtils.getCombinedConnections({}, vodConnections);
+
+ expect(result).toHaveLength(0);
+ });
+ });
+
+ describe('getClientStats', () => {
+ it('should extract clients from channel stats', () => {
+ const stats = {
+ 'ch1': {
+ channel_id: 'ch1',
+ clients: [
+ { client_id: 'client1' },
+ { client_id: 'client2' }
+ ]
+ }
+ };
+
+ const result = StatsUtils.getClientStats(stats);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].client_id).toBe('client1');
+ expect(result[0].channel.channel_id).toBe('ch1');
+ });
+
+ it('should attach channel reference to each client', () => {
+ const stats = {
+ 'ch1': {
+ channel_id: 'ch1',
+ name: 'Channel 1',
+ clients: [
+ { client_id: 'client1' }
+ ]
+ }
+ };
+
+ const result = StatsUtils.getClientStats(stats);
+
+ expect(result[0].channel).toEqual({
+ channel_id: 'ch1',
+ name: 'Channel 1',
+ clients: [{ client_id: 'client1' }]
+ });
+ });
+
+ it('should handle channels without clients array', () => {
+ const stats = {
+ 'ch1': { channel_id: 'ch1' },
+ 'ch2': { channel_id: 'ch2', clients: null }
+ };
+
+ const result = StatsUtils.getClientStats(stats);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle empty clients array', () => {
+ const stats = {
+ 'ch1': {
+ channel_id: 'ch1',
+ clients: []
+ }
+ };
+
+ const result = StatsUtils.getClientStats(stats);
+
+ expect(result).toEqual([]);
+ });
+
+ it('should combine clients from multiple channels', () => {
+ const stats = {
+ 'ch1': {
+ channel_id: 'ch1',
+ clients: [{ client_id: 'client1' }]
+ },
+ 'ch2': {
+ channel_id: 'ch2',
+ clients: [{ client_id: 'client2' }]
+ }
+ };
+
+ const result = StatsUtils.getClientStats(stats);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].channel.channel_id).toBe('ch1');
+ expect(result[1].channel.channel_id).toBe('ch2');
+ });
+
+ it('should handle empty stats object', () => {
+ const result = StatsUtils.getClientStats({});
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('getStatsByChannelId', () => {
+ it('should create stats indexed by channel_id', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', total_bytes: 1000 }
+ ]
+ };
+ const prevChannelHistory = {};
+ const channelsByUUID = {};
+ const channels = {};
+ const streamProfiles = [];
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ prevChannelHistory,
+ channelsByUUID,
+ channels,
+ streamProfiles
+ );
+
+ expect(result).toHaveProperty('ch1');
+ expect(result.ch1.channel_id).toBe('ch1');
+ });
+
+ it('should calculate bitrates from previous history', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', total_bytes: 2000 }
+ ]
+ };
+ const prevChannelHistory = {
+ 'ch1': {
+ total_bytes: 1000,
+ bitrates: [500]
+ }
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ prevChannelHistory,
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.bitrates).toEqual([500, 1000]);
+ });
+
+ it('should limit bitrates array to 15 entries', () => {
+ const prevBitrates = new Array(15).fill(100);
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', total_bytes: 2000 }
+ ]
+ };
+ const prevChannelHistory = {
+ 'ch1': {
+ total_bytes: 1000,
+ bitrates: prevBitrates
+ }
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ prevChannelHistory,
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.bitrates).toHaveLength(15);
+ expect(result.ch1.bitrates[0]).toBe(100);
+ expect(result.ch1.bitrates[14]).toBe(1000);
+ });
+
+ it('should skip negative bitrates', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', total_bytes: 500 }
+ ]
+ };
+ const prevChannelHistory = {
+ 'ch1': {
+ total_bytes: 1000,
+ bitrates: []
+ }
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ prevChannelHistory,
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.bitrates).toEqual([]);
+ });
+
+ it('should merge channel data from channelsByUUID', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'uuid1', total_bytes: 1000 }
+ ]
+ };
+ const channelsByUUID = {
+ 'uuid1': 'channel-key-1'
+ };
+ const channels = {
+ 'channel-key-1': {
+ name: 'Channel 1',
+ logo: 'logo.png'
+ }
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ channelsByUUID,
+ channels,
+ []
+ );
+
+ expect(result.uuid1.name).toBe('Channel 1');
+ expect(result.uuid1.logo).toBe('logo.png');
+ });
+
+ it('should find and attach stream profile', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', stream_profile: '1' }
+ ]
+ };
+ const streamProfiles = [
+ { id: 1, name: 'HD Profile' },
+ { id: 2, name: 'SD Profile' }
+ ];
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ streamProfiles
+ );
+
+ expect(result.ch1.stream_profile.name).toBe('HD Profile');
+ });
+
+ it('should default to Unknown for missing stream profile', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', stream_profile: '999' }
+ ]
+ };
+ const streamProfiles = [
+ { id: 1, name: 'HD Profile' }
+ ];
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ streamProfiles
+ );
+
+ expect(result.ch1.stream_profile.name).toBe('Unknown');
+ });
+
+ it('should preserve stream_id from channel stats', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', stream_id: 'stream-123' }
+ ]
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.stream_id).toBe('stream-123');
+ });
+
+ it('should set stream_id to null if missing', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1' }
+ ]
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.stream_id).toBeNull();
+ });
+
+ it('should skip channels without channel_id', () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const channelStats = {
+ channels: [
+ { total_bytes: 1000 },
+ { channel_id: 'ch1', total_bytes: 2000 }
+ ]
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ []
+ );
+
+ expect(result).not.toHaveProperty('undefined');
+ expect(result).toHaveProperty('ch1');
+ expect(consoleSpy).toHaveBeenCalledWith('Found channel without channel_id:', { total_bytes: 1000 });
+
+ consoleSpy.mockRestore();
+ });
+
+ it('should handle empty channels array', () => {
+ const channelStats = { channels: [] };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ []
+ );
+
+ expect(result).toEqual({});
+ });
+
+ it('should initialize empty bitrates array for new channels', () => {
+ const channelStats = {
+ channels: [
+ { channel_id: 'ch1', total_bytes: 1000 }
+ ]
+ };
+
+ const result = StatsUtils.getStatsByChannelId(
+ channelStats,
+ {},
+ {},
+ {},
+ []
+ );
+
+ expect(result.ch1.bitrates).toEqual([]);
+ });
+ });
+});
diff --git a/frontend/src/utils/pages/__tests__/VODsUtils.test.js b/frontend/src/utils/pages/__tests__/VODsUtils.test.js
new file mode 100644
index 00000000..e058ff0e
--- /dev/null
+++ b/frontend/src/utils/pages/__tests__/VODsUtils.test.js
@@ -0,0 +1,272 @@
+import { describe, it, expect } from 'vitest';
+import * as VODsUtils from '../VODsUtils';
+
+describe('VODsUtils', () => {
+ describe('getCategoryOptions', () => {
+ it('should return all categories option plus formatted categories', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' }
+ };
+ const filters = { type: 'all' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(3);
+ expect(result[0]).toEqual({ value: '', label: 'All Categories' });
+ expect(result[1]).toEqual({ value: 'Action|movie', label: 'Action (movie)' });
+ expect(result[2]).toEqual({ value: 'Drama|series', label: 'Drama (series)' });
+ });
+
+ it('should filter to only movies when type is movies', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' },
+ 'cat3': { name: 'Comedy', category_type: 'movie' }
+ };
+ const filters = { type: 'movies' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(3);
+ expect(result[0]).toEqual({ value: '', label: 'All Categories' });
+ expect(result[1].label).toContain('(movie)');
+ expect(result[2].label).toContain('(movie)');
+ });
+
+ it('should filter to only series when type is series', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' },
+ 'cat3': { name: 'Sitcom', category_type: 'series' }
+ };
+ const filters = { type: 'series' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(3);
+ expect(result[0]).toEqual({ value: '', label: 'All Categories' });
+ expect(result[1].label).toContain('(series)');
+ expect(result[2].label).toContain('(series)');
+ });
+
+ it('should show all categories when type is all', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' }
+ };
+ const filters = { type: 'all' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(3);
+ });
+
+ it('should handle empty categories object', () => {
+ const categories = {};
+ const filters = { type: 'all' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual({ value: '', label: 'All Categories' });
+ });
+
+ it('should create value with name and category_type separated by pipe', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' }
+ };
+ const filters = { type: 'all' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result[1].value).toBe('Action|movie');
+ });
+
+ it('should handle undefined type filter', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' }
+ };
+ const filters = {};
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(3);
+ });
+
+ it('should filter out categories that do not match type', () => {
+ const categories = {
+ 'cat1': { name: 'Action', category_type: 'movie' },
+ 'cat2': { name: 'Drama', category_type: 'series' },
+ 'cat3': { name: 'Comedy', category_type: 'movie' }
+ };
+ const filters = { type: 'series' };
+
+ const result = VODsUtils.getCategoryOptions(categories, filters);
+
+ expect(result).toHaveLength(2);
+ expect(result[1].value).toBe('Drama|series');
+ });
+ });
+
+ describe('filterCategoriesToEnabled', () => {
+ it('should return only categories with enabled m3u_accounts', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [
+ { id: 1, enabled: true }
+ ]
+ },
+ 'cat2': {
+ name: 'Drama',
+ m3u_accounts: [
+ { id: 2, enabled: false }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).toHaveProperty('cat1');
+ expect(result).not.toHaveProperty('cat2');
+ });
+
+ it('should include category if any m3u_account is enabled', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [
+ { id: 1, enabled: false },
+ { id: 2, enabled: true },
+ { id: 3, enabled: false }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).toHaveProperty('cat1');
+ });
+
+ it('should exclude category if all m3u_accounts are disabled', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [
+ { id: 1, enabled: false },
+ { id: 2, enabled: false }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).not.toHaveProperty('cat1');
+ });
+
+ it('should exclude category with empty m3u_accounts array', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: []
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).not.toHaveProperty('cat1');
+ });
+
+ it('should preserve original category data', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ category_type: 'movie',
+ m3u_accounts: [
+ { id: 1, enabled: true }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result.cat1).toEqual(allCategories.cat1);
+ });
+
+ it('should handle empty allCategories object', () => {
+ const result = VODsUtils.filterCategoriesToEnabled({});
+
+ expect(result).toEqual({});
+ });
+
+ it('should filter multiple categories correctly', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [{ id: 1, enabled: true }]
+ },
+ 'cat2': {
+ name: 'Drama',
+ m3u_accounts: [{ id: 2, enabled: false }]
+ },
+ 'cat3': {
+ name: 'Comedy',
+ m3u_accounts: [{ id: 3, enabled: true }]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(Object.keys(result)).toHaveLength(2);
+ expect(result).toHaveProperty('cat1');
+ expect(result).toHaveProperty('cat3');
+ expect(result).not.toHaveProperty('cat2');
+ });
+
+ it('should handle category with null m3u_accounts', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: null
+ }
+ };
+
+ expect(() => {
+ VODsUtils.filterCategoriesToEnabled(allCategories);
+ }).toThrow();
+ });
+
+ it('should handle truthy enabled values', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [
+ { id: 1, enabled: 1 },
+ { id: 2, enabled: false }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).not.toHaveProperty('cat1');
+ });
+
+ it('should only match strict true for enabled', () => {
+ const allCategories = {
+ 'cat1': {
+ name: 'Action',
+ m3u_accounts: [
+ { id: 1, enabled: 'true' }
+ ]
+ }
+ };
+
+ const result = VODsUtils.filterCategoriesToEnabled(allCategories);
+
+ expect(result).not.toHaveProperty('cat1');
+ });
+ });
+});