Squashed commit of the following:

commit df18a89d0562edc8fd8fb5bc4cac702aefb5272c
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sat Jan 10 19:18:23 2026 -0800

    Updated tests

commit 90240344b89717fbad0e16fe209dbf00c567b1a8
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sun Jan 4 03:18:41 2026 -0800

    Updated tests

commit 525b7cb32bc8d235613706d6795795a0177ea24b
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sun Jan 4 03:18:31 2026 -0800

    Extracted component and util logic

commit e54ea2c3173c0ce3cfb0a2d70d76fdd0a66accc8
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Wed Dec 31 11:55:40 2025 -0800

    Updated tests

commit 5cbe164cb9818d8eab607af037da5faee2c1556f
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Wed Dec 31 11:55:14 2025 -0800

    Minor changes

    Exporting UiSettingsForm as default
    Reverted admin level type check

commit f9ab0d2a06091a2eed3ee6f34268c81bfd746f1e
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Tue Dec 30 23:31:29 2025 -0800

    Extracted component and util logic

commit a705a4db4a32d0851d087a984111837a0a83f722
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sun Dec 28 00:47:29 2025 -0800

    Updated tests

commit a72c6720a3980d0f279edf050b6b51eaae11cdbd
Merge: e8dcab6f 43525ca3
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sun Dec 28 00:04:24 2025 -0800

    Merge branch 'enhancement/component-cleanup' into test/component-cleanup

commit e8dcab6f832570cb986f114cfa574db4994b3aab
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sat Dec 27 22:35:59 2025 -0800

    Updated tests

commit 0fd230503844fba0c418ab0a03c46dc878697a55
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sat Dec 27 22:35:53 2025 -0800

    Added plugins store

commit d987f2de72272f24e26b1ed5bc04bb5c83033868
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sat Dec 27 22:35:43 2025 -0800

    Extracted component and util logic

commit 5a3138370a468a99c9f1ed0a36709a173656d809
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Wed Dec 24 23:13:07 2025 -0800

    Lazy-loading button modals

commit ac6945b5b55e0e16d050d4412a20c82f19250c4b
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Wed Dec 24 22:41:51 2025 -0800

    Extracted notification util

commit befe159fc06b67ee415f7498b5400fee0dc82528
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Wed Dec 24 22:28:12 2025 -0800

    Extracted component and util logic

commit ec10a3a4200a0c94cae29691a9fe06e5c4317bb7
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Wed Dec 24 22:22:09 2025 -0800

    Updated tests

commit c1c7214c8589c0ce7645ea24418d9dd978ac8c1f
Merge: eba6dce7 9c9cbab9
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Tue Dec 23 12:41:25 2025 -0800

    Merge branch 'enhancement/component-cleanup' into test/component-cleanup

commit eba6dce786495e352d4696030500db41d028036e
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sun Dec 21 10:12:19 2025 -0800

    Updated style props

commit 2024b0b267b849a5f100e5543b9188e8ad6dd3d9
Merge: b3700956 1029eb5b
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sun Dec 21 09:27:21 2025 -0800

    Merge branch 'enhancement/component-cleanup' into test/component-cleanup

commit b3700956a4c2f473f1e977826f9537d27ea018ae
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Thu Dec 18 07:45:36 2025 -0800

    Reverted Channels change

commit 137cbb02473b7f2f41488601e3b64e5ff45ac656
Merge: 644ed001 2a0df81c
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Wed Dec 17 13:36:05 2025 -0800

    Merge branch 'enhancement/component-cleanup' into test/component-cleanup

commit 644ed00196c41eaa44df1b98236b7e5cc3124d82
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Wed Dec 17 13:29:13 2025 -0800

    Updated tests

commit c62d1bd0534aa19be99b8f87232ba872420111a0
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Tue Dec 16 14:12:31 2025 -0800

    Updated tests

commit 0cc0ee31d5ad84c59d8eba9fc4424f118f5e0ee2
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Tue Dec 16 13:44:55 2025 -0800

    Extracted component and util logic

commit 25d1b112af250b5ccebb1006511bff8e4387fc76
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Tue Dec 16 13:44:11 2025 -0800

    Added correct import for Text component

commit d8a04c6c09edf158220d3073939c9fb60069745c
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Tue Dec 16 13:43:55 2025 -0800

    Fixed component syntax

commit 59e35d3a4d0da8ed8476560cedacadf76162ea43
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Tue Dec 16 13:43:39 2025 -0800

    Fixed cache_url fallback

commit d2a170d2efd3d2b0e6078c9eebeb8dcea237be3b
Merge: b8f7e435 6c1b0f9a
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Tue Dec 16 12:00:45 2025 -0800

    Merge branch 'enhancement/component-cleanup' into test/component-cleanup

commit b8f7e4358a23f2e3a902929b57ab7a7d115241c5
Merge: 5b12c68a d97f0c90
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Mon Dec 15 07:42:06 2025 -0800

    Merge branch 'enhancement/component-cleanup' into test/component-cleanup

commit 5b12c68ab8ce429adc8d1355632aa411007d365b
Merge: eff58126 c63cb75b
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Mon Dec 8 16:56:14 2025 -0800

    Merge branch 'enhancement/unit-tests' into stage

commit eff58126fb6aba4ebe9a0c67eee65773bffb8ae9
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Mon Dec 8 16:49:43 2025 -0800

    Update .gitignore

commit c63cb75b8cad204d48a392a28d8a5bdf8c270496
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Mon Dec 8 16:28:03 2025 -0800

    Added unit tests for pages

commit 75306a6181ddeb2eaeb306387ba2b44c7fcfd5e3
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Mon Dec 8 16:27:19 2025 -0800

    Added Actions workflow
This commit is contained in:
Nick Sandstrom 2026-01-10 19:36:23 -08:00
parent e2e6f61dee
commit 93f74c9d91
35 changed files with 10428 additions and 69 deletions

35
.github/workflows/frontend-tests.yml vendored Normal file
View file

@ -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

3
.gitignore vendored
View file

@ -18,4 +18,5 @@ dump.rdb
debugpy*
uwsgi.sock
package-lock.json
models
models
.idea

View file

@ -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: () => <div data-testid="channels-table">ChannelsTable</div>
}));
vi.mock('../../components/tables/StreamsTable', () => ({
default: () => <div data-testid="streams-table">StreamsTable</div>
}));
vi.mock('@mantine/core', () => ({
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
}));
vi.mock('allotment', () => ({
Allotment: ({ children }) => <div data-testid="allotment">{children}</div>,
}));
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(<ChannelsPage />);
expect(container.firstChild).toBeNull();
});
it('renders only ChannelsTable for standard users', () => {
useAuthStore.mockReturnValue({ id: 1, user_level: 1 });
render(<ChannelsPage />);
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(<ChannelsPage />);
expect(screen.getByTestId('channels-table')).toBeInTheDocument();
await waitFor(() =>
expect(screen.getByTestId('streams-table')).toBeInTheDocument()
);
});
});

View file

@ -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: () => <div data-testid="m3us-table">M3UsTable</div>
}));
vi.mock('../../components/tables/EPGsTable', () => ({
default: () => <div data-testid="epgs-table">EPGsTable</div>
}));
vi.mock('@mantine/core', () => ({
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Stack: ({ children, ...props }) => <div {...props}>{children}</div>,
}));
describe('ContentSourcesPage', () => {
it('renders error on userAgents error', () => {
const errorMessage = 'Failed to load userAgents.';
useUserAgentsStore.mockReturnValue(errorMessage);
render(<ContentSourcesPage />);
const element = screen.getByText(/Something went wrong/i);
expect(element).toBeInTheDocument();
});
it('no error renders tables', () => {
useUserAgentsStore.mockReturnValue(null);
render(<ContentSourcesPage />);
expect(screen.getByTestId('m3us-table')).toBeInTheDocument();
expect(screen.getByTestId('epgs-table')).toBeInTheDocument();
});
});

View file

@ -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 }) => <div data-testid="box">{children}</div>,
Container: ({ children }) => <div data-testid="container">{children}</div>,
Title: ({ children, order }) => <h1 data-order={order}>{children}</h1>,
Text: ({ children }) => <p>{children}</p>,
Button: ({ children, onClick, leftSection, loading, ...props }) => (
<button onClick={onClick} disabled={loading} {...props}>
{leftSection}
{children}
</button>
),
Badge: ({ children }) => <span>{children}</span>,
SimpleGrid: ({ children }) => <div data-testid="simple-grid">{children}</div>,
Group: ({ children }) => <div data-testid="group">{children}</div>,
Stack: ({ children }) => <div data-testid="stack">{children}</div>,
Divider: () => <hr data-testid="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 }) => (
<div data-testid={`recording-card-${recording.id}`}>
<span>{recording.custom_properties?.Title || 'Recording'}</span>
<button onClick={() => onOpenDetails(recording)}>Open Details</button>
{recording.custom_properties?.rule && (
<button onClick={() => onOpenRecurring(recording)}>Open Recurring</button>
)}
</div>
),
}));
vi.mock('../../components/forms/RecordingDetailsModal', () => ({
default: ({ opened, onClose, recording, onEdit, onWatchLive, onWatchRecording }) =>
opened ? (
<div data-testid="details-modal">
<div data-testid="modal-title">{recording?.custom_properties?.Title}</div>
<button onClick={onClose}>Close Modal</button>
<button onClick={onEdit}>Edit</button>
<button onClick={onWatchLive}>Watch Live</button>
<button onClick={onWatchRecording}>Watch Recording</button>
</div>
) : null,
}));
vi.mock('../../components/forms/RecurringRuleModal', () => ({
default: ({ opened, onClose, ruleId }) =>
opened ? (
<div data-testid="recurring-modal">
<div>Rule ID: {ruleId}</div>
<button onClick={onClose}>Close Recurring</button>
</div>
) : null,
}));
vi.mock('../../components/forms/Recording', () => ({
default: ({ isOpen, onClose, recording }) =>
isOpen ? (
<div data-testid="recording-form">
<div>Recording ID: {recording?.id || 'new'}</div>
<button onClick={onClose}>Close Form</button>
</div>
) : null,
}));
vi.mock('../../components/ErrorBoundary', () => ({
default: ({ children }) => <div data-testid="error-boundary">{children}</div>,
}));
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(<DVRPage />);
expect(screen.getByText('New Recording')).toBeInTheDocument();
});
it('renders empty state when no recordings', () => {
render(<DVRPage />);
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(<DVRPage />);
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(<DVRPage />);
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(<DVRPage />);
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(<DVRPage />);
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(<DVRPage />);
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(<DVRPage />);
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(<DVRPage />);
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(<DVRPage />);
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(<DVRPage />);
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(<DVRPage />);
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(<DVRPage />);
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();
});
});
});

View file

@ -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 }) => (
<div style={style} onClick={onClick} className={className} ref={ref}>
{children}
</div>
),
Flex: ({ children, direction, justify, align, gap, mb, style }) => (
<div
style={style}
data-direction={direction}
data-justify={justify}
data-align={align}
data-gap={gap}
data-mb={mb}
>
{children}
</div>
),
Group: ({ children, gap, justify }) => (
<div data-gap={gap} data-justify={justify}>
{children}
</div>
),
Title: ({ children, order, size }) => (
<h2 data-order={order} data-size={size}>
{children}
</h2>
),
Text: ({ children, size, c, fw, lineClamp, style, onClick }) => (
<span
data-size={size}
data-color={c}
data-fw={fw}
data-line-clamp={lineClamp}
style={style}
onClick={onClick}
>
{children}
</span>
),
Paper: ({ children, style, onClick }) => (
<div style={style} onClick={onClick}>
{children}
</div>
),
Button: ({ children, onClick, leftSection, variant, size, color, disabled }) => (
<button onClick={onClick} disabled={disabled} data-variant={variant} data-size={size} data-color={color}>
{leftSection}
{children}
</button>
),
TextInput: ({ value, onChange, placeholder, icon, rightSection }) => (
<div>
{icon}
<input type="text" value={value} onChange={onChange} placeholder={placeholder} />
{rightSection}
</div>
),
Select: ({ value, onChange, data, placeholder, clearable }) => (
<select
value={value}
onChange={(e) => onChange?.(e.target.value)}
aria-label={placeholder}
data-clearable={clearable}
>
<option value="">Select...</option>
{data?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
),
ActionIcon: ({ children, onClick, variant, size, color }) => (
<button onClick={onClick} data-variant={variant} data-size={size} data-color={color}>
{children}
</button>
),
Tooltip: ({ children, label }) => <div title={label}>{children}</div>,
LoadingOverlay: ({ visible }) => (visible ? <div>Loading...</div> : null),
};
});
vi.mock('react-window', () => ({
VariableSizeList: ({ children, itemData, itemCount }) => (
<div data-testid="variable-size-list">
{Array.from({ length: Math.min(itemCount, 5) }, (_, i) =>
<div key={i}>
{children({
index: i,
style: {},
data: itemData.filteredChannels[i]
})}
</div>
)}
</div>
),
}));
vi.mock('../../components/GuideRow', () => ({
default: ({ data }) => <div data-testid="guide-row">GuideRow for {data?.name}</div>,
}));
vi.mock('../../components/HourTimeline', () => ({
default: ({ hourTimeline }) => (
<div data-testid="hour-timeline">
{hourTimeline.map((hour, i) => (
<div key={i}>{hour.label}</div>
))}
</div>
),
}));
vi.mock('../../components/forms/ProgramRecordingModal', () => ({
__esModule: true,
default: ({ opened, onClose, program, onRecordOne }) =>
opened ? (
<div data-testid="program-recording-modal">
<div>{program?.title}</div>
<button onClick={onClose}>Close</button>
<button onClick={onRecordOne}>Record One</button>
</div>
) : null,
}));
vi.mock('../../components/forms/SeriesRecordingModal', () => ({
__esModule: true,
default: ({ opened, onClose, rules }) =>
opened ? (
<div data-testid="series-recording-modal">
<div>Series Rules: {rules.length}</div>
<button onClick={onClose}>Close</button>
</div>
) : 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(<Guide />);
expect(screen.getByText('TV Guide')).toBeInTheDocument();
});
it('displays current time in header', async () => {
render(<Guide />);
expect(screen.getByText(/Monday, 01\/15\/2024/)).toBeInTheDocument();
});
it('renders channel rows when channels are available', async () => {
render(<Guide />);
expect(screen.getAllByTestId('guide-row')).toHaveLength(2);
});
it('shows no channels message when filters exclude all channels', async () => {
guideUtils.filterGuideChannels.mockReturnValue([]);
render(<Guide />);
// await waitFor(() => {
expect(screen.getByText('No channels match your filters')).toBeInTheDocument();
// });
});
it('displays channel count', async () => {
render(<Guide />);
// await waitFor(() => {
expect(screen.getByText(/2 channels/)).toBeInTheDocument();
// });
});
});
describe('Search Functionality', () => {
it('updates search query when user types', async () => {
vi.useRealTimers();
render(<Guide />);
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(<Guide />);
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(<Guide />);
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(<Guide />);
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(<Guide />);
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(<Guide />);
// 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(<Guide />);
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(<Guide />);
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(<Guide />);
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(<Guide />);
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(<Guide />);
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(<Guide />);
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(<Guide />);
await waitFor(() => {
expect(screen.getByText('TV Guide')).toBeInTheDocument();
});
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
});
});
});

View file

@ -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: () => <div data-testid="login-form">LoginForm</div>
}));
vi.mock('../../components/forms/SuperuserForm', () => ({
default: () => <div data-testid="superuser-form">SuperuserForm</div>
}));
vi.mock('@mantine/core', () => ({
Text: ({ children }) => <div>{children}</div>,
}));
describe('Login', () => {
it('renders SuperuserForm when superuser does not exist', async () => {
useAuthStore.mockReturnValue(false);
render(<Login/>);
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(<Login/>);
expect(screen.getByTestId('login-form')).toBeInTheDocument();
expect(screen.queryByTestId('superuser-form')).not.toBeInTheDocument();
});
});

View file

@ -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: () => <div data-testid="logos-table">LogosTable</div>
}));
vi.mock('../../components/tables/VODLogosTable', () => ({
default: () => <div data-testid="vod-logos-table">VODLogosTable</div>
}));
vi.mock('@mantine/core', () => {
const tabsComponent = ({ children, value, onChange }) =>
<div data-testid="tabs" data-value={value} onClick={() => onChange('vod')}>{children}</div>;
tabsComponent.List = ({ children }) => <div>{children}</div>;
tabsComponent.Tab = ({ children, value }) => <button data-value={value}>{children}</button>;
return {
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Flex: ({ children, ...props }) => <div {...props}>{children}</div>,
Text: ({ children, ...props }) => <span {...props}>{children}</span>,
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(<LogosPage />);
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(<LogosPage />);
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(<LogosPage />);
expect(screen.getByText(/\(1 logo\)/i)).toBeInTheDocument();
});
it('fetches all logos on mount when needed', async () => {
render(<LogosPage />);
await waitFor(() => {
expect(mockNeedsAllLogos).toHaveBeenCalled();
expect(mockFetchAllLogos).toHaveBeenCalled();
});
});
it('does not fetch logos when not needed', async () => {
mockNeedsAllLogos.mockReturnValue(false);
render(<LogosPage />);
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(<LogosPage />);
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(<LogosPage />);
expect(screen.getByTestId('logos-table')).toBeInTheDocument();
const tabs = screen.getByTestId('tabs');
fireEvent.click(tabs);
rerender(<LogosPage />);
expect(screen.getByTestId('vod-logos-table')).toBeInTheDocument();
expect(screen.queryByTestId('logos-table')).not.toBeInTheDocument();
});
it('renders both tab options', () => {
render(<LogosPage />);
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(<LogosPage />);
expect(screen.getByText(/\(0 logos\)/i)).toBeInTheDocument();
});
});

View file

@ -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 }) => <div>{children}</div>,
Box: ({ children, style }) => <div style={style}>{children}</div>,
Stack: ({ children, gap }) => <div data-gap={gap}>{children}</div>,
Group: ({ children, justify, mb }) => (
<div data-justify={justify} data-mb={mb}>
{children}
</div>
),
Alert: ({ children, color, title }) => (
<div data-testid="alert" data-color={color}>
{title && <div>{title}</div>}
{children}
</div>
),
Text: ({ children, size, fw, c }) => (
<span data-size={size} data-fw={fw} data-color={c}>
{children}
</span>
),
Button: ({ children, onClick, leftSection, variant, color, loading, disabled, fullWidth }) => (
<button
onClick={onClick}
disabled={loading || disabled}
data-variant={variant}
data-color={color}
data-full-width={fullWidth}
>
{leftSection}
{children}
</button>
),
Loader: () => <div data-testid="loader">Loading...</div>,
Switch: ({ checked, onChange, label, description }) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e)}
/>
{label}
{description && <span>{description}</span>}
</label>
),
Divider: ({ my }) => <hr data-my={my} />,
ActionIcon: ({ children, onClick, color, variant, title }) => (
<button onClick={onClick} data-color={color} data-variant={variant} title={title}>
{children}
</button>
),
SimpleGrid: ({ children, cols }) => (
<div data-cols={cols}>{children}</div>
),
Modal: ({ opened, onClose, title, children, size, centered }) =>
opened ? (
<div data-testid="modal" data-size={size} data-centered={centered}>
<div data-testid="modal-title">{title}</div>
<button onClick={onClose}>Close Modal</button>
{children}
</div>
) : null,
FileInput: ({ value, onChange, label, placeholder, accept }) => (
<div>
{label && <label>{label}</label>}
<input
type="file"
onChange={(e) => onChange?.(e.target.files[0])}
placeholder={placeholder}
accept={accept}
aria-label={label}
/>
</div>
),
};
});
vi.mock('@mantine/dropzone', () => ({
Dropzone: ({ children, onDrop, accept, maxSize }) => (
<div
data-testid="dropzone"
data-accept={accept}
data-max-size={maxSize}
onClick={() => {
const file = new File(['content'], 'plugin.zip', { type: 'application/zip' });
onDrop([file]);
}}
>
<div>Drop files</div>
{children}
</div>
),
}));
vi.mock('../../components/cards/PluginCard.jsx', () => ({
default: ({ plugin }) => (
<div>
<h2>{plugin.name}</h2>
<p>{plugin.description}</p>
</div>
),
}));
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(<PluginsPage />);
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(<PluginsPage />);
expect(screen.getByText('Import Plugin')).toBeInTheDocument();
});
it('renders reload button', () => {
render(<PluginsPage />);
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(<PluginsPage />);
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(<PluginsPage />);
expect(screen.getByText(/No plugins found/)).toBeInTheDocument();
});
});
describe('Import Plugin', () => {
it('opens import modal when import button is clicked', () => {
render(<PluginsPage />);
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(<PluginsPage />);
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(<PluginsPage />);
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(<PluginsPage />);
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(<PluginsPage />);
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(<PluginsPage />);
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(<PluginsPage />);
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(<PluginsPage />);
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(<PluginsPage />);
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(<PluginsPage />);
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(<PluginsPage />);
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(<PluginsPage />);
const reloadButton = screen.getByTitle('Reload');
fireEvent.click(reloadButton);
await waitFor(() => {
expect(invalidatePlugins).toHaveBeenCalled();
});
});
});
});

View file

@ -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 }) => <div data-testid="user-agents-table">UserAgentsTable {active ? 'active' : 'inactive'}</div>,
}));
vi.mock('../../components/tables/StreamProfilesTable', () => ({
default: ({ active }) => <div data-testid="stream-profiles-table">StreamProfilesTable {active ? 'active' : 'inactive'}</div>,
}));
vi.mock('../../components/backups/BackupManager', () => ({
default: ({ active }) => <div data-testid="backup-manager">BackupManager {active ? 'active' : 'inactive'}</div>,
}));
vi.mock('../../components/forms/settings/UiSettingsForm', () => ({
default: ({ active }) => <div data-testid="ui-settings-form">UiSettingsForm {active ? 'active' : 'inactive'}</div>,
}));
vi.mock('../../components/forms/settings/NetworkAccessForm', () => ({
default: ({ active }) => <div data-testid="network-access-form">NetworkAccessForm {active ? 'active' : 'inactive'}</div>,
}));
vi.mock('../../components/forms/settings/ProxySettingsForm', () => ({
default: ({ active }) => <div data-testid="proxy-settings-form">ProxySettingsForm {active ? 'active' : 'inactive'}</div>,
}));
vi.mock('../../components/forms/settings/StreamSettingsForm', () => ({
default: ({ active }) => <div data-testid="stream-settings-form">StreamSettingsForm {active ? 'active' : 'inactive'}</div>,
}));
vi.mock('../../components/forms/settings/DvrSettingsForm', () => ({
default: ({ active }) => <div data-testid="dvr-settings-form">DvrSettingsForm {active ? 'active' : 'inactive'}</div>,
}));
vi.mock('../../components/forms/settings/SystemSettingsForm', () => ({
default: ({ active }) => <div data-testid="system-settings-form">SystemSettingsForm {active ? 'active' : 'inactive'}</div>,
}));
vi.mock('../../components/ErrorBoundary', () => ({
default: ({ children }) => <div data-testid="error-boundary">{children}</div>,
}));
vi.mock('@mantine/core', async () => {
const accordionComponent = ({ children, onChange, defaultValue }) => <div data-testid="accordion">{children}</div>;
accordionComponent.Item = ({ children, value }) => (
<div data-testid={`accordion-item-${value}`}>{children}</div>
);
accordionComponent.Control = ({ children }) => (
<button data-testid="accordion-control">{children}</button>
);
accordionComponent.Panel = ({ children }) => (
<div data-testid="accordion-panel">{children}</div>
);
return {
Accordion: accordionComponent,
AccordionItem: accordionComponent.Item,
AccordionControl: accordionComponent.Control,
AccordionPanel: accordionComponent.Panel,
Box: ({ children }) => <div>{children}</div>,
Center: ({ children }) => <div>{children}</div>,
Loader: () => <div data-testid="loader">Loading...</div>,
Text: ({ children }) => <span>{children}</span>,
};
});
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(<SettingsPage />);
expect(screen.getByTestId('accordion')).toBeInTheDocument();
});
it('renders UI Settings accordion item', () => {
render(<SettingsPage />);
expect(screen.getByTestId('accordion-item-ui-settings')).toBeInTheDocument();
expect(screen.getByText('UI Settings')).toBeInTheDocument();
});
it('opens UI Settings panel by default', () => {
render(<SettingsPage />);
expect(screen.getByTestId('ui-settings-form')).toBeInTheDocument();
});
it('does not render admin-only sections for regular users', () => {
render(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
expect(screen.getByTestId('accordion-item-dvr-settings')).toBeInTheDocument();
});
it('renders Stream Settings accordion item', () => {
render(<SettingsPage />);
expect(screen.getByTestId('accordion-item-stream-settings')).toBeInTheDocument();
});
it('renders System Settings accordion item', () => {
render(<SettingsPage />);
expect(screen.getByTestId('accordion-item-system-settings')).toBeInTheDocument();
});
it('renders User-Agents accordion item', () => {
render(<SettingsPage />);
expect(screen.getByTestId('accordion-item-user-agents')).toBeInTheDocument();
});
it('renders Stream Profiles accordion item', () => {
render(<SettingsPage />);
expect(screen.getByTestId('accordion-item-stream-profiles')).toBeInTheDocument();
});
it('renders Network Access accordion item', () => {
render(<SettingsPage />);
expect(screen.getByTestId('accordion-item-network-access')).toBeInTheDocument();
});
it('renders Proxy Settings accordion item', () => {
render(<SettingsPage />);
expect(screen.getByTestId('accordion-item-proxy-settings')).toBeInTheDocument();
});
it('renders Backup & Restore accordion item', () => {
render(<SettingsPage />);
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(<SettingsPage />);
const streamSettingsButton = screen.getByText('DVR');
await user.click(streamSettingsButton);
await screen.findByTestId('dvr-settings-form');
});
});
});

View file

@ -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: () => <div data-testid="system-events">SystemEvents</div>
}));
vi.mock('../../components/ErrorBoundary.jsx', () => ({
default: ({ children }) => <div data-testid="error-boundary">{children}</div>
}));
vi.mock('../../components/cards/VodConnectionCard.jsx', () => ({
default: ({ vodContent, stopVODClient }) => (
<div data-testid={`vod-connection-card-${vodContent.content_uuid}`}>
VODConnectionCard - {vodContent.content_uuid}
{vodContent.connections?.map((conn) => (
<button
key={conn.client_id}
data-testid={`stop-vod-client-${conn.client_id}`}
onClick={() => stopVODClient(conn.client_id)}
>
Stop VOD Client
</button>
))}
</div>
),
}));
vi.mock('../../components/cards/StreamConnectionCard.jsx', () => ({
default: ({ channel }) => (
<div data-testid={`stream-connection-card-${channel.uuid}`}>
StreamConnectionCard - {channel.uuid}
</div>
),
}));
// Mock Mantine components
vi.mock('@mantine/core', () => ({
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Button: ({ children, onClick, loading, ...props }) => (
<button onClick={onClick} disabled={loading} {...props}>
{children}
</button>
),
Group: ({ children }) => <div>{children}</div>,
LoadingOverlay: () => <div data-testid="loading-overlay">Loading...</div>,
Text: ({ children }) => <span>{children}</span>,
Title: ({ children }) => <h3>{children}</h3>,
NumberInput: ({ value, onChange, min, max, ...props }) => (
<input
data-testid="refresh-interval-input"
type="number"
value={value}
onChange={(e) => 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(<StatsPage />);
await screen.findByText('Active Connections')
});
it('fetches initial stats on mount', async () => {
render(<StatsPage />);
await waitFor(() => {
expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
expect(getVODStats).toHaveBeenCalledTimes(2);
});
});
it('displays connection counts', async () => {
render(<StatsPage />);
await waitFor(() => {
expect(screen.getByText(/2 streams/)).toBeInTheDocument();
expect(screen.getByText(/1 VOD connection/)).toBeInTheDocument();
});
});
it('renders SystemEvents component', async () => {
render(<StatsPage />);
await screen.findByTestId('system-events')
});
});
describe('Refresh Interval Controls', () => {
it('displays default refresh interval', () => {
render(<StatsPage />);
waitFor(() => {
const input = screen.getByTestId('refresh-interval-input');
expect(input).toHaveValue(5);
});
});
it('updates refresh interval when input changes', async () => {
render(<StatsPage />);
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(<StatsPage />);
await waitFor(() => {
expect(screen.getByText(/Refreshing every 5s/)).toBeInTheDocument();
});
});
it('displays disabled message when interval is 0', async () => {
useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]);
render(<StatsPage />);
await screen.findByText('Refreshing disabled')
});
});
describe('Auto-refresh Polling', () => {
it('sets up polling interval for stats', async () => {
vi.useFakeTimers();
render(<StatsPage />);
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(<StatsPage />);
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(<StatsPage />);
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(<StatsPage />);
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(<StatsPage />);
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(<StatsPage />);
await waitFor(() => {
expect(screen.getByTestId('vod-connection-card-vod-1')).toBeInTheDocument();
});
});
it('displays empty state when no connections', async () => {
getCombinedConnections.mockReturnValue([]);
render(<StatsPage />);
await waitFor(() => {
expect(screen.getByText('No active connections')).toBeInTheDocument();
});
});
});
describe('VOD Client Management', () => {
it('stops VOD client when stop button is clicked', async () => {
render(<StatsPage />);
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(<StatsPage />);
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(<StatsPage />);
await waitFor(() => {
expect(getStatsByChannelId).toHaveBeenCalledWith(
mockChannelStats,
expect.any(Object),
mockChannelsByUUID,
mockChannels,
mockStreamProfiles
);
});
});
it('updates clients based on processed stats', async () => {
render(<StatsPage />);
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(<StatsPage />);
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(<StatsPage />);
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(<StatsPage />);
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(<StatsPage />);
await waitFor(() => {
expect(screen.getByText(/2 VOD connections/)).toBeInTheDocument();
});
});
});
});

View file

@ -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: () => <div data-testid="users-table">UsersTable</div>
}));
vi.mock('@mantine/core', () => ({
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
}));
describe('UsersPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders nothing when user is not authenticated', () => {
useAuthStore.mockReturnValue({ id: null });
const { container } = render(<UsersPage />);
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(<UsersPage />);
expect(screen.getByTestId('users-table')).toBeInTheDocument();
});
it('handles user with id 0 as authenticated', () => {
useAuthStore.mockReturnValue({ id: 0 });
const { container } = render(<UsersPage />);
// 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(<UsersPage />);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
useAuthStore.mockReturnValue({ id: 1 });
render(<UsersPage />);
expect(screen.getByTestId('users-table')).toBeInTheDocument();
});
});

View file

@ -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 ? (
<div data-testid="series-modal">
<div data-testid="series-name">{series?.name}</div>
<button onClick={onClose}>Close</button>
</div>
) : null
}));
vi.mock('../../components/VODModal', () => ({
default: ({ opened, vod, onClose }) =>
opened ? (
<div data-testid="vod-modal">
<div data-testid="vod-name">{vod?.name}</div>
<button onClick={onClose}>Close</button>
</div>
) : null
}));
vi.mock('../../components/cards/VODCard', () => ({
default: ({ vod, onClick }) => (
<div data-testid="vod-card" onClick={() => onClick(vod)}>
<div>{vod.name}</div>
</div>
)
}));
vi.mock('../../components/cards/SeriesCard', () => ({
default: ({ series, onClick }) => (
<div data-testid="series-card" onClick={() => onClick(series)}>
<div>{series.name}</div>
</div>
)
}));
vi.mock('@mantine/core', () => {
const gridComponent = ({ children, ...props }) => <div {...props}>{children}</div>;
gridComponent.Col = ({ children, ...props }) => <div {...props}>{children}</div>;
return {
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Stack: ({ children, ...props }) => <div {...props}>{children}</div>,
Group: ({ children, ...props }) => <div {...props}>{children}</div>,
Flex: ({ children, ...props }) => <div {...props}>{children}</div>,
Title: ({ children, ...props }) => <h2 {...props}>{children}</h2>,
TextInput: ({ value, onChange, placeholder, icon }) => (
<div>
{icon}
<input
type="text"
value={value}
onChange={onChange}
placeholder={placeholder}
/>
</div>
),
Select: ({ value, onChange, data, label, placeholder }) => (
<div>
{label && <label>{label}</label>}
<select
value={value}
onChange={(e) => onChange?.(e.target.value)}
aria-label={placeholder || label}
>
{data?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
),
SegmentedControl: ({ value, onChange, data }) => (
<div>
{data.map((item) => (
<button
key={item.value}
onClick={() => onChange(item.value)}
data-active={value === item.value}
>
{item.label}
</button>
))}
</div>
),
Pagination: ({ page, onChange, total }) => (
<div data-testid="pagination">
<button onClick={() => onChange(page - 1)} disabled={page === 1}>
Prev
</button>
<span>{page} of {total}</span>
<button onClick={() => onChange(page + 1)} disabled={page === total}>
Next
</button>
</div>
),
Grid: gridComponent,
GridCol: gridComponent.Col,
Loader: () => <div data-testid="loader">Loading...</div>,
LoadingOverlay: ({ visible }) =>
visible ? <div data-testid="loading-overlay">Loading...</div> : 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(<VODsPage />);
await screen.findByText('Video on Demand');
});
it('fetches categories on mount', async () => {
render(<VODsPage />);
await waitFor(() => {
expect(mockFetchCategories).toHaveBeenCalledTimes(1);
});
});
it('fetches content on mount', async () => {
render(<VODsPage />);
await waitFor(() => {
expect(mockFetchContent).toHaveBeenCalledTimes(1);
});
});
it('displays loader during initial load', async () => {
render(<VODsPage />);
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(<VODsPage />);
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(<VODsPage />);
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(<VODsPage />);
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(<VODsPage />);
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(<VODsPage />);
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(<VODsPage />);
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(<VODsPage />);
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(<VODsPage />);
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(<VODsPage />);
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(<VODsPage />);
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(<VODsPage />);
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(<VODsPage />);
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(<VODsPage />);
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(<VODsPage />);
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(<VODsPage />);
await waitFor(() => {
fireEvent.click(screen.getByText('Next'));
});
expect(mockSetPage).toHaveBeenCalledWith(2);
});
it('refetches content when filters change', async () => {
const { rerender } = render(<VODsPage />);
const updatedState = {
...defaultStoreState,
filters: { type: 'movies', search: '', category: '' },
};
useVODStore.mockImplementation((selector) => selector(updatedState));
rerender(<VODsPage />);
await waitFor(() => {
expect(mockFetchContent).toHaveBeenCalledTimes(2);
});
});
it('refetches content when page changes', async () => {
const { rerender } = render(<VODsPage />);
const updatedState = {
...defaultStoreState,
currentPage: 2,
};
useVODStore.mockImplementation((selector) => selector(updatedState));
rerender(<VODsPage />);
await waitFor(() => {
expect(mockFetchContent).toHaveBeenCalledTimes(2);
});
});
it('refetches content when page size changes', async () => {
const { rerender } = render(<VODsPage />);
const updatedState = {
...defaultStoreState,
pageSize: 24,
};
useVODStore.mockImplementation((selector) => selector(updatedState));
rerender(<VODsPage />);
await waitFor(() => {
expect(mockFetchContent).toHaveBeenCalledTimes(2);
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -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;
});
});
});

View file

@ -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');
});
});
});

View file

@ -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);
});
});
});

View file

@ -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);
});
});
});

View file

@ -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,
});
});
});
});

View file

@ -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]');
});
});
});

View file

@ -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('');
});
});
});

View file

@ -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
});
});
});

View file

@ -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([]);
});
});
});

View file

@ -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
});
});
});
});

View file

@ -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');
});
});
});

View file

@ -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/24')).toBe('Invalid CIDR range');
expect(validator('invalid')).toBe('Invalid CIDR range');
expect(validator('192.168.1.0/33')).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({});
});
});
});

View file

@ -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');
});
});
});

View file

@ -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');
});
});
});

View file

@ -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');
});
});
});

View file

@ -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: ''
});
});
});
});

View file

@ -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);
});
});
});

View file

@ -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);
});
});
});

View file

@ -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'
});
});
});
});

View file

@ -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([]);
});
});
});

View file

@ -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');
});
});
});