mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-22 18:28:00 +00:00
Merge pull request #841 from nick4810/tests/frontend-unit-tests
Tests/frontend unit tests
This commit is contained in:
commit
e72e0215cb
35 changed files with 10428 additions and 69 deletions
35
.github/workflows/frontend-tests.yml
vendored
Normal file
35
.github/workflows/frontend-tests.yml
vendored
Normal 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
3
.gitignore
vendored
|
|
@ -18,4 +18,5 @@ dump.rdb
|
|||
debugpy*
|
||||
uwsgi.sock
|
||||
package-lock.json
|
||||
models
|
||||
models
|
||||
.idea
|
||||
48
frontend/src/pages/__tests__/Channels.test.jsx
Normal file
48
frontend/src/pages/__tests__/Channels.test.jsx
Normal 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()
|
||||
);
|
||||
});
|
||||
});
|
||||
33
frontend/src/pages/__tests__/ContentSources.test.jsx
Normal file
33
frontend/src/pages/__tests__/ContentSources.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
541
frontend/src/pages/__tests__/DVR.test.jsx
Normal file
541
frontend/src/pages/__tests__/DVR.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
619
frontend/src/pages/__tests__/Guide.test.jsx
Normal file
619
frontend/src/pages/__tests__/Guide.test.jsx
Normal 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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
37
frontend/src/pages/__tests__/Login.test.jsx
Normal file
37
frontend/src/pages/__tests__/Login.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
172
frontend/src/pages/__tests__/Logos.test.jsx
Normal file
172
frontend/src/pages/__tests__/Logos.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
561
frontend/src/pages/__tests__/Plugins.test.jsx
Normal file
561
frontend/src/pages/__tests__/Plugins.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
208
frontend/src/pages/__tests__/Settings.test.jsx
Normal file
208
frontend/src/pages/__tests__/Settings.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
494
frontend/src/pages/__tests__/Stats.test.jsx
Normal file
494
frontend/src/pages/__tests__/Stats.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
58
frontend/src/pages/__tests__/Users.test.jsx
Normal file
58
frontend/src/pages/__tests__/Users.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
468
frontend/src/pages/__tests__/VODs.test.jsx
Normal file
468
frontend/src/pages/__tests__/VODs.test.jsx
Normal 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
472
frontend/src/utils/__tests__/dateTimeUtils.test.js
Normal file
472
frontend/src/utils/__tests__/dateTimeUtils.test.js
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
144
frontend/src/utils/__tests__/networkUtils.test.js
Normal file
144
frontend/src/utils/__tests__/networkUtils.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
145
frontend/src/utils/__tests__/notificationUtils.test.js
Normal file
145
frontend/src/utils/__tests__/notificationUtils.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
158
frontend/src/utils/cards/__tests__/PluginCardUtils.test.js
Normal file
158
frontend/src/utils/cards/__tests__/PluginCardUtils.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
390
frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js
Normal file
390
frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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]');
|
||||
});
|
||||
});
|
||||
});
|
||||
90
frontend/src/utils/cards/__tests__/VODCardUtils.test.js
Normal file
90
frontend/src/utils/cards/__tests__/VODCardUtils.test.js
Normal 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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as NetworkAccessFormUtils from '../NetworkAccessFormUtils';
|
||||
import * as constants from '../../../../constants.js';
|
||||
|
||||
vi.mock('../../../../constants.js', () => ({
|
||||
NETWORK_ACCESS_OPTIONS: {}
|
||||
}));
|
||||
|
||||
vi.mock('../../../networkUtils.js', () => ({
|
||||
IPV4_CIDR_REGEX: /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/,
|
||||
IPV6_CIDR_REGEX: /^([0-9a-fA-F:]+)\/\d{1,3}$/
|
||||
}));
|
||||
|
||||
describe('NetworkAccessFormUtils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getNetworkAccessFormInitialValues', () => {
|
||||
it('should return initial values for all network access options', () => {
|
||||
vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
|
||||
'network-access-admin': 'Admin Access',
|
||||
'network-access-api': 'API Access',
|
||||
'network-access-streaming': 'Streaming Access'
|
||||
};
|
||||
|
||||
const result = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
|
||||
|
||||
expect(result).toEqual({
|
||||
'network-access-admin': '0.0.0.0/0,::/0',
|
||||
'network-access-api': '0.0.0.0/0,::/0',
|
||||
'network-access-streaming': '0.0.0.0/0,::/0'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty object when NETWORK_ACCESS_OPTIONS is empty', () => {
|
||||
vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {};
|
||||
|
||||
const result = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return a new object each time', () => {
|
||||
vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
|
||||
'network-access-admin': 'Admin Access'
|
||||
};
|
||||
|
||||
const result1 = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
|
||||
const result2 = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
|
||||
|
||||
expect(result1).toEqual(result2);
|
||||
expect(result1).not.toBe(result2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNetworkAccessFormValidation', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {
|
||||
'network-access-admin': 'Admin Access',
|
||||
'network-access-api': 'API Access'
|
||||
};
|
||||
});
|
||||
|
||||
it('should return validation functions for all network access options', () => {
|
||||
const result = NetworkAccessFormUtils.getNetworkAccessFormValidation();
|
||||
|
||||
expect(Object.keys(result)).toEqual(['network-access-admin', 'network-access-api']);
|
||||
expect(typeof result['network-access-admin']).toBe('function');
|
||||
expect(typeof result['network-access-api']).toBe('function');
|
||||
});
|
||||
|
||||
it('should validate valid IPv4 CIDR ranges', () => {
|
||||
const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
|
||||
const validator = validation['network-access-admin'];
|
||||
|
||||
expect(validator('192.168.1.0/24')).toBeNull();
|
||||
expect(validator('10.0.0.0/8')).toBeNull();
|
||||
expect(validator('0.0.0.0/0')).toBeNull();
|
||||
});
|
||||
|
||||
it('should validate valid IPv6 CIDR ranges', () => {
|
||||
const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
|
||||
const validator = validation['network-access-admin'];
|
||||
|
||||
expect(validator('2001:db8::/32')).toBeNull();
|
||||
expect(validator('::/0')).toBeNull();
|
||||
});
|
||||
|
||||
it('should validate multiple CIDR ranges separated by commas', () => {
|
||||
const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
|
||||
const validator = validation['network-access-admin'];
|
||||
|
||||
expect(validator('192.168.1.0/24,10.0.0.0/8')).toBeNull();
|
||||
expect(validator('0.0.0.0/0,::/0')).toBeNull();
|
||||
expect(validator('192.168.1.0/24,2001:db8::/32')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error for invalid IPv4 CIDR ranges', () => {
|
||||
const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
|
||||
const validator = validation['network-access-admin'];
|
||||
|
||||
expect(validator('192.168.1.256.1/24')).toBe('Invalid CIDR range');
|
||||
expect(validator('invalid')).toBe('Invalid CIDR range');
|
||||
expect(validator('192.168.1.0/256')).toBe('Invalid CIDR range');
|
||||
});
|
||||
|
||||
it('should return error when any CIDR in comma-separated list is invalid', () => {
|
||||
const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
|
||||
const validator = validation['network-access-admin'];
|
||||
|
||||
expect(validator('192.168.1.0/24,invalid')).toBe('Invalid CIDR range');
|
||||
expect(validator('invalid,192.168.1.0/24')).toBe('Invalid CIDR range');
|
||||
expect(validator('192.168.1.0/24,10.0.0.0/8,invalid')).toBe('Invalid CIDR range');
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation();
|
||||
const validator = validation['network-access-admin'];
|
||||
|
||||
expect(validator('')).toBe('Invalid CIDR range');
|
||||
});
|
||||
|
||||
it('should return empty object when NETWORK_ACCESS_OPTIONS is empty', () => {
|
||||
vi.mocked(constants).NETWORK_ACCESS_OPTIONS = {};
|
||||
|
||||
const result = NetworkAccessFormUtils.getNetworkAccessFormValidation();
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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: ''
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
539
frontend/src/utils/pages/__tests__/DVRUtils.test.js
Normal file
539
frontend/src/utils/pages/__tests__/DVRUtils.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
269
frontend/src/utils/pages/__tests__/PluginsUtils.test.js
Normal file
269
frontend/src/utils/pages/__tests__/PluginsUtils.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
558
frontend/src/utils/pages/__tests__/SettingsUtils.test.js
Normal file
558
frontend/src/utils/pages/__tests__/SettingsUtils.test.js
Normal 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'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
654
frontend/src/utils/pages/__tests__/StatsUtils.test.js
Normal file
654
frontend/src/utils/pages/__tests__/StatsUtils.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
272
frontend/src/utils/pages/__tests__/VODsUtils.test.js
Normal file
272
frontend/src/utils/pages/__tests__/VODsUtils.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue