mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-20 01:59:50 +00:00
Merge pull request #905 from nick4810:tests/frontend-unit-tests
Tests/frontend unit tests
This commit is contained in:
commit
09a83ca7bb
29 changed files with 8160 additions and 324 deletions
|
|
@ -773,8 +773,6 @@ const StreamsTable = ({ onReady }) => {
|
|||
channel_profile_ids: channelProfileIds,
|
||||
});
|
||||
await API.requeryChannels();
|
||||
// const fetchLogos = useChannelsStore.getState().fetchLogos;
|
||||
// fetchLogos();
|
||||
};
|
||||
|
||||
// Handle confirming the single channel numbering modal
|
||||
|
|
|
|||
110
frontend/src/hooks/__tests__/useLocalStorage.test.jsx
Normal file
110
frontend/src/hooks/__tests__/useLocalStorage.test.jsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import useLocalStorage from '../useLocalStorage';
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store = {};
|
||||
|
||||
return {
|
||||
getItem: vi.fn((key) => store[key] || null),
|
||||
setItem: vi.fn((key, value) => {
|
||||
store[key] = value.toString();
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
removeItem: vi.fn((key) => {
|
||||
delete store[key];
|
||||
})
|
||||
};
|
||||
})();
|
||||
|
||||
global.localStorage = localStorageMock;
|
||||
|
||||
// Mock console.error to avoid cluttering test output
|
||||
global.console.error = vi.fn();
|
||||
|
||||
describe('useLocalStorage', () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with default value when localStorage is empty', () => {
|
||||
const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
|
||||
|
||||
expect(result.current[0]).toBe('defaultValue');
|
||||
});
|
||||
|
||||
it('should initialize with value from localStorage if available', () => {
|
||||
localStorageMock.setItem('testKey', JSON.stringify('storedValue'));
|
||||
|
||||
const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
|
||||
|
||||
expect(result.current[0]).toBe('storedValue');
|
||||
});
|
||||
|
||||
it('should update localStorage when value changes', () => {
|
||||
const { result } = renderHook(() => useLocalStorage('testKey', 'initial'));
|
||||
|
||||
act(() => {
|
||||
result.current[1]('updated');
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('testKey', JSON.stringify('updated'));
|
||||
expect(result.current[0]).toBe('updated');
|
||||
});
|
||||
|
||||
it('should handle complex objects', () => {
|
||||
const complexObject = { name: 'test', count: 42, nested: { value: true } };
|
||||
|
||||
const { result } = renderHook(() => useLocalStorage('testKey', complexObject));
|
||||
|
||||
act(() => {
|
||||
result.current[1]({ name: 'updated', count: 100 });
|
||||
});
|
||||
|
||||
expect(result.current[0]).toEqual({ name: 'updated', count: 100 });
|
||||
});
|
||||
|
||||
it('should handle errors when reading from localStorage', () => {
|
||||
localStorageMock.getItem.mockImplementationOnce(() => {
|
||||
throw new Error('Read error');
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
|
||||
|
||||
expect(result.current[0]).toBe('defaultValue');
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Error reading key "testKey":',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors when writing to localStorage', () => {
|
||||
localStorageMock.setItem.mockImplementationOnce(() => {
|
||||
throw new Error('Write error');
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useLocalStorage('testKey', 'initial'));
|
||||
|
||||
act(() => {
|
||||
result.current[1]('updated');
|
||||
});
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Error saving setting: testKey:',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle invalid JSON in localStorage', () => {
|
||||
localStorageMock.getItem.mockReturnValueOnce('invalid json{');
|
||||
|
||||
const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue'));
|
||||
|
||||
expect(result.current[0]).toBe('defaultValue');
|
||||
expect(console.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
297
frontend/src/hooks/__tests__/useSmartLogos.test.jsx
Normal file
297
frontend/src/hooks/__tests__/useSmartLogos.test.jsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useLogoSelection, useChannelLogoSelection, useLogosById } from '../useSmartLogos';
|
||||
import useLogosStore from '../../store/logos';
|
||||
|
||||
// Mock the logos store
|
||||
vi.mock('../../store/logos');
|
||||
|
||||
describe('useSmartLogos', () => {
|
||||
describe('useLogoSelection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with empty state', () => {
|
||||
useLogosStore.mockImplementation((selector) => selector({
|
||||
logos: {},
|
||||
fetchLogos: vi.fn(),
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useLogoSelection());
|
||||
|
||||
expect(result.current.logos).toEqual({});
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.hasLogos).toBe(false);
|
||||
});
|
||||
|
||||
it('should load logos when ensureLogosLoaded is called', async () => {
|
||||
const mockFetchLogos = vi.fn().mockResolvedValue();
|
||||
useLogosStore.mockImplementation((selector) => selector({
|
||||
logos: {},
|
||||
fetchLogos: mockFetchLogos,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useLogoSelection());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.ensureLogosLoaded();
|
||||
});
|
||||
|
||||
expect(mockFetchLogos).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not reload logos if already loaded', async () => {
|
||||
const mockFetchLogos = vi.fn().mockResolvedValue();
|
||||
useLogosStore.mockImplementation((selector) => selector({
|
||||
logos: { logo1: { id: 'logo1' } },
|
||||
fetchLogos: mockFetchLogos,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useLogoSelection());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.ensureLogosLoaded();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.ensureLogosLoaded();
|
||||
});
|
||||
|
||||
expect(mockFetchLogos).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle errors when fetching logos', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const mockFetchLogos = vi.fn().mockRejectedValue(new Error('Fetch failed'));
|
||||
useLogosStore.mockImplementation((selector) => selector({
|
||||
logos: {},
|
||||
fetchLogos: mockFetchLogos,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useLogoSelection());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.ensureLogosLoaded();
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to load logos for selection:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should indicate hasLogos when logos are present', () => {
|
||||
useLogosStore.mockImplementation((selector) => selector({
|
||||
logos: { logo1: { id: 'logo1' }, logo2: { id: 'logo2' } },
|
||||
fetchLogos: vi.fn(),
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useLogoSelection());
|
||||
|
||||
expect(result.current.hasLogos).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useChannelLogoSelection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with channel logos state', () => {
|
||||
useLogosStore.mockImplementation((selector) => selector({
|
||||
channelLogos: {},
|
||||
hasLoadedChannelLogos: false,
|
||||
backgroundLoading: false,
|
||||
fetchChannelAssignableLogos: vi.fn(),
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useChannelLogoSelection());
|
||||
|
||||
expect(result.current.logos).toEqual({});
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.hasLogos).toBe(false);
|
||||
});
|
||||
|
||||
it('should load channel logos when ensureLogosLoaded is called', async () => {
|
||||
const mockFetchChannelLogos = vi.fn().mockResolvedValue();
|
||||
useLogosStore.mockImplementation((selector) => selector({
|
||||
channelLogos: {},
|
||||
hasLoadedChannelLogos: false,
|
||||
backgroundLoading: false,
|
||||
fetchChannelAssignableLogos: mockFetchChannelLogos,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useChannelLogoSelection());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.ensureLogosLoaded();
|
||||
});
|
||||
|
||||
expect(mockFetchChannelLogos).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not reload if already loaded', async () => {
|
||||
const mockFetchChannelLogos = vi.fn().mockResolvedValue();
|
||||
useLogosStore.mockImplementation((selector) => selector({
|
||||
channelLogos: { logo1: { id: 'logo1' } },
|
||||
hasLoadedChannelLogos: true,
|
||||
backgroundLoading: false,
|
||||
fetchChannelAssignableLogos: mockFetchChannelLogos,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useChannelLogoSelection());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.ensureLogosLoaded();
|
||||
});
|
||||
|
||||
expect(mockFetchChannelLogos).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not load if backgroundLoading is true', async () => {
|
||||
const mockFetchChannelLogos = vi.fn().mockResolvedValue();
|
||||
useLogosStore.mockImplementation((selector) => selector({
|
||||
channelLogos: {},
|
||||
hasLoadedChannelLogos: false,
|
||||
backgroundLoading: true,
|
||||
fetchChannelAssignableLogos: mockFetchChannelLogos,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useChannelLogoSelection());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.ensureLogosLoaded();
|
||||
});
|
||||
|
||||
expect(mockFetchChannelLogos).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors when fetching channel logos', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const mockFetchChannelLogos = vi.fn().mockRejectedValue(new Error('Fetch failed'));
|
||||
useLogosStore.mockImplementation((selector) => selector({
|
||||
channelLogos: {},
|
||||
hasLoadedChannelLogos: false,
|
||||
backgroundLoading: false,
|
||||
fetchChannelAssignableLogos: mockFetchChannelLogos,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useChannelLogoSelection());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.ensureLogosLoaded();
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to load channel logos:',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLogosById', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with empty logos', () => {
|
||||
useLogosStore.mockImplementation((selector) => selector({
|
||||
logos: {},
|
||||
fetchLogosByIds: vi.fn(),
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useLogosById([]));
|
||||
|
||||
expect(result.current.logos).toEqual({});
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.missingLogos).toBe(0);
|
||||
});
|
||||
|
||||
it('should fetch missing logos by IDs', async () => {
|
||||
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
|
||||
useLogosStore.mockImplementation((selector) => selector({
|
||||
logos: {},
|
||||
fetchLogosByIds: mockFetchLogosByIds,
|
||||
}));
|
||||
|
||||
renderHook(() => useLogosById(['logo1', 'logo2']));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo1', 'logo2']);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not fetch logos that are already loaded', async () => {
|
||||
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
|
||||
useLogosStore.mockImplementation((selector) => selector({
|
||||
logos: { logo1: { id: 'logo1' } },
|
||||
fetchLogosByIds: mockFetchLogosByIds,
|
||||
}));
|
||||
|
||||
renderHook(() => useLogosById(['logo1', 'logo2']));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo2']);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors when fetching logos by IDs', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const mockFetchLogosByIds = vi.fn().mockRejectedValue(new Error('Fetch failed'));
|
||||
useLogosStore.mockImplementation((selector) => selector({
|
||||
logos: {},
|
||||
fetchLogosByIds: mockFetchLogosByIds,
|
||||
}));
|
||||
|
||||
renderHook(() => useLogosById(['logo1']));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to load logos by IDs:',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should filter out null/undefined IDs', async () => {
|
||||
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
|
||||
useLogosStore.mockImplementation((selector) => selector({
|
||||
logos: {},
|
||||
fetchLogosByIds: mockFetchLogosByIds,
|
||||
}));
|
||||
|
||||
renderHook(() => useLogosById(['logo1', null, undefined, 'logo2']));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo1', 'logo2']);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not refetch the same IDs multiple times', async () => {
|
||||
const mockFetchLogosByIds = vi.fn().mockResolvedValue();
|
||||
useLogosStore.mockImplementation((selector) => selector({
|
||||
logos: {},
|
||||
fetchLogosByIds: mockFetchLogosByIds,
|
||||
}));
|
||||
|
||||
const { rerender } = renderHook(() => useLogosById(['logo1']));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
390
frontend/src/hooks/__tests__/useTablePreferences.test.jsx
Normal file
390
frontend/src/hooks/__tests__/useTablePreferences.test.jsx
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import useTablePreferences from '../useTablePreferences';
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store = {};
|
||||
|
||||
return {
|
||||
getItem: vi.fn((key) => store[key] || null),
|
||||
setItem: vi.fn((key, value) => {
|
||||
store[key] = value.toString();
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
removeItem: vi.fn((key) => {
|
||||
delete store[key];
|
||||
})
|
||||
};
|
||||
})();
|
||||
|
||||
global.localStorage = localStorageMock;
|
||||
|
||||
describe('useTablePreferences', () => {
|
||||
let consoleErrorSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
// Spy on console.error
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
localStorageMock.clear();
|
||||
|
||||
// Mock window.addEventListener and removeEventListener
|
||||
vi.spyOn(window, 'addEventListener');
|
||||
vi.spyOn(window, 'removeEventListener');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should initialize with default values when localStorage is empty', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
expect(result.current.headerPinned).toBe(false);
|
||||
expect(result.current.tableSize).toBe('default');
|
||||
});
|
||||
|
||||
it('should initialize headerPinned from localStorage', () => {
|
||||
localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
expect(result.current.headerPinned).toBe(true);
|
||||
});
|
||||
|
||||
it('should initialize tableSize from localStorage', () => {
|
||||
localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' }));
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
expect(result.current.tableSize).toBe('compact');
|
||||
});
|
||||
|
||||
it('should initialize both preferences from localStorage', () => {
|
||||
localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true, tableSize: 'comfortable' }));
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
expect(result.current.headerPinned).toBe(true);
|
||||
expect(result.current.tableSize).toBe('comfortable');
|
||||
});
|
||||
|
||||
it('should migrate tableSize from old localStorage location', () => {
|
||||
localStorageMock.setItem('table-size', JSON.stringify('compact'));
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
expect(result.current.tableSize).toBe('compact');
|
||||
});
|
||||
|
||||
it('should prefer new localStorage location over old location', () => {
|
||||
localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'comfortable' }));
|
||||
localStorageMock.setItem('table-size', JSON.stringify('compact'));
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
expect(result.current.tableSize).toBe('comfortable');
|
||||
});
|
||||
|
||||
it('should handle malformed JSON in localStorage gracefully', () => {
|
||||
localStorageMock.setItem('table-preferences', 'invalid json');
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
expect(result.current.headerPinned).toBe(false);
|
||||
expect(result.current.tableSize).toBe('default');
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setHeaderPinned', () => {
|
||||
it('should update headerPinned state', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setHeaderPinned(true);
|
||||
});
|
||||
|
||||
expect(result.current.headerPinned).toBe(true);
|
||||
});
|
||||
|
||||
it('should persist headerPinned to localStorage', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setHeaderPinned(true);
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'table-preferences',
|
||||
JSON.stringify({ headerPinned: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing preferences when updating headerPinned', () => {
|
||||
localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' }));
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setHeaderPinned(true);
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'table-preferences',
|
||||
JSON.stringify({ tableSize: 'compact', headerPinned: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch custom event when updating headerPinned', () => {
|
||||
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setHeaderPinned(true);
|
||||
});
|
||||
|
||||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'table-preferences-changed',
|
||||
detail: { headerPinned: true },
|
||||
})
|
||||
);
|
||||
|
||||
dispatchEventSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle localStorage errors gracefully', () => {
|
||||
localStorageMock.setItem('table-preferences', JSON.stringify({})); // Ensure it exists
|
||||
localStorageMock.setItem.mockImplementationOnce(() => {
|
||||
throw new Error('Storage error');
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setHeaderPinned(true);
|
||||
});
|
||||
|
||||
expect(result.current.headerPinned).toBe(true);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error saving headerPinned to localStorage:',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTableSize', () => {
|
||||
it('should update tableSize state', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setTableSize('compact');
|
||||
});
|
||||
|
||||
expect(result.current.tableSize).toBe('compact');
|
||||
});
|
||||
|
||||
it('should persist tableSize to localStorage', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setTableSize('comfortable');
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'table-preferences',
|
||||
JSON.stringify({ tableSize: 'comfortable' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing preferences when updating tableSize', () => {
|
||||
localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setTableSize('compact');
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'table-preferences',
|
||||
JSON.stringify({ headerPinned: true, tableSize: 'compact' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch custom event when updating tableSize', () => {
|
||||
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setTableSize('compact');
|
||||
});
|
||||
|
||||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'table-preferences-changed',
|
||||
detail: { tableSize: 'compact' },
|
||||
})
|
||||
);
|
||||
|
||||
dispatchEventSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle localStorage errors gracefully', () => {
|
||||
localStorageMock.setItem('table-preferences', JSON.stringify({})); // Ensure it exists
|
||||
localStorageMock.setItem.mockImplementationOnce(() => {
|
||||
throw new Error('Storage error');
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
result.current.setTableSize('compact');
|
||||
});
|
||||
|
||||
expect(result.current.tableSize).toBe('compact');
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error saving tableSize to localStorage:',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event Listeners', () => {
|
||||
it('should register event listener on mount', () => {
|
||||
renderHook(() => useTablePreferences());
|
||||
|
||||
expect(window.addEventListener).toHaveBeenCalledWith(
|
||||
'table-preferences-changed',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove event listener on unmount', () => {
|
||||
const { unmount } = renderHook(() => useTablePreferences());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(window.removeEventListener).toHaveBeenCalledWith(
|
||||
'table-preferences-changed',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('should update headerPinned from custom event', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
const event = new CustomEvent('table-preferences-changed', {
|
||||
detail: { headerPinned: true },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(result.current.headerPinned).toBe(true);
|
||||
});
|
||||
|
||||
it('should update tableSize from custom event', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
const event = new CustomEvent('table-preferences-changed', {
|
||||
detail: { tableSize: 'compact' },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(result.current.tableSize).toBe('compact');
|
||||
});
|
||||
|
||||
it('should update both preferences from custom event', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
act(() => {
|
||||
const event = new CustomEvent('table-preferences-changed', {
|
||||
detail: { headerPinned: true, tableSize: 'comfortable' },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(result.current.headerPinned).toBe(true);
|
||||
expect(result.current.tableSize).toBe('comfortable');
|
||||
});
|
||||
|
||||
it('should not update if value is the same', () => {
|
||||
localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true }));
|
||||
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
const initialHeaderPinned = result.current.headerPinned;
|
||||
|
||||
act(() => {
|
||||
const event = new CustomEvent('table-preferences-changed', {
|
||||
detail: { headerPinned: true },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(result.current.headerPinned).toBe(initialHeaderPinned);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
it('should handle complete workflow', () => {
|
||||
const { result } = renderHook(() => useTablePreferences());
|
||||
|
||||
// Update headerPinned
|
||||
act(() => {
|
||||
result.current.setHeaderPinned(true);
|
||||
});
|
||||
|
||||
expect(result.current.headerPinned).toBe(true);
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'table-preferences',
|
||||
JSON.stringify({ headerPinned: true })
|
||||
);
|
||||
|
||||
// Update tableSize
|
||||
act(() => {
|
||||
result.current.setTableSize('compact');
|
||||
});
|
||||
|
||||
expect(result.current.tableSize).toBe('compact');
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'table-preferences',
|
||||
JSON.stringify({ headerPinned: true, tableSize: 'compact' })
|
||||
);
|
||||
|
||||
// Verify both preferences are maintained
|
||||
expect(result.current.headerPinned).toBe(true);
|
||||
expect(result.current.tableSize).toBe('compact');
|
||||
});
|
||||
|
||||
it('should sync changes across multiple hook instances via events', () => {
|
||||
const { result: result1 } = renderHook(() => useTablePreferences());
|
||||
const { result: result2 } = renderHook(() => useTablePreferences());
|
||||
|
||||
// Update from first instance
|
||||
act(() => {
|
||||
result1.current.setHeaderPinned(true);
|
||||
});
|
||||
|
||||
// Second instance should receive the event
|
||||
act(() => {
|
||||
const event = new CustomEvent('table-preferences-changed', {
|
||||
detail: { headerPinned: true },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(result2.current.headerPinned).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const useLocalStorage = (key, defaultValue) => {
|
||||
const localKey = key;
|
||||
|
|
|
|||
631
frontend/src/store/__tests__/auth.test.jsx
Normal file
631
frontend/src/store/__tests__/auth.test.jsx
Normal file
|
|
@ -0,0 +1,631 @@
|
|||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import useAuthStore from '../auth';
|
||||
import useSettingsStore from '../settings';
|
||||
import useChannelsStore from '../channels';
|
||||
import usePlaylistsStore from '../playlists';
|
||||
import useEPGsStore from '../epgs';
|
||||
import useStreamProfilesStore from '../streamProfiles';
|
||||
import useUserAgentsStore from '../userAgents';
|
||||
import useUsersStore from '../users';
|
||||
import API from '../../api';
|
||||
import { USER_LEVELS } from '../../constants';
|
||||
|
||||
// Mock all store dependencies
|
||||
vi.mock('../settings');
|
||||
vi.mock('../channels');
|
||||
vi.mock('../playlists');
|
||||
vi.mock('../epgs');
|
||||
vi.mock('../streamProfiles');
|
||||
vi.mock('../userAgents');
|
||||
vi.mock('../users');
|
||||
vi.mock('../../api');
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store = {};
|
||||
return {
|
||||
getItem: vi.fn((key) => store[key] || null),
|
||||
setItem: vi.fn((key, value) => {
|
||||
store[key] = value.toString();
|
||||
}),
|
||||
removeItem: vi.fn((key) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
};
|
||||
})();
|
||||
|
||||
global.localStorage = localStorageMock;
|
||||
|
||||
// Helper to create a mock JWT token
|
||||
const createMockToken = (expiresInSeconds = 3600) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const exp = now + expiresInSeconds;
|
||||
const payload = btoa(JSON.stringify({ exp }));
|
||||
return `header.${payload}.signature`;
|
||||
};
|
||||
|
||||
describe('useAuthStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorageMock.clear();
|
||||
|
||||
// Setup default store mocks
|
||||
useSettingsStore.mockImplementation((selector) => selector({
|
||||
fetchSettings: vi.fn().mockResolvedValue(),
|
||||
}));
|
||||
|
||||
useChannelsStore.mockImplementation((selector) => selector({
|
||||
fetchChannels: vi.fn().mockResolvedValue(),
|
||||
fetchChannelGroups: vi.fn().mockResolvedValue(),
|
||||
fetchChannelProfiles: vi.fn().mockResolvedValue(),
|
||||
}));
|
||||
|
||||
usePlaylistsStore.mockImplementation((selector) => selector({
|
||||
fetchPlaylists: vi.fn().mockResolvedValue(),
|
||||
}));
|
||||
|
||||
useEPGsStore.mockImplementation((selector) => selector({
|
||||
fetchEPGs: vi.fn().mockResolvedValue(),
|
||||
fetchEPGData: vi.fn().mockResolvedValue(),
|
||||
}));
|
||||
|
||||
useStreamProfilesStore.mockImplementation((selector) => selector({
|
||||
fetchProfiles: vi.fn().mockResolvedValue(),
|
||||
}));
|
||||
|
||||
useUserAgentsStore.mockImplementation((selector) => selector({
|
||||
fetchUserAgents: vi.fn().mockResolvedValue(),
|
||||
}));
|
||||
|
||||
useUsersStore.mockImplementation((selector) => selector({
|
||||
fetchUsers: vi.fn().mockResolvedValue(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Reset the store state
|
||||
const { setState } = useAuthStore;
|
||||
if (setState) {
|
||||
setState({
|
||||
isAuthenticated: false,
|
||||
isInitialized: false,
|
||||
needsSuperuser: false,
|
||||
user: {
|
||||
username: '',
|
||||
email: '',
|
||||
user_level: '',
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
tokenExpiration: null,
|
||||
superuserExists: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
expect(result.current.isInitialized).toBe(false);
|
||||
expect(result.current.needsSuperuser).toBe(false);
|
||||
expect(result.current.user).toEqual({
|
||||
username: '',
|
||||
email: '',
|
||||
user_level: '',
|
||||
});
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.superuserExists).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should successfully login and store tokens', async () => {
|
||||
const mockAccessToken = createMockToken();
|
||||
const mockRefreshToken = createMockToken(86400);
|
||||
|
||||
API.login.mockResolvedValue({
|
||||
access: mockAccessToken,
|
||||
refresh: mockRefreshToken,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.login({ username: 'testuser', password: 'password' });
|
||||
});
|
||||
|
||||
expect(API.login).toHaveBeenCalledWith('testuser', 'password');
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockAccessToken);
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('refreshToken', mockRefreshToken);
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('tokenExpiration', expect.any(Number));
|
||||
});
|
||||
|
||||
it('should handle login failure', async () => {
|
||||
API.login.mockRejectedValue(new Error('Invalid credentials'));
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.login({ username: 'testuser', password: 'wrong' });
|
||||
});
|
||||
|
||||
expect(API.login).toHaveBeenCalledWith('testuser', 'wrong');
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
expect(localStorageMock.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRefreshToken', () => {
|
||||
it('should refresh token successfully', async () => {
|
||||
const mockNewAccessToken = createMockToken();
|
||||
localStorageMock.getItem.mockReturnValue('old-refresh-token');
|
||||
|
||||
API.refreshToken.mockResolvedValue({
|
||||
access: mockNewAccessToken,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
let newToken;
|
||||
await act(async () => {
|
||||
newToken = await result.current.getRefreshToken();
|
||||
});
|
||||
|
||||
expect(API.refreshToken).toHaveBeenCalledWith('old-refresh-token');
|
||||
expect(newToken).toBe(mockNewAccessToken);
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockNewAccessToken);
|
||||
});
|
||||
|
||||
it('should return false if no refresh token exists', async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
let response;
|
||||
await act(async () => {
|
||||
response = await result.current.getRefreshToken();
|
||||
});
|
||||
|
||||
expect(response).toBe(false);
|
||||
expect(API.refreshToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should logout on refresh token failure', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('invalid-refresh-token');
|
||||
API.refreshToken.mockRejectedValue(new Error('Invalid token'));
|
||||
API.logout.mockResolvedValue();
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.getRefreshToken();
|
||||
});
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken');
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken');
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToken', () => {
|
||||
it('should return valid access token if not expired', async () => {
|
||||
const mockToken = createMockToken(3600);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
localStorageMock.getItem.mockImplementation((key) => {
|
||||
if (key === 'tokenExpiration') return (now + 1800).toString();
|
||||
if (key === 'accessToken') return mockToken;
|
||||
return null;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
let token;
|
||||
await act(async () => {
|
||||
token = await result.current.getToken();
|
||||
});
|
||||
|
||||
expect(token).toBe(mockToken);
|
||||
});
|
||||
|
||||
it('should refresh token if expired', async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const mockNewToken = createMockToken();
|
||||
|
||||
localStorageMock.getItem.mockImplementation((key) => {
|
||||
if (key === 'tokenExpiration') return (now - 100).toString();
|
||||
if (key === 'refreshToken') return 'refresh-token';
|
||||
return null;
|
||||
});
|
||||
|
||||
API.refreshToken.mockResolvedValue({
|
||||
access: mockNewToken,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.getToken();
|
||||
});
|
||||
|
||||
expect(API.refreshToken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should clear tokens and call logout API', async () => {
|
||||
API.logout.mockResolvedValue();
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout();
|
||||
});
|
||||
|
||||
expect(API.logout).toHaveBeenCalled();
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken');
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken');
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration');
|
||||
});
|
||||
|
||||
it('should continue logout even if API call fails', async () => {
|
||||
API.logout.mockRejectedValue(new Error('API error'));
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout();
|
||||
});
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeAuth', () => {
|
||||
it('should initialize auth with valid refresh token', async () => {
|
||||
const mockToken = createMockToken();
|
||||
localStorageMock.getItem.mockReturnValue('valid-refresh-token');
|
||||
API.refreshToken.mockResolvedValue({ access: mockToken });
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
let initialized;
|
||||
await act(async () => {
|
||||
initialized = await result.current.initializeAuth();
|
||||
});
|
||||
|
||||
expect(initialized).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if no refresh token exists', async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
let initialized;
|
||||
await act(async () => {
|
||||
initialized = await result.current.initializeAuth();
|
||||
});
|
||||
|
||||
expect(initialized).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initData', () => {
|
||||
const fetchSettings = vi.fn().mockResolvedValue();
|
||||
const fetchChannels = vi.fn().mockResolvedValue();
|
||||
const fetchChannelGroups = vi.fn().mockResolvedValue();
|
||||
const fetchChannelProfiles = vi.fn().mockResolvedValue();
|
||||
const fetchPlaylists = vi.fn().mockResolvedValue();
|
||||
const fetchEPGs = vi.fn().mockResolvedValue();
|
||||
const fetchEPGData = vi.fn().mockResolvedValue();
|
||||
const fetchProfiles = vi.fn().mockResolvedValue();
|
||||
const fetchUserAgents = vi.fn().mockResolvedValue();
|
||||
const fetchUsers = vi.fn().mockResolvedValue();
|
||||
|
||||
// Mock getState for each store
|
||||
useSettingsStore.getState = () => ({ fetchSettings });
|
||||
useChannelsStore.getState = () => ({
|
||||
fetchChannels,
|
||||
fetchChannelGroups,
|
||||
fetchChannelProfiles,
|
||||
});
|
||||
usePlaylistsStore.getState = () => ({ fetchPlaylists });
|
||||
useEPGsStore.getState = () => ({ fetchEPGs, fetchEPGData });
|
||||
useStreamProfilesStore.getState = () => ({ fetchProfiles });
|
||||
useUserAgentsStore.getState = () => ({ fetchUserAgents });
|
||||
useUsersStore.getState = () => ({ fetchUsers });
|
||||
|
||||
it('should initialize data for admin user', async () => {
|
||||
const mockUser = {
|
||||
username: 'admin',
|
||||
email: 'admin@test.com',
|
||||
user_level: USER_LEVELS.ADMIN,
|
||||
};
|
||||
|
||||
API.me.mockResolvedValue(mockUser);
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initData();
|
||||
});
|
||||
|
||||
expect(API.me).toHaveBeenCalled();
|
||||
expect(result.current.user).toEqual(mockUser);
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
expect(fetchSettings).toHaveBeenCalled();
|
||||
expect(fetchChannels).toHaveBeenCalled();
|
||||
expect(fetchUsers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it('should not fetch users for non-admin user', async () => {
|
||||
const mockUser = {
|
||||
username: 'reseller',
|
||||
email: 'reseller@test.com',
|
||||
user_level: USER_LEVELS.RESELLER,
|
||||
};
|
||||
|
||||
API.me.mockResolvedValue(mockUser);
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initData();
|
||||
});
|
||||
|
||||
expect(fetchUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error for unauthorized user level', async () => {
|
||||
const mockUser = {
|
||||
username: 'streamer',
|
||||
email: 'streamer@test.com',
|
||||
user_level: USER_LEVELS.STREAMER,
|
||||
};
|
||||
|
||||
API.me.mockResolvedValue(mockUser);
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.initData();
|
||||
})
|
||||
).rejects.toThrow('Unauthorized');
|
||||
});
|
||||
|
||||
it('should handle errors during data initialization', async () => {
|
||||
const mockUser = {
|
||||
username: 'admin',
|
||||
email: 'admin@test.com',
|
||||
user_level: USER_LEVELS.ADMIN,
|
||||
};
|
||||
|
||||
API.me.mockResolvedValue(mockUser);
|
||||
|
||||
const fetchChannels = vi.fn().mockRejectedValue(new Error('Fetch failed'));
|
||||
|
||||
useChannelsStore.getState = vi.fn(() => ({
|
||||
fetchChannels,
|
||||
fetchChannelGroups: vi.fn().mockResolvedValue(),
|
||||
fetchChannelProfiles: vi.fn().mockResolvedValue(),
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initData();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setUser', () => {
|
||||
it('should update user state', () => {
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
const newUser = { username: 'test', email: 'test@test.com', user_level: USER_LEVELS.ADMIN };
|
||||
|
||||
act(() => {
|
||||
result.current.setUser(newUser);
|
||||
});
|
||||
|
||||
expect(result.current.user).toEqual(newUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setIsAuthenticated', () => {
|
||||
it('should update authentication state', () => {
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setIsAuthenticated(true);
|
||||
});
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSuperuserExists', () => {
|
||||
it('should update superuser exists state', () => {
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSuperuserExists(false);
|
||||
});
|
||||
|
||||
expect(result.current.superuserExists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRefreshToken edge cases', () => {
|
||||
it('should return false if API response has no access token', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('refresh-token');
|
||||
API.refreshToken.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
let response;
|
||||
await act(async () => {
|
||||
response = await result.current.getRefreshToken();
|
||||
});
|
||||
|
||||
expect(response).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login edge cases', () => {
|
||||
it('should not update state if response has no access token', async () => {
|
||||
API.login.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.login({ username: 'testuser', password: 'password' });
|
||||
});
|
||||
|
||||
expect(result.current.accessToken).toBeNull();
|
||||
expect(localStorageMock.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout edge cases', () => {
|
||||
it('should reset isInitializing flag on logout', async () => {
|
||||
useAuthStore.setState({ isInitializing: true });
|
||||
API.logout.mockResolvedValue();
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.logout();
|
||||
});
|
||||
|
||||
expect(result.current.isInitializing).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeAuth edge cases', () => {
|
||||
it('should return false if refresh token API call fails', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('refresh-token');
|
||||
API.refreshToken.mockRejectedValue(new Error('Token expired'));
|
||||
API.logout.mockResolvedValue();
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
let initialized;
|
||||
await act(async () => {
|
||||
initialized = await result.current.initializeAuth();
|
||||
});
|
||||
|
||||
expect(initialized).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if refresh returns no access token', async () => {
|
||||
localStorageMock.getItem.mockReturnValue('refresh-token');
|
||||
API.refreshToken.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
let initialized;
|
||||
await act(async () => {
|
||||
initialized = await result.current.initializeAuth();
|
||||
});
|
||||
|
||||
expect(initialized).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initData edge cases', () => {
|
||||
it('should skip initialization if already initialized', async () => {
|
||||
useAuthStore.setState({ isInitialized: true });
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initData();
|
||||
});
|
||||
|
||||
expect(API.me).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip initialization if already initializing', async () => {
|
||||
useAuthStore.setState({ isInitializing: true });
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initData();
|
||||
});
|
||||
|
||||
expect(API.me).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set isInitializing to false on error', async () => {
|
||||
// Reset state before the test
|
||||
useAuthStore.setState({
|
||||
isInitializing: false,
|
||||
isInitialized: false
|
||||
});
|
||||
|
||||
API.me.mockRejectedValue(new Error('API error'));
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.initData();
|
||||
} catch {
|
||||
// Expected error
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.current.isInitializing).toBe(false);
|
||||
expect(result.current.isInitialized).toBe(false);
|
||||
});
|
||||
|
||||
it('should call fetchChannels in background after initialization', async () => {
|
||||
const mockUser = {
|
||||
username: 'admin',
|
||||
email: 'admin@test.com',
|
||||
user_level: USER_LEVELS.ADMIN,
|
||||
};
|
||||
|
||||
const fetchChannels = vi.fn().mockResolvedValue();
|
||||
useChannelsStore.getState = () => ({
|
||||
fetchChannels,
|
||||
fetchChannelGroups: vi.fn().mockResolvedValue(),
|
||||
fetchChannelProfiles: vi.fn().mockResolvedValue(),
|
||||
});
|
||||
|
||||
API.me.mockResolvedValue(mockUser);
|
||||
|
||||
const { result } = renderHook(() => useAuthStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.initData();
|
||||
});
|
||||
|
||||
// Wait for the background call to complete
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
// The background fetchChannels is called synchronously without await
|
||||
// so we just need to verify it was called
|
||||
expect(fetchChannels).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
442
frontend/src/store/__tests__/channels.test.jsx
Normal file
442
frontend/src/store/__tests__/channels.test.jsx
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import useChannelsStore from '../channels';
|
||||
import api from '../../api';
|
||||
import { showNotification } from '../../utils/notificationUtils';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../api');
|
||||
vi.mock('../../utils/notificationUtils');
|
||||
|
||||
describe('useChannelsStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset store state between tests
|
||||
useChannelsStore.setState({
|
||||
channels: {},
|
||||
channelsByUUID: {},
|
||||
channelGroups: {},
|
||||
profiles: { 0: { id: '0', name: 'All', channels: new Set() } },
|
||||
selectedProfileId: '0',
|
||||
channelsPageSelection: [],
|
||||
stats: {},
|
||||
activeChannels: {},
|
||||
activeClients: {},
|
||||
recordings: [],
|
||||
recurringRules: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
forceUpdate: 0,
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchChannels', () => {
|
||||
it('should fetch and store channels successfully', async () => {
|
||||
const mockChannels = [
|
||||
{ id: 1, uuid: 'uuid-1', name: 'Channel 1' },
|
||||
{ id: 2, uuid: 'uuid-2', name: 'Channel 2' },
|
||||
];
|
||||
api.getChannels.mockResolvedValue(mockChannels);
|
||||
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchChannels();
|
||||
});
|
||||
|
||||
expect(api.getChannels).toHaveBeenCalledOnce();
|
||||
expect(result.current.channels).toEqual({
|
||||
1: mockChannels[0],
|
||||
2: mockChannels[1],
|
||||
});
|
||||
expect(result.current.channelsByUUID).toEqual({
|
||||
'uuid-1': 1,
|
||||
'uuid-2': 2,
|
||||
});
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle fetch error', async () => {
|
||||
const errorMessage = 'Network error';
|
||||
api.getChannels.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchChannels();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe(errorMessage);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchChannelGroups', () => {
|
||||
it('should fetch and process channel groups', async () => {
|
||||
const mockGroups = [
|
||||
{ id: 1, name: 'Group 1', channel_count: 5, m3u_account_count: 0 },
|
||||
{ id: 2, name: 'Group 2', channel_count: 0, m3u_account_count: 2 },
|
||||
];
|
||||
api.getChannelGroups.mockResolvedValue(mockGroups);
|
||||
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchChannelGroups();
|
||||
});
|
||||
|
||||
expect(api.getChannelGroups).toHaveBeenCalledOnce();
|
||||
expect(result.current.channelGroups[1]).toMatchObject({
|
||||
hasChannels: true,
|
||||
hasM3UAccounts: false,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
});
|
||||
expect(result.current.channelGroups[2]).toMatchObject({
|
||||
hasChannels: false,
|
||||
hasM3UAccounts: true,
|
||||
canEdit: false,
|
||||
canDelete: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchChannelProfiles', () => {
|
||||
it('should fetch and process channel profiles', async () => {
|
||||
const mockProfiles = [
|
||||
{ id: '1', name: 'Profile 1', channels: [1, 2, 3] },
|
||||
];
|
||||
api.getChannelProfiles.mockResolvedValue(mockProfiles);
|
||||
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchChannelProfiles();
|
||||
});
|
||||
|
||||
expect(api.getChannelProfiles).toHaveBeenCalledOnce();
|
||||
expect(result.current.profiles['1'].channels).toBeInstanceOf(Set);
|
||||
expect(result.current.profiles['1'].channels.has(1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addChannel', () => {
|
||||
it('should add a new channel', async () => {
|
||||
const newChannel = { id: 3, uuid: 'uuid-3', name: 'Channel 3' };
|
||||
api.getChannelProfiles.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
await act(async () => {
|
||||
result.current.addChannel(newChannel);
|
||||
});
|
||||
|
||||
expect(result.current.channels[3]).toEqual(newChannel);
|
||||
expect(result.current.channelsByUUID['uuid-3']).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateChannel', () => {
|
||||
it('should update an existing channel', () => {
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
act(() => {
|
||||
useChannelsStore.setState({
|
||||
channels: { 1: { id: 1, uuid: 'uuid-1', name: 'Old Name' } },
|
||||
channelsByUUID: { 'uuid-1': 1 },
|
||||
});
|
||||
});
|
||||
|
||||
const updatedChannel = { id: 1, uuid: 'uuid-1', name: 'New Name' };
|
||||
|
||||
act(() => {
|
||||
result.current.updateChannel(updatedChannel);
|
||||
});
|
||||
|
||||
expect(result.current.channels[1].name).toBe('New Name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeChannels', () => {
|
||||
it('should remove channels by IDs', () => {
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
act(() => {
|
||||
useChannelsStore.setState({
|
||||
channels: {
|
||||
1: { id: 1, uuid: 'uuid-1' },
|
||||
2: { id: 2, uuid: 'uuid-2' },
|
||||
},
|
||||
channelsByUUID: { 'uuid-1': 1, 'uuid-2': 2 },
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removeChannels([1]);
|
||||
});
|
||||
|
||||
expect(result.current.channels[1]).toBeUndefined();
|
||||
expect(result.current.channelsByUUID['uuid-1']).toBeUndefined();
|
||||
expect(result.current.channels[2]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel groups operations', () => {
|
||||
it('should add a channel group', () => {
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
const newGroup = { id: 1, name: 'New Group' };
|
||||
|
||||
act(() => {
|
||||
result.current.addChannelGroup(newGroup);
|
||||
});
|
||||
|
||||
expect(result.current.channelGroups[1]).toEqual(newGroup);
|
||||
});
|
||||
|
||||
it('should update a channel group', () => {
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
act(() => {
|
||||
useChannelsStore.setState({
|
||||
channelGroups: { 1: { id: 1, name: 'Old Name' } },
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateChannelGroup({ id: 1, name: 'Updated Name' });
|
||||
});
|
||||
|
||||
expect(result.current.channelGroups[1].name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should remove a channel group', () => {
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
act(() => {
|
||||
useChannelsStore.setState({
|
||||
channelGroups: { 1: { id: 1, name: 'Group' } },
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removeChannelGroup(1);
|
||||
});
|
||||
|
||||
expect(result.current.channelGroups[1]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('profile operations', () => {
|
||||
it('should add a profile', () => {
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
const newProfile = { id: '1', name: 'Profile', channels: [1, 2] };
|
||||
|
||||
act(() => {
|
||||
result.current.addProfile(newProfile);
|
||||
});
|
||||
|
||||
expect(result.current.profiles['1'].channels).toBeInstanceOf(Set);
|
||||
});
|
||||
|
||||
it('should update a profile', () => {
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.updateProfile({ id: '1', name: 'Updated', channels: [3] });
|
||||
});
|
||||
|
||||
expect(result.current.profiles['1'].name).toBe('Updated');
|
||||
});
|
||||
|
||||
it('should remove profiles and reset selected if needed', () => {
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
act(() => {
|
||||
useChannelsStore.setState({
|
||||
profiles: { '1': { id: '1' }, '2': { id: '2' } },
|
||||
selectedProfileId: '1',
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removeProfiles(['1']);
|
||||
});
|
||||
|
||||
expect(result.current.profiles['1']).toBeUndefined();
|
||||
expect(result.current.selectedProfileId).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateProfileChannels', () => {
|
||||
it('should add channels to profile', () => {
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
act(() => {
|
||||
useChannelsStore.setState({
|
||||
profiles: { '1': { id: '1', channels: new Set([1]) } },
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateProfileChannels([2, 3], '1', true);
|
||||
});
|
||||
|
||||
expect(result.current.profiles['1'].channels.has(2)).toBe(true);
|
||||
expect(result.current.profiles['1'].channels.has(3)).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove channels from profile', () => {
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
act(() => {
|
||||
useChannelsStore.setState({
|
||||
profiles: { '1': { id: '1', channels: new Set([1, 2, 3]) } },
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateProfileChannels([2], '1', false);
|
||||
});
|
||||
|
||||
expect(result.current.profiles['1'].channels.has(2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setChannelStats', () => {
|
||||
it('should update stats and show notifications for new channels', () => {
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
act(() => {
|
||||
useChannelsStore.setState({
|
||||
channels: { 1: { id: 1, name: 'Channel 1' } },
|
||||
channelsByUUID: { 'uuid-1': 1 },
|
||||
stats: { channels: [] },
|
||||
});
|
||||
});
|
||||
|
||||
const newStats = {
|
||||
channels: [
|
||||
{ channel_id: 'uuid-1', clients: [] },
|
||||
],
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.setChannelStats(newStats);
|
||||
});
|
||||
|
||||
expect(result.current.stats).toEqual(newStats);
|
||||
expect(showNotification).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordings operations', () => {
|
||||
it('should fetch recordings', async () => {
|
||||
const mockRecordings = [{ id: 1, title: 'Recording 1' }];
|
||||
api.getRecordings.mockResolvedValue(mockRecordings);
|
||||
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchRecordings();
|
||||
});
|
||||
|
||||
expect(result.current.recordings).toEqual(mockRecordings);
|
||||
});
|
||||
|
||||
it('should remove a recording', () => {
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
act(() => {
|
||||
useChannelsStore.setState({
|
||||
recordings: [{ id: 1 }, { id: 2 }],
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removeRecording(1);
|
||||
});
|
||||
|
||||
expect(result.current.recordings).toHaveLength(1);
|
||||
expect(result.current.recordings[0].id).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recurring rules operations', () => {
|
||||
it('should fetch recurring rules', async () => {
|
||||
const mockRules = [{ id: 1, name: 'Rule 1' }];
|
||||
api.listRecurringRules.mockResolvedValue(mockRules);
|
||||
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchRecurringRules();
|
||||
});
|
||||
|
||||
expect(result.current.recurringRules).toEqual(mockRules);
|
||||
});
|
||||
|
||||
it('should remove a recurring rule', () => {
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
act(() => {
|
||||
useChannelsStore.setState({
|
||||
recurringRules: [{ id: 1 }, { id: 2 }],
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removeRecurringRule(1);
|
||||
});
|
||||
|
||||
expect(result.current.recurringRules).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('helper methods', () => {
|
||||
it('should validate if channel group can be edited', () => {
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
act(() => {
|
||||
useChannelsStore.setState({
|
||||
channelGroups: {
|
||||
1: { id: 1, canEdit: true },
|
||||
2: { id: 2, canEdit: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.canEditChannelGroup(1)).toBe(true);
|
||||
expect(result.current.canEditChannelGroup(2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate if channel group can be deleted', () => {
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
|
||||
act(() => {
|
||||
useChannelsStore.setState({
|
||||
channelGroups: {
|
||||
1: { id: 1, canDelete: true },
|
||||
2: { id: 2, canDelete: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.canDeleteChannelGroup(1)).toBe(true);
|
||||
expect(result.current.canDeleteChannelGroup(2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerUpdate', () => {
|
||||
it('should update forceUpdate timestamp', () => {
|
||||
const { result } = renderHook(() => useChannelsStore());
|
||||
const initialUpdate = result.current.forceUpdate;
|
||||
|
||||
act(() => {
|
||||
result.current.triggerUpdate();
|
||||
});
|
||||
|
||||
expect(result.current.forceUpdate).not.toBe(initialUpdate);
|
||||
});
|
||||
});
|
||||
});
|
||||
402
frontend/src/store/__tests__/channelsTable.test.jsx
Normal file
402
frontend/src/store/__tests__/channelsTable.test.jsx
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import useChannelsTableStore from '../channelsTable';
|
||||
|
||||
describe('useChannelsTableStore', () => {
|
||||
beforeEach(() => {
|
||||
// Mock localStorage
|
||||
const mockLocalStorage = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
global.localStorage = mockLocalStorage;
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset store state between tests
|
||||
useChannelsTableStore.setState({
|
||||
channels: [],
|
||||
pageCount: 0,
|
||||
totalCount: 0,
|
||||
sorting: [{ id: 'channel_number', desc: false }],
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: 50,
|
||||
},
|
||||
selectedChannelIds: [],
|
||||
allQueryIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
expect(result.current.channels).toEqual([]);
|
||||
expect(result.current.pageCount).toBe(0);
|
||||
expect(result.current.totalCount).toBe(0);
|
||||
expect(result.current.sorting).toEqual([{ id: 'channel_number', desc: false }]);
|
||||
expect(result.current.pagination.pageIndex).toBe(0);
|
||||
expect(result.current.pagination.pageSize).toBe(50);
|
||||
expect(result.current.selectedChannelIds).toEqual([]);
|
||||
expect(result.current.allQueryIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryChannels', () => {
|
||||
it('should update channels, totalCount, and pageCount', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
const mockResults = [
|
||||
{ id: 1, name: 'Channel 1' },
|
||||
{ id: 2, name: 'Channel 2' },
|
||||
];
|
||||
const mockData = {
|
||||
results: mockResults,
|
||||
count: 150,
|
||||
};
|
||||
const mockParams = new URLSearchParams({ page_size: '50' });
|
||||
|
||||
act(() => {
|
||||
result.current.queryChannels(mockData, mockParams);
|
||||
});
|
||||
|
||||
expect(result.current.channels).toEqual(mockResults);
|
||||
expect(result.current.totalCount).toBe(150);
|
||||
expect(result.current.pageCount).toBe(3); // Math.ceil(150 / 50)
|
||||
});
|
||||
|
||||
it('should calculate pageCount correctly with different page sizes', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
const mockData = {
|
||||
results: [],
|
||||
count: 75,
|
||||
};
|
||||
const mockParams = new URLSearchParams({ page_size: '25' });
|
||||
|
||||
act(() => {
|
||||
result.current.queryChannels(mockData, mockParams);
|
||||
});
|
||||
|
||||
expect(result.current.pageCount).toBe(3); // Math.ceil(75 / 25)
|
||||
});
|
||||
|
||||
it('should handle zero results', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
const mockData = {
|
||||
results: [],
|
||||
count: 0,
|
||||
};
|
||||
const mockParams = new URLSearchParams({ page_size: '50' });
|
||||
|
||||
act(() => {
|
||||
result.current.queryChannels(mockData, mockParams);
|
||||
});
|
||||
|
||||
expect(result.current.channels).toEqual([]);
|
||||
expect(result.current.totalCount).toBe(0);
|
||||
expect(result.current.pageCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAllQueryIds', () => {
|
||||
it('should update allQueryIds', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
const mockIds = [1, 2, 3, 4, 5];
|
||||
|
||||
act(() => {
|
||||
result.current.setAllQueryIds(mockIds);
|
||||
});
|
||||
|
||||
expect(result.current.allQueryIds).toEqual(mockIds);
|
||||
});
|
||||
|
||||
it('should replace existing allQueryIds', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setAllQueryIds([1, 2, 3]);
|
||||
});
|
||||
|
||||
expect(result.current.allQueryIds).toEqual([1, 2, 3]);
|
||||
|
||||
act(() => {
|
||||
result.current.setAllQueryIds([4, 5, 6]);
|
||||
});
|
||||
|
||||
expect(result.current.allQueryIds).toEqual([4, 5, 6]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSelectedChannelIds', () => {
|
||||
it('should update selectedChannelIds', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
const mockIds = [1, 3, 5];
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedChannelIds(mockIds);
|
||||
});
|
||||
|
||||
expect(result.current.selectedChannelIds).toEqual(mockIds);
|
||||
});
|
||||
|
||||
it('should clear selectedChannelIds when empty array is passed', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedChannelIds([1, 2, 3]);
|
||||
});
|
||||
|
||||
expect(result.current.selectedChannelIds).toEqual([1, 2, 3]);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedChannelIds([]);
|
||||
});
|
||||
|
||||
expect(result.current.selectedChannelIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChannelStreams', () => {
|
||||
it('should return streams for existing channel', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
const mockChannels = [
|
||||
{ id: 1, name: 'Channel 1', streams: ['stream1', 'stream2'] },
|
||||
{ id: 2, name: 'Channel 2', streams: ['stream3'] },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
useChannelsTableStore.setState({ channels: mockChannels });
|
||||
});
|
||||
|
||||
const streams = result.current.getChannelStreams(1);
|
||||
|
||||
expect(streams).toEqual(['stream1', 'stream2']);
|
||||
});
|
||||
|
||||
it('should return empty array for channel without streams', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
const mockChannels = [
|
||||
{ id: 1, name: 'Channel 1' },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
useChannelsTableStore.setState({ channels: mockChannels });
|
||||
});
|
||||
|
||||
const streams = result.current.getChannelStreams(1);
|
||||
|
||||
expect(streams).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent channel', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
const mockChannels = [
|
||||
{ id: 1, name: 'Channel 1', streams: ['stream1'] },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
useChannelsTableStore.setState({ channels: mockChannels });
|
||||
});
|
||||
|
||||
const streams = result.current.getChannelStreams(999);
|
||||
|
||||
expect(streams).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setPagination', () => {
|
||||
it('should update pagination', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
const newPagination = {
|
||||
pageIndex: 2,
|
||||
pageSize: 100,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.setPagination(newPagination);
|
||||
});
|
||||
|
||||
expect(result.current.pagination).toEqual(newPagination);
|
||||
});
|
||||
|
||||
it('should update only changed pagination properties', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setPagination({
|
||||
pageIndex: 5,
|
||||
pageSize: 50,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.pagination.pageIndex).toBe(5);
|
||||
expect(result.current.pagination.pageSize).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSorting', () => {
|
||||
it('should update sorting', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
const newSorting = [{ id: 'name', desc: true }];
|
||||
|
||||
act(() => {
|
||||
result.current.setSorting(newSorting);
|
||||
});
|
||||
|
||||
expect(result.current.sorting).toEqual(newSorting);
|
||||
});
|
||||
|
||||
it('should handle multiple sorting columns', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
const newSorting = [
|
||||
{ id: 'name', desc: false },
|
||||
{ id: 'channel_number', desc: true },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.setSorting(newSorting);
|
||||
});
|
||||
|
||||
expect(result.current.sorting).toEqual(newSorting);
|
||||
});
|
||||
|
||||
it('should clear sorting when empty array is passed', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSorting([{ id: 'name', desc: true }]);
|
||||
});
|
||||
|
||||
expect(result.current.sorting).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
result.current.setSorting([]);
|
||||
});
|
||||
|
||||
expect(result.current.sorting).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUnlocked', () => {
|
||||
it('should initialize with default false value', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
expect(result.current.isUnlocked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setIsUnlocked', () => {
|
||||
it('should update isUnlocked to true', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setIsUnlocked(true);
|
||||
});
|
||||
|
||||
expect(result.current.isUnlocked).toBe(true);
|
||||
});
|
||||
|
||||
it('should update isUnlocked to false', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setIsUnlocked(true);
|
||||
});
|
||||
|
||||
expect(result.current.isUnlocked).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.setIsUnlocked(false);
|
||||
});
|
||||
|
||||
expect(result.current.isUnlocked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateChannel', () => {
|
||||
it('should update an existing channel', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
const mockChannels = [
|
||||
{ id: 1, name: 'Channel 1', channel_number: 1 },
|
||||
{ id: 2, name: 'Channel 2', channel_number: 2 },
|
||||
{ id: 3, name: 'Channel 3', channel_number: 3 },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
useChannelsTableStore.setState({ channels: mockChannels });
|
||||
});
|
||||
|
||||
const updatedChannel = { id: 2, name: 'Updated Channel 2', channel_number: 22 };
|
||||
|
||||
act(() => {
|
||||
result.current.updateChannel(updatedChannel);
|
||||
});
|
||||
|
||||
expect(result.current.channels).toEqual([
|
||||
{ id: 1, name: 'Channel 1', channel_number: 1 },
|
||||
{ id: 2, name: 'Updated Channel 2', channel_number: 22 },
|
||||
{ id: 3, name: 'Channel 3', channel_number: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not modify channels when updating non-existent channel', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
const mockChannels = [
|
||||
{ id: 1, name: 'Channel 1', channel_number: 1 },
|
||||
{ id: 2, name: 'Channel 2', channel_number: 2 },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
useChannelsTableStore.setState({ channels: mockChannels });
|
||||
});
|
||||
|
||||
const updatedChannel = { id: 999, name: 'Non-existent', channel_number: 999 };
|
||||
|
||||
act(() => {
|
||||
result.current.updateChannel(updatedChannel);
|
||||
});
|
||||
|
||||
expect(result.current.channels).toEqual(mockChannels);
|
||||
});
|
||||
|
||||
it('should preserve other channels when updating one channel', () => {
|
||||
const { result } = renderHook(() => useChannelsTableStore());
|
||||
|
||||
const mockChannels = [
|
||||
{ id: 1, name: 'Channel 1', channel_number: 1, streams: ['stream1'] },
|
||||
{ id: 2, name: 'Channel 2', channel_number: 2, streams: ['stream2'] },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
useChannelsTableStore.setState({ channels: mockChannels });
|
||||
});
|
||||
|
||||
const updatedChannel = { id: 1, name: 'Updated Channel 1', channel_number: 10 };
|
||||
|
||||
act(() => {
|
||||
result.current.updateChannel(updatedChannel);
|
||||
});
|
||||
|
||||
expect(result.current.channels[0]).toEqual(updatedChannel);
|
||||
expect(result.current.channels[1]).toEqual(mockChannels[1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
687
frontend/src/store/__tests__/epgs.test.jsx
Normal file
687
frontend/src/store/__tests__/epgs.test.jsx
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import useEPGsStore from '../epgs';
|
||||
import api from '../../api';
|
||||
|
||||
// Mock the api module
|
||||
vi.mock('../../api');
|
||||
|
||||
describe('useEPGsStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset store state between tests
|
||||
useEPGsStore.setState({
|
||||
epgs: {},
|
||||
tvgs: [],
|
||||
tvgsById: {},
|
||||
tvgsLoaded: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refreshProgress: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
expect(result.current.epgs).toEqual({});
|
||||
expect(result.current.tvgs).toEqual([]);
|
||||
expect(result.current.tvgsById).toEqual({});
|
||||
expect(result.current.tvgsLoaded).toBe(false);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.refreshProgress).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchEPGs', () => {
|
||||
it('should fetch and store EPGs successfully', async () => {
|
||||
const mockEPGs = [
|
||||
{ id: 'epg1', name: 'EPG 1', status: 'idle' },
|
||||
{ id: 'epg2', name: 'EPG 2', status: 'success' },
|
||||
];
|
||||
|
||||
api.getEPGs.mockResolvedValue(mockEPGs);
|
||||
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchEPGs();
|
||||
});
|
||||
|
||||
expect(result.current.epgs).toEqual({
|
||||
epg1: { id: 'epg1', name: 'EPG 1', status: 'idle' },
|
||||
epg2: { id: 'epg2', name: 'EPG 2', status: 'success' },
|
||||
});
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(api.getEPGs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should set loading state while fetching', async () => {
|
||||
api.getEPGs.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100)));
|
||||
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
const fetchPromise = act(async () => {
|
||||
await result.current.fetchEPGs();
|
||||
});
|
||||
|
||||
// Check loading state immediately after calling
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
|
||||
await fetchPromise;
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle fetch error', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
api.getEPGs.mockRejectedValue(mockError);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchEPGs();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Failed to load epgs.');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch epgs:', mockError);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle empty EPGs array', async () => {
|
||||
api.getEPGs.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchEPGs();
|
||||
});
|
||||
|
||||
expect(result.current.epgs).toEqual({});
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchEPGData', () => {
|
||||
it('should fetch and store TVG data successfully', async () => {
|
||||
const mockTVGs = [
|
||||
{ id: 'tvg1', name: 'TVG 1' },
|
||||
{ id: 'tvg2', name: 'TVG 2' },
|
||||
];
|
||||
|
||||
api.getEPGData.mockResolvedValue(mockTVGs);
|
||||
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchEPGData();
|
||||
});
|
||||
|
||||
expect(result.current.tvgs).toEqual(mockTVGs);
|
||||
expect(result.current.tvgsById).toEqual({
|
||||
tvg1: { id: 'tvg1', name: 'TVG 1' },
|
||||
tvg2: { id: 'tvg2', name: 'TVG 2' },
|
||||
});
|
||||
expect(result.current.tvgsLoaded).toBe(true);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(api.getEPGData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should set loading state while fetching', async () => {
|
||||
api.getEPGData.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100)));
|
||||
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
const fetchPromise = act(async () => {
|
||||
await result.current.fetchEPGData();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
|
||||
await fetchPromise;
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle fetch error', async () => {
|
||||
const mockError = new Error('API error');
|
||||
api.getEPGData.mockRejectedValue(mockError);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchEPGData();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Failed to load tvgs.');
|
||||
expect(result.current.tvgsLoaded).toBe(true);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch tvgs:', mockError);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle empty TVG array', async () => {
|
||||
api.getEPGData.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchEPGData();
|
||||
});
|
||||
|
||||
expect(result.current.tvgs).toEqual([]);
|
||||
expect(result.current.tvgsById).toEqual({});
|
||||
expect(result.current.tvgsLoaded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addEPG', () => {
|
||||
it('should add new EPG to store', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
const newEPG = { id: 'epg1', name: 'New EPG', status: 'idle' };
|
||||
|
||||
act(() => {
|
||||
result.current.addEPG(newEPG);
|
||||
});
|
||||
|
||||
expect(result.current.epgs).toEqual({
|
||||
epg1: newEPG,
|
||||
});
|
||||
});
|
||||
|
||||
it('should add multiple EPGs', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
const epg1 = { id: 'epg1', name: 'EPG 1', status: 'idle' };
|
||||
const epg2 = { id: 'epg2', name: 'EPG 2', status: 'success' };
|
||||
|
||||
act(() => {
|
||||
result.current.addEPG(epg1);
|
||||
result.current.addEPG(epg2);
|
||||
});
|
||||
|
||||
expect(result.current.epgs).toEqual({
|
||||
epg1,
|
||||
epg2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not overwrite existing EPGs', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
const originalEPG = { id: 'epg1', name: 'Original', status: 'idle' };
|
||||
const newEPG = { id: 'epg2', name: 'New', status: 'success' };
|
||||
|
||||
act(() => {
|
||||
result.current.addEPG(originalEPG);
|
||||
result.current.addEPG(newEPG);
|
||||
});
|
||||
|
||||
expect(result.current.epgs).toEqual({
|
||||
epg1: originalEPG,
|
||||
epg2: newEPG,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEPG', () => {
|
||||
it('should update existing EPG', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
const originalEPG = { id: 'epg1', name: 'Original', status: 'idle' };
|
||||
const updatedEPG = { id: 'epg1', name: 'Updated', status: 'success' };
|
||||
|
||||
act(() => {
|
||||
result.current.addEPG(originalEPG);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPG(updatedEPG);
|
||||
});
|
||||
|
||||
expect(result.current.epgs.epg1).toEqual(updatedEPG);
|
||||
});
|
||||
|
||||
it('should add EPG if it does not exist', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
const newEPG = { id: 'epg1', name: 'New', status: 'idle' };
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPG(newEPG);
|
||||
});
|
||||
|
||||
expect(result.current.epgs.epg1).toEqual(newEPG);
|
||||
});
|
||||
|
||||
it('should not update state when called with invalid epg (null)', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
|
||||
act(() => {
|
||||
useEPGsStore.setState({ epgs: initialEPGs });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPG(null);
|
||||
});
|
||||
|
||||
expect(result.current.epgs).toEqual(initialEPGs);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', null);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not update state when called with invalid epg (missing id)', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
|
||||
act(() => {
|
||||
useEPGsStore.setState({ epgs: initialEPGs });
|
||||
});
|
||||
|
||||
const invalidEPG = { name: 'No ID' };
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPG(invalidEPG);
|
||||
});
|
||||
|
||||
expect(result.current.epgs).toEqual(initialEPGs);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', invalidEPG);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not update state when called with invalid epg (non-object)', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } };
|
||||
act(() => {
|
||||
useEPGsStore.setState({ epgs: initialEPGs });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPG('invalid');
|
||||
});
|
||||
|
||||
expect(result.current.epgs).toEqual(initialEPGs);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', 'invalid');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeEPGs', () => {
|
||||
it('should remove single EPG', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
act(() => {
|
||||
useEPGsStore.setState({
|
||||
epgs: {
|
||||
epg1: { id: 'epg1', name: 'EPG 1' },
|
||||
epg2: { id: 'epg2', name: 'EPG 2' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removeEPGs(['epg1']);
|
||||
});
|
||||
|
||||
expect(result.current.epgs).toEqual({
|
||||
epg2: { id: 'epg2', name: 'EPG 2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove multiple EPGs', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
act(() => {
|
||||
useEPGsStore.setState({
|
||||
epgs: {
|
||||
epg1: { id: 'epg1', name: 'EPG 1' },
|
||||
epg2: { id: 'epg2', name: 'EPG 2' },
|
||||
epg3: { id: 'epg3', name: 'EPG 3' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removeEPGs(['epg1', 'epg3']);
|
||||
});
|
||||
|
||||
expect(result.current.epgs).toEqual({
|
||||
epg2: { id: 'epg2', name: 'EPG 2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle removing non-existent EPG', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
const initialEPGs = {
|
||||
epg1: { id: 'epg1', name: 'EPG 1' },
|
||||
};
|
||||
|
||||
act(() => {
|
||||
useEPGsStore.setState({ epgs: initialEPGs });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removeEPGs(['nonexistent']);
|
||||
});
|
||||
|
||||
expect(result.current.epgs).toEqual(initialEPGs);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
const initialEPGs = {
|
||||
epg1: { id: 'epg1', name: 'EPG 1' },
|
||||
};
|
||||
|
||||
act(() => {
|
||||
useEPGsStore.setState({ epgs: initialEPGs });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removeEPGs([]);
|
||||
});
|
||||
|
||||
expect(result.current.epgs).toEqual(initialEPGs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEPGProgress', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
useEPGsStore.setState({
|
||||
epgs: {
|
||||
source1: { id: 'source1', status: 'idle', last_message: '' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should update progress for downloading action', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPGProgress({
|
||||
source: 'source1',
|
||||
action: 'downloading',
|
||||
progress: 50,
|
||||
speed: '1.5 MB/s',
|
||||
elapsed_time: '00:00:30',
|
||||
time_remaining: '00:00:30',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.refreshProgress.source1).toEqual({
|
||||
action: 'downloading',
|
||||
progress: 50,
|
||||
speed: '1.5 MB/s',
|
||||
elapsed_time: '00:00:30',
|
||||
time_remaining: '00:00:30',
|
||||
status: 'in_progress',
|
||||
});
|
||||
expect(result.current.epgs.source1.status).toBe('fetching');
|
||||
});
|
||||
|
||||
it('should update progress for parsing_channels action', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPGProgress({
|
||||
source: 'source1',
|
||||
action: 'parsing_channels',
|
||||
progress: 75,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.epgs.source1.status).toBe('parsing');
|
||||
});
|
||||
|
||||
it('should update progress for parsing_programs action', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPGProgress({
|
||||
source: 'source1',
|
||||
action: 'parsing_programs',
|
||||
progress: 90,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.epgs.source1.status).toBe('parsing');
|
||||
});
|
||||
|
||||
it('should set status to success when progress is 100', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPGProgress({
|
||||
source: 'source1',
|
||||
action: 'success',
|
||||
progress: 100,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.epgs.source1.status).toBe('success');
|
||||
});
|
||||
|
||||
it('should use explicit status from data', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPGProgress({
|
||||
source: 'source1',
|
||||
status: 'error',
|
||||
progress: 50,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.epgs.source1.status).toBe('error');
|
||||
expect(result.current.refreshProgress.source1.status).toBe('error');
|
||||
});
|
||||
|
||||
it('should set last_message on error status', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPGProgress({
|
||||
source: 'source1',
|
||||
status: 'error',
|
||||
error: 'Connection failed',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.epgs.source1.last_message).toBe('Connection failed');
|
||||
});
|
||||
|
||||
it('should use default error message if error is not provided', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPGProgress({
|
||||
source: 'source1',
|
||||
status: 'error',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.epgs.source1.last_message).toBe('Unknown error');
|
||||
});
|
||||
|
||||
it('should not update state when called with invalid data (null)', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
const initialEPGs = { ...result.current.epgs };
|
||||
const initialProgress = { ...result.current.refreshProgress };
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPGProgress(null);
|
||||
});
|
||||
|
||||
expect(result.current.epgs).toEqual(initialEPGs);
|
||||
expect(result.current.refreshProgress).toEqual(initialProgress);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', null);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not update state when called with invalid data (missing source)', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
const initialEPGs = { ...result.current.epgs };
|
||||
const initialProgress = { ...result.current.refreshProgress };
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPGProgress({ progress: 50 });
|
||||
});
|
||||
|
||||
expect(result.current.epgs).toEqual(initialEPGs);
|
||||
expect(result.current.refreshProgress).toEqual(initialProgress);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', { progress: 50 });
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not update state when source does not exist and no status', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
const initialEPGs = { ...result.current.epgs };
|
||||
const initialProgress = { ...result.current.refreshProgress };
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPGProgress({
|
||||
source: 'nonexistent',
|
||||
progress: 50,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.epgs).toEqual(initialEPGs);
|
||||
expect(result.current.refreshProgress).toEqual(initialProgress);
|
||||
});
|
||||
|
||||
it('should update refreshProgress even when source does not exist but status is provided', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPGProgress({
|
||||
source: 'newSource',
|
||||
status: 'success',
|
||||
progress: 100,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.refreshProgress.newSource).toEqual({
|
||||
action: undefined,
|
||||
progress: 100,
|
||||
speed: undefined,
|
||||
elapsed_time: undefined,
|
||||
time_remaining: undefined,
|
||||
status: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not update EPG if status and last_message have not changed', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
act(() => {
|
||||
useEPGsStore.setState({
|
||||
epgs: {
|
||||
source1: { id: 'source1', status: 'fetching', last_message: '' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const epgsBeforeUpdate = result.current.epgs;
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPGProgress({
|
||||
source: 'source1',
|
||||
action: 'downloading',
|
||||
progress: 25,
|
||||
});
|
||||
});
|
||||
|
||||
// EPGs object should be the same reference (not updated)
|
||||
expect(result.current.epgs).toBe(epgsBeforeUpdate);
|
||||
// But refreshProgress should be updated
|
||||
expect(result.current.refreshProgress.source1.progress).toBe(25);
|
||||
});
|
||||
|
||||
it('should update EPG if status changed', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
act(() => {
|
||||
useEPGsStore.setState({
|
||||
epgs: {
|
||||
source1: { id: 'source1', status: 'idle', last_message: '' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const epgsBeforeUpdate = result.current.epgs;
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPGProgress({
|
||||
source: 'source1',
|
||||
action: 'downloading',
|
||||
progress: 25,
|
||||
});
|
||||
});
|
||||
|
||||
// EPGs object should be different (updated) because status changed from 'idle' to 'fetching'
|
||||
expect(result.current.epgs).not.toBe(epgsBeforeUpdate);
|
||||
expect(result.current.epgs.source1.status).toBe('fetching');
|
||||
});
|
||||
|
||||
it('should preserve current EPG status when no status change is detected', () => {
|
||||
const { result } = renderHook(() => useEPGsStore());
|
||||
|
||||
act(() => {
|
||||
useEPGsStore.setState({
|
||||
epgs: {
|
||||
source1: { id: 'source1', status: 'parsing', last_message: 'Processing' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateEPGProgress({
|
||||
source: 'source1',
|
||||
action: 'parsing_programs',
|
||||
progress: 85,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.epgs.source1.status).toBe('parsing');
|
||||
expect(result.current.epgs.source1.last_message).toBe('Processing');
|
||||
});
|
||||
});
|
||||
});
|
||||
916
frontend/src/store/__tests__/logos.test.jsx
Normal file
916
frontend/src/store/__tests__/logos.test.jsx
Normal file
|
|
@ -0,0 +1,916 @@
|
|||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import useLogosStore from '../logos';
|
||||
import api from '../../api';
|
||||
|
||||
// Mock the api module
|
||||
vi.mock('../../api');
|
||||
|
||||
describe('useLogosStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset store state between tests
|
||||
useLogosStore.setState({
|
||||
logos: {},
|
||||
channelLogos: {},
|
||||
isLoading: false,
|
||||
backgroundLoading: false,
|
||||
hasLoadedAll: false,
|
||||
hasLoadedChannelLogos: false,
|
||||
error: null,
|
||||
allowLogoRendering: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
expect(result.current.logos).toEqual({});
|
||||
expect(result.current.channelLogos).toEqual({});
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.backgroundLoading).toBe(false);
|
||||
expect(result.current.hasLoadedAll).toBe(false);
|
||||
expect(result.current.hasLoadedChannelLogos).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.allowLogoRendering).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableLogoRendering', () => {
|
||||
it('should enable logo rendering', () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
result.current.enableLogoRendering();
|
||||
});
|
||||
|
||||
expect(result.current.allowLogoRendering).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addLogo', () => {
|
||||
it('should add logo to main logos store', () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
|
||||
|
||||
act(() => {
|
||||
result.current.addLogo(newLogo);
|
||||
});
|
||||
|
||||
expect(result.current.logos).toEqual({
|
||||
logo1: newLogo,
|
||||
});
|
||||
});
|
||||
|
||||
it('should add logo to channelLogos if hasLoadedChannelLogos is true', () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({ hasLoadedChannelLogos: true });
|
||||
});
|
||||
|
||||
const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
|
||||
|
||||
act(() => {
|
||||
result.current.addLogo(newLogo);
|
||||
});
|
||||
|
||||
expect(result.current.logos).toEqual({ logo1: newLogo });
|
||||
expect(result.current.channelLogos).toEqual({ logo1: newLogo });
|
||||
});
|
||||
|
||||
it('should not add logo to channelLogos if hasLoadedChannelLogos is false', () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' };
|
||||
|
||||
act(() => {
|
||||
result.current.addLogo(newLogo);
|
||||
});
|
||||
|
||||
expect(result.current.logos).toEqual({ logo1: newLogo });
|
||||
expect(result.current.channelLogos).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateLogo', () => {
|
||||
it('should update logo in main logos store', () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
|
||||
const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({ logos: { logo1: originalLogo } });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateLogo(updatedLogo);
|
||||
});
|
||||
|
||||
expect(result.current.logos.logo1).toEqual(updatedLogo);
|
||||
});
|
||||
|
||||
it('should update logo in channelLogos if it exists there', () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
|
||||
const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({
|
||||
logos: { logo1: originalLogo },
|
||||
channelLogos: { logo1: originalLogo },
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateLogo(updatedLogo);
|
||||
});
|
||||
|
||||
expect(result.current.logos.logo1).toEqual(updatedLogo);
|
||||
expect(result.current.channelLogos.logo1).toEqual(updatedLogo);
|
||||
});
|
||||
|
||||
it('should not update channelLogos if logo does not exist there', () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' };
|
||||
const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' };
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({ logos: { logo1: originalLogo } });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateLogo(updatedLogo);
|
||||
});
|
||||
|
||||
expect(result.current.channelLogos).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeLogo', () => {
|
||||
it('should remove logo from both stores', () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
const logo1 = { id: 'logo1', name: 'Logo 1' };
|
||||
const logo2 = { id: 'logo2', name: 'Logo 2' };
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({
|
||||
logos: { logo1, logo2 },
|
||||
channelLogos: { logo1, logo2 },
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removeLogo('logo1');
|
||||
});
|
||||
|
||||
expect(result.current.logos).toEqual({ logo2 });
|
||||
expect(result.current.channelLogos).toEqual({ logo2 });
|
||||
});
|
||||
|
||||
it('should handle removing non-existent logo', () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
const logo1 = { id: 'logo1', name: 'Logo 1' };
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({ logos: { logo1 } });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removeLogo('nonexistent');
|
||||
});
|
||||
|
||||
expect(result.current.logos).toEqual({ logo1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchLogos', () => {
|
||||
it('should fetch logos successfully with array response', async () => {
|
||||
const mockLogos = [
|
||||
{ id: 'logo1', name: 'Logo 1' },
|
||||
{ id: 'logo2', name: 'Logo 2' },
|
||||
];
|
||||
|
||||
api.getLogos.mockResolvedValue(mockLogos);
|
||||
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
let response;
|
||||
await act(async () => {
|
||||
response = await result.current.fetchLogos(100);
|
||||
});
|
||||
|
||||
expect(result.current.logos).toEqual({
|
||||
logo1: { id: 'logo1', name: 'Logo 1' },
|
||||
logo2: { id: 'logo2', name: 'Logo 2' },
|
||||
});
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(api.getLogos).toHaveBeenCalledWith({ page_size: 100 });
|
||||
expect(response).toEqual(mockLogos);
|
||||
});
|
||||
|
||||
it('should fetch logos successfully with paginated response', async () => {
|
||||
const mockResponse = {
|
||||
results: [
|
||||
{ id: 'logo1', name: 'Logo 1' },
|
||||
{ id: 'logo2', name: 'Logo 2' },
|
||||
],
|
||||
count: 2,
|
||||
};
|
||||
|
||||
api.getLogos.mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchLogos(50);
|
||||
});
|
||||
|
||||
expect(result.current.logos).toEqual({
|
||||
logo1: { id: 'logo1', name: 'Logo 1' },
|
||||
logo2: { id: 'logo2', name: 'Logo 2' },
|
||||
});
|
||||
expect(api.getLogos).toHaveBeenCalledWith({ page_size: 50 });
|
||||
});
|
||||
|
||||
it('should handle fetch error', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
api.getLogos.mockRejectedValue(mockError);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.fetchLogos();
|
||||
})
|
||||
).rejects.toThrow('Network error');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Failed to load logos.');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos:', mockError);
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAllLogos', () => {
|
||||
it('should fetch all logos successfully', async () => {
|
||||
const mockLogos = [
|
||||
{ id: 'logo1', name: 'Logo 1' },
|
||||
{ id: 'logo2', name: 'Logo 2' },
|
||||
];
|
||||
|
||||
api.getLogos.mockResolvedValue(mockLogos);
|
||||
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
let response;
|
||||
await act(async () => {
|
||||
response = await result.current.fetchAllLogos();
|
||||
});
|
||||
|
||||
expect(result.current.logos).toEqual({
|
||||
logo1: { id: 'logo1', name: 'Logo 1' },
|
||||
logo2: { id: 'logo2', name: 'Logo 2' },
|
||||
});
|
||||
expect(result.current.hasLoadedAll).toBe(true);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(api.getLogos).toHaveBeenCalledWith({ no_pagination: 'true' });
|
||||
expect(response).toEqual(mockLogos);
|
||||
});
|
||||
|
||||
it('should not refetch if already loaded and not forced', async () => {
|
||||
const mockLogos = [{ id: 'logo1', name: 'Logo 1' }];
|
||||
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({
|
||||
logos: { logo1: mockLogos[0] },
|
||||
hasLoadedAll: true,
|
||||
});
|
||||
});
|
||||
|
||||
const response = await act(async () => {
|
||||
return await result.current.fetchAllLogos();
|
||||
});
|
||||
|
||||
expect(api.getLogos).not.toHaveBeenCalled();
|
||||
expect(response).toEqual([mockLogos[0]]);
|
||||
});
|
||||
|
||||
it('should refetch if forced', async () => {
|
||||
const mockLogos = [{ id: 'logo1', name: 'Logo 1' }];
|
||||
|
||||
api.getLogos.mockResolvedValue(mockLogos);
|
||||
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({
|
||||
logos: { logo1: mockLogos[0] },
|
||||
hasLoadedAll: true,
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchAllLogos(true);
|
||||
});
|
||||
|
||||
expect(api.getLogos).toHaveBeenCalledWith({ no_pagination: 'true' });
|
||||
});
|
||||
|
||||
it('should not refetch if already loading', async () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({ isLoading: true });
|
||||
});
|
||||
|
||||
const response = await act(async () => {
|
||||
return await result.current.fetchAllLogos();
|
||||
});
|
||||
|
||||
expect(api.getLogos).not.toHaveBeenCalled();
|
||||
expect(response).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle fetch error', async () => {
|
||||
const mockError = new Error('API error');
|
||||
api.getLogos.mockRejectedValue(mockError);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.fetchAllLogos();
|
||||
})
|
||||
).rejects.toThrow('API error');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Failed to load all logos.');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch all logos:', mockError);
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchUsedLogos', () => {
|
||||
it('should fetch used logos successfully', async () => {
|
||||
const mockResponse = {
|
||||
results: [
|
||||
{ id: 'logo1', name: 'Used Logo 1' },
|
||||
{ id: 'logo2', name: 'Used Logo 2' },
|
||||
],
|
||||
};
|
||||
|
||||
api.getLogos.mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
let response;
|
||||
await act(async () => {
|
||||
response = await result.current.fetchUsedLogos(100);
|
||||
});
|
||||
|
||||
expect(result.current.logos).toEqual({
|
||||
logo1: { id: 'logo1', name: 'Used Logo 1' },
|
||||
logo2: { id: 'logo2', name: 'Used Logo 2' },
|
||||
});
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(api.getLogos).toHaveBeenCalledWith({ used: 'true', page_size: 100 });
|
||||
expect(response).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should merge with existing logos', async () => {
|
||||
const existingLogo = { id: 'logo1', name: 'Existing Logo' };
|
||||
const newLogo = { id: 'logo2', name: 'New Logo' };
|
||||
|
||||
api.getLogos.mockResolvedValue({ results: [newLogo] });
|
||||
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({ logos: { logo1: existingLogo } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchUsedLogos();
|
||||
});
|
||||
|
||||
expect(result.current.logos).toEqual({
|
||||
logo1: existingLogo,
|
||||
logo2: newLogo,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle fetch error', async () => {
|
||||
const mockError = new Error('Fetch error');
|
||||
api.getLogos.mockRejectedValue(mockError);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.fetchUsedLogos();
|
||||
})
|
||||
).rejects.toThrow('Fetch error');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Failed to load used logos.');
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch used logos:', mockError);
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchChannelAssignableLogos', () => {
|
||||
it('should return cached logos if already loaded', async () => {
|
||||
const cachedLogos = {
|
||||
logo1: { id: 'logo1', name: 'Cached Logo' },
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({
|
||||
channelLogos: cachedLogos,
|
||||
hasLoadedChannelLogos: true,
|
||||
});
|
||||
});
|
||||
|
||||
const response = await act(async () => {
|
||||
return await result.current.fetchChannelAssignableLogos();
|
||||
});
|
||||
|
||||
expect(api.getLogos).not.toHaveBeenCalled();
|
||||
expect(response).toEqual([cachedLogos.logo1]);
|
||||
});
|
||||
|
||||
it('should fetch and cache logos if not loaded', async () => {
|
||||
const mockLogos = [
|
||||
{ id: 'logo1', name: 'Logo 1' },
|
||||
{ id: 'logo2', name: 'Logo 2' },
|
||||
];
|
||||
|
||||
api.getLogos.mockResolvedValue(mockLogos);
|
||||
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchChannelAssignableLogos();
|
||||
});
|
||||
|
||||
expect(result.current.channelLogos).toEqual({
|
||||
logo1: { id: 'logo1', name: 'Logo 1' },
|
||||
logo2: { id: 'logo2', name: 'Logo 2' },
|
||||
});
|
||||
expect(result.current.hasLoadedChannelLogos).toBe(true);
|
||||
expect(api.getLogos).toHaveBeenCalledWith({ no_pagination: 'true' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchLogosByIds', () => {
|
||||
it('should fetch missing logos by IDs', async () => {
|
||||
const existingLogo = { id: 'logo1', name: 'Existing' };
|
||||
const newLogo = { id: 'logo2', name: 'New' };
|
||||
|
||||
api.getLogosByIds.mockResolvedValue([newLogo]);
|
||||
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({ logos: { logo1: existingLogo } });
|
||||
});
|
||||
|
||||
let response;
|
||||
await act(async () => {
|
||||
response = await result.current.fetchLogosByIds(['logo1', 'logo2']);
|
||||
});
|
||||
|
||||
expect(api.getLogosByIds).toHaveBeenCalledWith(['logo2']);
|
||||
expect(result.current.logos).toEqual({
|
||||
logo1: existingLogo,
|
||||
logo2: newLogo,
|
||||
});
|
||||
expect(response).toEqual([newLogo]);
|
||||
});
|
||||
|
||||
it('should return empty array if all logos exist', async () => {
|
||||
const logo1 = { id: 'logo1', name: 'Logo 1' };
|
||||
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({ logos: { logo1 } });
|
||||
});
|
||||
|
||||
const response = await act(async () => {
|
||||
return await result.current.fetchLogosByIds(['logo1']);
|
||||
});
|
||||
|
||||
expect(api.getLogosByIds).not.toHaveBeenCalled();
|
||||
expect(response).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle fetch error', async () => {
|
||||
const mockError = new Error('Fetch error');
|
||||
api.getLogosByIds.mockRejectedValue(mockError);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.fetchLogosByIds(['logo1']);
|
||||
})
|
||||
).rejects.toThrow('Fetch error');
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos by IDs:', mockError);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchLogosInBackground', () => {
|
||||
it('should fetch logos in background with pagination', async () => {
|
||||
// vi.useRealTimers();
|
||||
|
||||
const page1 = {
|
||||
results: [{ id: 'logo1', name: 'Logo 1' }],
|
||||
next: 'http://example.com/page2',
|
||||
};
|
||||
const page2 = {
|
||||
results: [{ id: 'logo2', name: 'Logo 2' }],
|
||||
next: null,
|
||||
};
|
||||
|
||||
api.getLogos
|
||||
.mockResolvedValueOnce(page1)
|
||||
.mockResolvedValueOnce(page2);
|
||||
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchLogosInBackground();
|
||||
});
|
||||
|
||||
expect(result.current.logos).toEqual({
|
||||
logo1: { id: 'logo1', name: 'Logo 1' },
|
||||
logo2: { id: 'logo2', name: 'Logo 2' },
|
||||
});
|
||||
expect(result.current.backgroundLoading).toBe(false);
|
||||
expect(api.getLogos).toHaveBeenCalledTimes(2);
|
||||
expect(api.getLogos).toHaveBeenCalledWith({ page: 1, page_size: 200 });
|
||||
expect(api.getLogos).toHaveBeenCalledWith({ page: 2, page_size: 200 });
|
||||
});
|
||||
|
||||
it('should handle errors gracefully without throwing', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
api.getLogos.mockRejectedValue(mockError);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchLogosInBackground();
|
||||
});
|
||||
|
||||
expect(result.current.backgroundLoading).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Background logo loading failed:', mockError);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backgroundLoadAllLogos', () => {
|
||||
it('should not start if already loading', async () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({ backgroundLoading: true });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.backgroundLoadAllLogos();
|
||||
});
|
||||
|
||||
expect(api.getLogos).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not start if already loaded', async () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({ hasLoadedAll: true });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.backgroundLoadAllLogos();
|
||||
});
|
||||
|
||||
expect(api.getLogos).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should load logos in background asynchronously', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockLogos = Array.from({ length: 2500 }, (_, i) => ({
|
||||
id: `logo${i}`,
|
||||
name: `Logo ${i}`,
|
||||
}));
|
||||
|
||||
api.getLogos.mockResolvedValue(mockLogos);
|
||||
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
// Start background loading
|
||||
result.current.backgroundLoadAllLogos();
|
||||
|
||||
// Advance timers to execute setTimeout
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.hasLoadedAll).toBe(true);
|
||||
expect(result.current.backgroundLoading).toBe(false);
|
||||
expect(Object.keys(result.current.logos).length).toBe(2500);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockError = new Error('Fetch error');
|
||||
api.getLogos.mockRejectedValue(mockError);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
result.current.backgroundLoadAllLogos();
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.backgroundLoading).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Background all logos loading failed:', mockError);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backgroundLoadChannelLogos', () => {
|
||||
it('should not start if already loading', async () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({ backgroundLoading: true });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.backgroundLoadChannelLogos();
|
||||
});
|
||||
|
||||
expect(api.getLogos).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not start if already loaded', async () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({ hasLoadedChannelLogos: true });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.backgroundLoadChannelLogos();
|
||||
});
|
||||
|
||||
expect(api.getLogos).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not start if channelLogos already has many items', async () => {
|
||||
const channelLogos = Array.from({ length: 150 }, (_, i) => [`logo${i}`, { id: `logo${i}` }]);
|
||||
const channelLogosObj = Object.fromEntries(channelLogos);
|
||||
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({ channelLogos: channelLogosObj });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.backgroundLoadChannelLogos();
|
||||
});
|
||||
|
||||
expect(api.getLogos).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should load channel logos in background', async () => {
|
||||
const mockLogos = [
|
||||
{ id: 'logo1', name: 'Logo 1' },
|
||||
{ id: 'logo2', name: 'Logo 2' },
|
||||
];
|
||||
|
||||
api.getLogos.mockResolvedValue(mockLogos);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.backgroundLoadChannelLogos();
|
||||
});
|
||||
|
||||
expect(result.current.hasLoadedChannelLogos).toBe(true);
|
||||
expect(result.current.backgroundLoading).toBe(false);
|
||||
expect(Object.keys(result.current.channelLogos).length).toBe(2);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Background loading channel logos...');
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Background loaded 2 channel logos');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const mockError = new Error('Fetch error');
|
||||
api.getLogos.mockRejectedValue(mockError);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.backgroundLoadChannelLogos();
|
||||
});
|
||||
|
||||
expect(result.current.backgroundLoading).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Background channel logo loading failed:', mockError);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startBackgroundLoading', () => {
|
||||
it('should start background loading after delay', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockLogos = [{ id: 'logo1', name: 'Logo 1' }];
|
||||
api.getLogos.mockResolvedValue(mockLogos);
|
||||
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
result.current.startBackgroundLoading();
|
||||
|
||||
// Advance timer by 3 seconds
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.hasLoadedAll).toBe(true);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should handle errors in background loading', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockError = new Error('Background error');
|
||||
api.getLogos.mockRejectedValue(mockError);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
result.current.startBackgroundLoading();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('helper methods', () => {
|
||||
describe('getLogoById', () => {
|
||||
it('should return logo if it exists', () => {
|
||||
const logo = { id: 'logo1', name: 'Logo 1' };
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({ logos: { logo1: logo } });
|
||||
});
|
||||
|
||||
expect(result.current.getLogoById('logo1')).toEqual(logo);
|
||||
});
|
||||
|
||||
it('should return null if logo does not exist', () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
expect(result.current.getLogoById('nonexistent')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasLogo', () => {
|
||||
it('should return true if logo exists', () => {
|
||||
const logo = { id: 'logo1', name: 'Logo 1' };
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({ logos: { logo1: logo } });
|
||||
});
|
||||
|
||||
expect(result.current.hasLogo('logo1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if logo does not exist', () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
expect(result.current.hasLogo('nonexistent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLogosCount', () => {
|
||||
it('should return correct count of logos', () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({
|
||||
logos: {
|
||||
logo1: { id: 'logo1' },
|
||||
logo2: { id: 'logo2' },
|
||||
logo3: { id: 'logo3' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.getLogosCount()).toBe(3);
|
||||
});
|
||||
|
||||
it('should return 0 for empty logos', () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
expect(result.current.getLogosCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsAllLogos', () => {
|
||||
it('should return true if hasLoadedAll is false', () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
expect(result.current.needsAllLogos()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if logos is empty', () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({ hasLoadedAll: true, logos: {} });
|
||||
});
|
||||
|
||||
expect(result.current.needsAllLogos()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if hasLoadedAll is true and logos exist', () => {
|
||||
const { result } = renderHook(() => useLogosStore());
|
||||
|
||||
act(() => {
|
||||
useLogosStore.setState({
|
||||
hasLoadedAll: true,
|
||||
logos: { logo1: { id: 'logo1' } },
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.needsAllLogos()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
266
frontend/src/store/__tests__/playlists.test.jsx
Normal file
266
frontend/src/store/__tests__/playlists.test.jsx
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import usePlaylistsStore from '../playlists';
|
||||
import api from '../../api';
|
||||
|
||||
vi.mock('../../api');
|
||||
|
||||
describe('usePlaylistsStore', () => {
|
||||
beforeEach(() => {
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
act(() => {
|
||||
result.current.playlists = [];
|
||||
result.current.profiles = {};
|
||||
result.current.refreshProgress = {};
|
||||
result.current.isLoading = false;
|
||||
result.current.error = null;
|
||||
result.current.profileSearchPreview = '';
|
||||
result.current.profileResult = '';
|
||||
result.current.editPlaylistId = null;
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
|
||||
expect(result.current.playlists).toEqual([]);
|
||||
expect(result.current.profiles).toEqual({});
|
||||
expect(result.current.refreshProgress).toEqual({});
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
expect(result.current.profileSearchPreview).toBe('');
|
||||
expect(result.current.profileResult).toBe('');
|
||||
expect(result.current.editPlaylistId).toBe(null);
|
||||
});
|
||||
|
||||
it('should set edit playlist id', () => {
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setEditPlaylistId('playlist1');
|
||||
});
|
||||
|
||||
expect(result.current.editPlaylistId).toBe('playlist1');
|
||||
});
|
||||
|
||||
it('should fetch playlist successfully', async () => {
|
||||
const mockPlaylist = {
|
||||
id: 'playlist1',
|
||||
name: 'Test Playlist',
|
||||
profiles: ['profile1', 'profile2'],
|
||||
};
|
||||
|
||||
api.getPlaylist.mockResolvedValue(mockPlaylist);
|
||||
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.playlists = [{ id: 'playlist1', name: 'Old Name' }];
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchPlaylist('playlist1');
|
||||
});
|
||||
|
||||
expect(api.getPlaylist).toHaveBeenCalledWith('playlist1');
|
||||
expect(result.current.playlists).toEqual([mockPlaylist]);
|
||||
expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] });
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle fetch playlist error', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
api.getPlaylist.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchPlaylist('playlist1');
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Failed to load playlists.');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should fetch playlists successfully', async () => {
|
||||
const mockPlaylists = [
|
||||
{ id: 'playlist1', name: 'Playlist 1', profiles: ['profile1'] },
|
||||
{ id: 'playlist2', name: 'Playlist 2', profiles: ['profile2'] },
|
||||
];
|
||||
|
||||
api.getPlaylists.mockResolvedValue(mockPlaylists);
|
||||
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchPlaylists();
|
||||
});
|
||||
|
||||
expect(api.getPlaylists).toHaveBeenCalled();
|
||||
expect(result.current.playlists).toEqual(mockPlaylists);
|
||||
expect(result.current.profiles).toEqual({
|
||||
playlist1: ['profile1'],
|
||||
playlist2: ['profile2'],
|
||||
});
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle fetch playlists error', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
api.getPlaylists.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchPlaylists();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Failed to load playlists.');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should add playlist', () => {
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
const newPlaylist = {
|
||||
id: 'playlist1',
|
||||
name: 'New Playlist',
|
||||
profiles: ['profile1'],
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.addPlaylist(newPlaylist);
|
||||
});
|
||||
|
||||
expect(result.current.playlists).toEqual([newPlaylist]);
|
||||
expect(result.current.profiles).toEqual({ playlist1: ['profile1'] });
|
||||
});
|
||||
|
||||
it('should update playlist', () => {
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
const existingPlaylist = { id: 'playlist1', name: 'Old Name', profiles: ['profile1'] };
|
||||
const updatedPlaylist = { id: 'playlist1', name: 'New Name', profiles: ['profile1', 'profile2'] };
|
||||
|
||||
act(() => {
|
||||
result.current.playlists = [existingPlaylist];
|
||||
result.current.profiles = { playlist1: ['profile1'] };
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updatePlaylist(updatedPlaylist);
|
||||
});
|
||||
|
||||
expect(result.current.playlists).toEqual([updatedPlaylist]);
|
||||
expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] });
|
||||
});
|
||||
|
||||
it('should update profiles', () => {
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.profiles = { playlist1: ['profile1'] };
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateProfiles('playlist1', ['profile1', 'profile2', 'profile3']);
|
||||
});
|
||||
|
||||
expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2', 'profile3'] });
|
||||
});
|
||||
|
||||
it('should remove playlists', () => {
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.playlists = [
|
||||
{ id: 'playlist1', name: 'Playlist 1' },
|
||||
{ id: 'playlist2', name: 'Playlist 2' },
|
||||
{ id: 'playlist3', name: 'Playlist 3' },
|
||||
];
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removePlaylists(['playlist1', 'playlist3']);
|
||||
});
|
||||
|
||||
expect(result.current.playlists).toEqual([{ id: 'playlist2', name: 'Playlist 2' }]);
|
||||
});
|
||||
|
||||
it('should set refresh progress with two parameters', () => {
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
const progressData = { action: 'refreshing', progress: 50 };
|
||||
|
||||
act(() => {
|
||||
result.current.setRefreshProgress('account1', progressData);
|
||||
});
|
||||
|
||||
expect(result.current.refreshProgress).toEqual({ account1: progressData });
|
||||
});
|
||||
|
||||
it('should set refresh progress with WebSocket data', () => {
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
const wsData = { account: 'account1', action: 'refreshing', progress: 50 };
|
||||
|
||||
act(() => {
|
||||
result.current.setRefreshProgress(wsData);
|
||||
});
|
||||
|
||||
expect(result.current.refreshProgress).toEqual({ account1: wsData });
|
||||
});
|
||||
|
||||
it('should preserve initializing status until real progress', () => {
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.refreshProgress = {
|
||||
account1: { action: 'initializing', progress: 0 },
|
||||
};
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setRefreshProgress({ account: 'account1', progress: 0 });
|
||||
});
|
||||
|
||||
expect(result.current.refreshProgress.account1.action).toBe('initializing');
|
||||
|
||||
act(() => {
|
||||
result.current.setRefreshProgress({ account: 'account1', action: 'refreshing', progress: 25 });
|
||||
});
|
||||
|
||||
expect(result.current.refreshProgress.account1.action).toBe('refreshing');
|
||||
});
|
||||
|
||||
it('should remove refresh progress', () => {
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.refreshProgress = {
|
||||
account1: { action: 'refreshing', progress: 50 },
|
||||
account2: { action: 'refreshing', progress: 75 },
|
||||
};
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removeRefreshProgress('account1');
|
||||
});
|
||||
|
||||
expect(result.current.refreshProgress).toEqual({
|
||||
account2: { action: 'refreshing', progress: 75 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should set profile preview', () => {
|
||||
const { result } = renderHook(() => usePlaylistsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setProfilePreview('search text', 'result data');
|
||||
});
|
||||
|
||||
expect(result.current.profileSearchPreview).toBe('search text');
|
||||
expect(result.current.profileResult).toBe('result data');
|
||||
});
|
||||
});
|
||||
234
frontend/src/store/__tests__/plugins.test.jsx
Normal file
234
frontend/src/store/__tests__/plugins.test.jsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { usePluginStore } from '../plugins';
|
||||
import API from '../../api';
|
||||
|
||||
vi.mock('../../api');
|
||||
|
||||
describe('usePluginStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
usePluginStore.setState({
|
||||
plugins: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => usePluginStore());
|
||||
|
||||
expect(result.current.plugins).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should fetch plugins successfully', async () => {
|
||||
const mockPlugins = [
|
||||
{ key: 'plugin1', name: 'Plugin 1', enabled: true },
|
||||
{ key: 'plugin2', name: 'Plugin 2', enabled: false },
|
||||
];
|
||||
|
||||
API.getPlugins.mockResolvedValue(mockPlugins);
|
||||
|
||||
const { result } = renderHook(() => usePluginStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchPlugins();
|
||||
});
|
||||
|
||||
expect(API.getPlugins).toHaveBeenCalled();
|
||||
expect(result.current.plugins).toEqual(mockPlugins);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle fetch plugins with empty response', async () => {
|
||||
API.getPlugins.mockResolvedValue(null);
|
||||
|
||||
const { result } = renderHook(() => usePluginStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchPlugins();
|
||||
});
|
||||
|
||||
expect(result.current.plugins).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle fetch plugins error', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
API.getPlugins.mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => usePluginStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchPlugins();
|
||||
});
|
||||
|
||||
expect(result.current.error).toEqual(mockError);
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should set loading state during fetch', async () => {
|
||||
let resolvePromise;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
API.getPlugins.mockReturnValue(promise);
|
||||
|
||||
const { result } = renderHook(() => usePluginStore());
|
||||
|
||||
// Start the fetch without awaiting
|
||||
act(() => {
|
||||
result.current.fetchPlugins();
|
||||
});
|
||||
|
||||
// Check loading is true synchronously
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
// Resolve the promise and wait for state update
|
||||
await act(async () => {
|
||||
resolvePromise([]);
|
||||
await promise;
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should update plugin', () => {
|
||||
const { result } = renderHook(() => usePluginStore());
|
||||
|
||||
act(() => {
|
||||
usePluginStore.setState({
|
||||
plugins: [
|
||||
{ key: 'plugin1', name: 'Plugin 1', enabled: false },
|
||||
{ key: 'plugin2', name: 'Plugin 2', enabled: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updatePlugin('plugin1', { enabled: true });
|
||||
});
|
||||
|
||||
expect(result.current.plugins).toEqual([
|
||||
{ key: 'plugin1', name: 'Plugin 1', enabled: true },
|
||||
{ key: 'plugin2', name: 'Plugin 2', enabled: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not modify other plugins when updating', () => {
|
||||
const { result } = renderHook(() => usePluginStore());
|
||||
|
||||
act(() => {
|
||||
usePluginStore.setState({
|
||||
plugins: [
|
||||
{ key: 'plugin1', name: 'Plugin 1', enabled: false },
|
||||
{ key: 'plugin2', name: 'Plugin 2', enabled: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updatePlugin('plugin1', { name: 'Updated Plugin' });
|
||||
});
|
||||
|
||||
expect(result.current.plugins[1]).toEqual({ key: 'plugin2', name: 'Plugin 2', enabled: false });
|
||||
});
|
||||
|
||||
it('should add plugin', () => {
|
||||
const { result } = renderHook(() => usePluginStore());
|
||||
const newPlugin = { key: 'plugin1', name: 'New Plugin', enabled: true };
|
||||
|
||||
act(() => {
|
||||
result.current.addPlugin(newPlugin);
|
||||
});
|
||||
|
||||
expect(result.current.plugins).toEqual([newPlugin]);
|
||||
});
|
||||
|
||||
it('should add plugin to existing plugins', () => {
|
||||
const { result } = renderHook(() => usePluginStore());
|
||||
const existingPlugin = { key: 'plugin1', name: 'Existing Plugin', enabled: true };
|
||||
const newPlugin = { key: 'plugin2', name: 'New Plugin', enabled: false };
|
||||
|
||||
act(() => {
|
||||
usePluginStore.setState({ plugins: [existingPlugin] });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addPlugin(newPlugin);
|
||||
});
|
||||
|
||||
expect(result.current.plugins).toEqual([existingPlugin, newPlugin]);
|
||||
});
|
||||
|
||||
it('should remove plugin', () => {
|
||||
const { result } = renderHook(() => usePluginStore());
|
||||
|
||||
act(() => {
|
||||
usePluginStore.setState({
|
||||
plugins: [
|
||||
{ key: 'plugin1', name: 'Plugin 1', enabled: true },
|
||||
{ key: 'plugin2', name: 'Plugin 2', enabled: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removePlugin('plugin1');
|
||||
});
|
||||
|
||||
expect(result.current.plugins).toEqual([
|
||||
{ key: 'plugin2', name: 'Plugin 2', enabled: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle removing non-existent plugin', () => {
|
||||
const { result } = renderHook(() => usePluginStore());
|
||||
|
||||
act(() => {
|
||||
usePluginStore.setState({
|
||||
plugins: [
|
||||
{ key: 'plugin1', name: 'Plugin 1', enabled: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removePlugin('nonexistent');
|
||||
});
|
||||
|
||||
expect(result.current.plugins).toEqual([
|
||||
{ key: 'plugin1', name: 'Plugin 1', enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should invalidate plugins and refetch', async () => {
|
||||
const mockPlugins = [
|
||||
{ key: 'plugin1', name: 'Plugin 1', enabled: true },
|
||||
];
|
||||
|
||||
API.getPlugins.mockResolvedValue(mockPlugins);
|
||||
|
||||
const { result } = renderHook(() => usePluginStore());
|
||||
|
||||
act(() => {
|
||||
usePluginStore.setState({
|
||||
plugins: [
|
||||
{ key: 'old-plugin', name: 'Old Plugin', enabled: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.invalidatePlugins();
|
||||
});
|
||||
|
||||
expect(result.current.plugins).toEqual(mockPlugins);
|
||||
expect(API.getPlugins).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
353
frontend/src/store/__tests__/settings.test.jsx
Normal file
353
frontend/src/store/__tests__/settings.test.jsx
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import useSettingsStore from '../settings';
|
||||
import api from '../../api';
|
||||
|
||||
vi.mock('../../api');
|
||||
|
||||
describe('useSettingsStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({
|
||||
settings: {},
|
||||
environment: {
|
||||
public_ip: '',
|
||||
country_code: '',
|
||||
country_name: '',
|
||||
env_mode: 'prod',
|
||||
},
|
||||
version: {
|
||||
version: '',
|
||||
timestamp: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useSettingsStore());
|
||||
|
||||
expect(result.current.settings).toEqual({});
|
||||
expect(result.current.environment).toEqual({
|
||||
public_ip: '',
|
||||
country_code: '',
|
||||
country_name: '',
|
||||
env_mode: 'prod',
|
||||
});
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should fetch settings successfully', async () => {
|
||||
const mockSettings = [
|
||||
{ key: 'setting1', value: 'value1' },
|
||||
{ key: 'setting2', value: 'value2' },
|
||||
];
|
||||
const mockEnv = {
|
||||
public_ip: '192.168.1.1',
|
||||
country_code: 'US',
|
||||
country_name: 'United States',
|
||||
env_mode: 'dev',
|
||||
};
|
||||
|
||||
api.getSettings.mockResolvedValue(mockSettings);
|
||||
api.getEnvironmentSettings.mockResolvedValue(mockEnv);
|
||||
|
||||
const { result } = renderHook(() => useSettingsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchSettings();
|
||||
});
|
||||
|
||||
expect(api.getSettings).toHaveBeenCalled();
|
||||
expect(api.getEnvironmentSettings).toHaveBeenCalled();
|
||||
expect(result.current.settings).toEqual({
|
||||
setting1: { key: 'setting1', value: 'value1' },
|
||||
setting2: { key: 'setting2', value: 'value2' },
|
||||
});
|
||||
expect(result.current.environment).toEqual(mockEnv);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle null environment response', async () => {
|
||||
const mockSettings = [{ key: 'setting1', value: 'value1' }];
|
||||
|
||||
api.getSettings.mockResolvedValue(mockSettings);
|
||||
api.getEnvironmentSettings.mockResolvedValue(null);
|
||||
|
||||
const { result } = renderHook(() => useSettingsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchSettings();
|
||||
});
|
||||
|
||||
expect(result.current.environment).toEqual({
|
||||
public_ip: '',
|
||||
country_code: '',
|
||||
country_name: '',
|
||||
env_mode: 'prod',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle fetch settings error', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
api.getSettings.mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => useSettingsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchSettings();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Failed to load settings.');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should set loading state during fetch', async () => {
|
||||
let resolveSettingsPromise;
|
||||
let resolveEnvPromise;
|
||||
const settingsPromise = new Promise((resolve) => {
|
||||
resolveSettingsPromise = resolve;
|
||||
});
|
||||
const envPromise = new Promise((resolve) => {
|
||||
resolveEnvPromise = resolve;
|
||||
});
|
||||
|
||||
api.getSettings.mockReturnValue(settingsPromise);
|
||||
api.getEnvironmentSettings.mockReturnValue(envPromise);
|
||||
|
||||
const { result } = renderHook(() => useSettingsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.fetchSettings();
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.error).toBe(null);
|
||||
|
||||
await act(async () => {
|
||||
resolveSettingsPromise([]);
|
||||
resolveEnvPromise({});
|
||||
await settingsPromise;
|
||||
await envPromise;
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should update setting', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
setting1: { key: 'setting1', value: 'old_value' },
|
||||
setting2: { key: 'setting2', value: 'value2' },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettingsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.updateSetting({ key: 'setting1', value: 'new_value' });
|
||||
});
|
||||
|
||||
expect(result.current.settings).toEqual({
|
||||
setting1: { key: 'setting1', value: 'new_value' },
|
||||
setting2: { key: 'setting2', value: 'value2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should add new setting when updating non-existent key', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
setting1: { key: 'setting1', value: 'value1' },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettingsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.updateSetting({ key: 'setting2', value: 'new_value' });
|
||||
});
|
||||
|
||||
expect(result.current.settings).toEqual({
|
||||
setting1: { key: 'setting1', value: 'value1' },
|
||||
setting2: { key: 'setting2', value: 'new_value' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should not modify other settings when updating', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
setting1: { key: 'setting1', value: 'value1' },
|
||||
setting2: { key: 'setting2', value: 'value2' },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettingsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.updateSetting({ key: 'setting1', value: 'updated' });
|
||||
});
|
||||
|
||||
expect(result.current.settings.setting2).toEqual({ key: 'setting2', value: 'value2' });
|
||||
});
|
||||
|
||||
it('should handle empty settings array', async () => {
|
||||
api.getSettings.mockResolvedValue([]);
|
||||
api.getEnvironmentSettings.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useSettingsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchSettings();
|
||||
});
|
||||
|
||||
expect(result.current.settings).toEqual({});
|
||||
});
|
||||
|
||||
it('should initialize version with default state', () => {
|
||||
const { result } = renderHook(() => useSettingsStore());
|
||||
|
||||
expect(result.current.version).toEqual({
|
||||
version: '',
|
||||
timestamp: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch version successfully', async () => {
|
||||
const mockVersion = {
|
||||
version: '1.2.3',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
api.getVersion.mockResolvedValue(mockVersion);
|
||||
|
||||
const { result } = renderHook(() => useSettingsStore());
|
||||
|
||||
let versionResult;
|
||||
await act(async () => {
|
||||
versionResult = await result.current.fetchVersion();
|
||||
});
|
||||
|
||||
expect(api.getVersion).toHaveBeenCalled();
|
||||
expect(result.current.version).toEqual({
|
||||
version: '1.2.3',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
expect(versionResult).toEqual({
|
||||
version: '1.2.3',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip fetching version if already loaded', async () => {
|
||||
useSettingsStore.setState({
|
||||
version: {
|
||||
version: '1.0.0',
|
||||
timestamp: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' });
|
||||
|
||||
const { result } = renderHook(() => useSettingsStore());
|
||||
|
||||
let versionResult;
|
||||
await act(async () => {
|
||||
versionResult = await result.current.fetchVersion();
|
||||
});
|
||||
|
||||
expect(api.getVersion).not.toHaveBeenCalled();
|
||||
expect(result.current.version).toEqual({
|
||||
version: '1.0.0',
|
||||
timestamp: '2023-01-01T00:00:00Z',
|
||||
});
|
||||
expect(versionResult).toEqual({
|
||||
version: '1.0.0',
|
||||
timestamp: '2023-01-01T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null version response', async () => {
|
||||
api.getVersion.mockResolvedValue(null);
|
||||
|
||||
const { result } = renderHook(() => useSettingsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchVersion();
|
||||
});
|
||||
|
||||
expect(result.current.version).toEqual({
|
||||
version: '',
|
||||
timestamp: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle fetch version error', async () => {
|
||||
const mockError = new Error('Version fetch failed');
|
||||
api.getVersion.mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => useSettingsStore());
|
||||
|
||||
let versionResult;
|
||||
await act(async () => {
|
||||
versionResult = await result.current.fetchVersion();
|
||||
});
|
||||
|
||||
expect(versionResult).toEqual({
|
||||
version: '',
|
||||
timestamp: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch version with settings when version not loaded', async () => {
|
||||
const mockSettings = [{ key: 'setting1', value: 'value1' }];
|
||||
const mockEnv = { public_ip: '192.168.1.1' };
|
||||
const mockVersion = { version: '1.0.0', timestamp: '2024-01-01T00:00:00Z' };
|
||||
|
||||
api.getSettings.mockResolvedValue(mockSettings);
|
||||
api.getEnvironmentSettings.mockResolvedValue(mockEnv);
|
||||
api.getVersion.mockResolvedValue(mockVersion);
|
||||
|
||||
const { result } = renderHook(() => useSettingsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchSettings();
|
||||
});
|
||||
|
||||
expect(api.getVersion).toHaveBeenCalled();
|
||||
expect(result.current.version).toEqual({
|
||||
version: '1.0.0',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip fetching version with settings when already loaded', async () => {
|
||||
useSettingsStore.setState({
|
||||
version: {
|
||||
version: '1.0.0',
|
||||
timestamp: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
const mockSettings = [{ key: 'setting1', value: 'value1' }];
|
||||
const mockEnv = { public_ip: '192.168.1.1' };
|
||||
|
||||
api.getSettings.mockResolvedValue(mockSettings);
|
||||
api.getEnvironmentSettings.mockResolvedValue(mockEnv);
|
||||
api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' });
|
||||
|
||||
const { result } = renderHook(() => useSettingsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchSettings();
|
||||
});
|
||||
|
||||
expect(api.getVersion).not.toHaveBeenCalled();
|
||||
expect(result.current.version).toEqual({
|
||||
version: '1.0.0',
|
||||
timestamp: '2023-01-01T00:00:00Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
265
frontend/src/store/__tests__/streamProfiles.test.jsx
Normal file
265
frontend/src/store/__tests__/streamProfiles.test.jsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import useStreamProfilesStore from '../streamProfiles';
|
||||
import api from '../../api';
|
||||
|
||||
vi.mock('../../api');
|
||||
|
||||
describe('useStreamProfilesStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useStreamProfilesStore.setState({
|
||||
profiles: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useStreamProfilesStore());
|
||||
|
||||
expect(result.current.profiles).toEqual([]);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should fetch profiles successfully', async () => {
|
||||
const mockProfiles = [
|
||||
{ id: 1, name: 'Profile 1', bitrate: 5000 },
|
||||
{ id: 2, name: 'Profile 2', bitrate: 8000 },
|
||||
];
|
||||
|
||||
api.getStreamProfiles.mockResolvedValue(mockProfiles);
|
||||
|
||||
const { result } = renderHook(() => useStreamProfilesStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchProfiles();
|
||||
});
|
||||
|
||||
expect(api.getStreamProfiles).toHaveBeenCalled();
|
||||
expect(result.current.profiles).toEqual(mockProfiles);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle fetch profiles error', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
api.getStreamProfiles.mockRejectedValue(mockError);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useStreamProfilesStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchProfiles();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Failed to load profiles.');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch profiles:', mockError);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should set loading state during fetch', async () => {
|
||||
let resolvePromise;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
api.getStreamProfiles.mockReturnValue(promise);
|
||||
|
||||
const { result } = renderHook(() => useStreamProfilesStore());
|
||||
|
||||
act(() => {
|
||||
result.current.fetchProfiles();
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.error).toBe(null);
|
||||
|
||||
await act(async () => {
|
||||
resolvePromise([]);
|
||||
await promise;
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should add stream profile', () => {
|
||||
useStreamProfilesStore.setState({
|
||||
profiles: [{ id: 1, name: 'Profile 1', bitrate: 5000 }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamProfilesStore());
|
||||
const newProfile = { id: 2, name: 'Profile 2', bitrate: 8000 };
|
||||
|
||||
act(() => {
|
||||
result.current.addStreamProfile(newProfile);
|
||||
});
|
||||
|
||||
expect(result.current.profiles).toEqual([
|
||||
{ id: 1, name: 'Profile 1', bitrate: 5000 },
|
||||
{ id: 2, name: 'Profile 2', bitrate: 8000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add stream profile to empty profiles', () => {
|
||||
const { result } = renderHook(() => useStreamProfilesStore());
|
||||
const newProfile = { id: 1, name: 'Profile 1', bitrate: 5000 };
|
||||
|
||||
act(() => {
|
||||
result.current.addStreamProfile(newProfile);
|
||||
});
|
||||
|
||||
expect(result.current.profiles).toEqual([newProfile]);
|
||||
});
|
||||
|
||||
it('should update stream profile', () => {
|
||||
useStreamProfilesStore.setState({
|
||||
profiles: [
|
||||
{ id: 1, name: 'Profile 1', bitrate: 5000 },
|
||||
{ id: 2, name: 'Profile 2', bitrate: 8000 },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamProfilesStore());
|
||||
const updatedProfile = { id: 1, name: 'Updated Profile', bitrate: 10000 };
|
||||
|
||||
act(() => {
|
||||
result.current.updateStreamProfile(updatedProfile);
|
||||
});
|
||||
|
||||
expect(result.current.profiles).toEqual([
|
||||
{ id: 1, name: 'Updated Profile', bitrate: 10000 },
|
||||
{ id: 2, name: 'Profile 2', bitrate: 8000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not modify other profiles when updating', () => {
|
||||
useStreamProfilesStore.setState({
|
||||
profiles: [
|
||||
{ id: 1, name: 'Profile 1', bitrate: 5000 },
|
||||
{ id: 2, name: 'Profile 2', bitrate: 8000 },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamProfilesStore());
|
||||
const updatedProfile = { id: 1, name: 'Updated Profile', bitrate: 10000 };
|
||||
|
||||
act(() => {
|
||||
result.current.updateStreamProfile(updatedProfile);
|
||||
});
|
||||
|
||||
expect(result.current.profiles[1]).toEqual({ id: 2, name: 'Profile 2', bitrate: 8000 });
|
||||
});
|
||||
|
||||
it('should not modify profiles when updating non-existent profile', () => {
|
||||
const initialProfiles = [
|
||||
{ id: 1, name: 'Profile 1', bitrate: 5000 },
|
||||
{ id: 2, name: 'Profile 2', bitrate: 8000 },
|
||||
];
|
||||
|
||||
useStreamProfilesStore.setState({
|
||||
profiles: initialProfiles,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamProfilesStore());
|
||||
const nonExistentProfile = { id: 999, name: 'Non-existent', bitrate: 10000 };
|
||||
|
||||
act(() => {
|
||||
result.current.updateStreamProfile(nonExistentProfile);
|
||||
});
|
||||
|
||||
expect(result.current.profiles).toEqual(initialProfiles);
|
||||
});
|
||||
|
||||
it('should remove single stream profile', () => {
|
||||
useStreamProfilesStore.setState({
|
||||
profiles: [
|
||||
{ id: 1, name: 'Profile 1', bitrate: 5000 },
|
||||
{ id: 2, name: 'Profile 2', bitrate: 8000 },
|
||||
{ id: 3, name: 'Profile 3', bitrate: 10000 },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamProfilesStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeStreamProfiles([2]);
|
||||
});
|
||||
|
||||
expect(result.current.profiles).toEqual([
|
||||
{ id: 1, name: 'Profile 1', bitrate: 5000 },
|
||||
{ id: 3, name: 'Profile 3', bitrate: 10000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove multiple stream profiles', () => {
|
||||
useStreamProfilesStore.setState({
|
||||
profiles: [
|
||||
{ id: 1, name: 'Profile 1', bitrate: 5000 },
|
||||
{ id: 2, name: 'Profile 2', bitrate: 8000 },
|
||||
{ id: 3, name: 'Profile 3', bitrate: 10000 },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamProfilesStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeStreamProfiles([1, 3]);
|
||||
});
|
||||
|
||||
expect(result.current.profiles).toEqual([
|
||||
{ id: 2, name: 'Profile 2', bitrate: 8000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle removing non-existent profiles', () => {
|
||||
const initialProfiles = [
|
||||
{ id: 1, name: 'Profile 1', bitrate: 5000 },
|
||||
{ id: 2, name: 'Profile 2', bitrate: 8000 },
|
||||
];
|
||||
|
||||
useStreamProfilesStore.setState({
|
||||
profiles: initialProfiles,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamProfilesStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeStreamProfiles([999]);
|
||||
});
|
||||
|
||||
expect(result.current.profiles).toEqual(initialProfiles);
|
||||
});
|
||||
|
||||
it('should handle removing from empty profiles', () => {
|
||||
const { result } = renderHook(() => useStreamProfilesStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeStreamProfiles([1, 2]);
|
||||
});
|
||||
|
||||
expect(result.current.profiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty array when removing profiles', () => {
|
||||
const initialProfiles = [
|
||||
{ id: 1, name: 'Profile 1', bitrate: 5000 },
|
||||
];
|
||||
|
||||
useStreamProfilesStore.setState({
|
||||
profiles: initialProfiles,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamProfilesStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeStreamProfiles([]);
|
||||
});
|
||||
|
||||
expect(result.current.profiles).toEqual(initialProfiles);
|
||||
});
|
||||
});
|
||||
289
frontend/src/store/__tests__/streams.test.jsx
Normal file
289
frontend/src/store/__tests__/streams.test.jsx
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import useStreamsStore from '../streams';
|
||||
import api from '../../api';
|
||||
|
||||
vi.mock('../../api');
|
||||
|
||||
describe('useStreamsStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useStreamsStore.setState({
|
||||
streams: [],
|
||||
count: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useStreamsStore());
|
||||
|
||||
expect(result.current.streams).toEqual([]);
|
||||
expect(result.current.count).toBe(0);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should fetch streams successfully', async () => {
|
||||
const mockResponse = {
|
||||
results: [
|
||||
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
|
||||
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
|
||||
],
|
||||
count: 2,
|
||||
};
|
||||
|
||||
api.getStreams.mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useStreamsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchStreams();
|
||||
});
|
||||
|
||||
expect(api.getStreams).toHaveBeenCalled();
|
||||
expect(result.current.streams).toEqual(mockResponse.results);
|
||||
expect(result.current.count).toBe(2);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle fetch streams error', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
api.getStreams.mockRejectedValue(mockError);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useStreamsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchStreams();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Failed to load streams.');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch streams:', mockError);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should set loading state during fetch', async () => {
|
||||
let resolvePromise;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
api.getStreams.mockReturnValue(promise);
|
||||
|
||||
const { result } = renderHook(() => useStreamsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.fetchStreams();
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.error).toBe(null);
|
||||
|
||||
await act(async () => {
|
||||
resolvePromise({ results: [], count: 0 });
|
||||
await promise;
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should add stream', () => {
|
||||
useStreamsStore.setState({
|
||||
streams: [{ id: 1, name: 'Stream 1', url: 'http://example.com/1' }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamsStore());
|
||||
const newStream = { id: 2, name: 'Stream 2', url: 'http://example.com/2' };
|
||||
|
||||
act(() => {
|
||||
result.current.addStream(newStream);
|
||||
});
|
||||
|
||||
expect(result.current.streams).toEqual([
|
||||
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
|
||||
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add stream to empty streams', () => {
|
||||
const { result } = renderHook(() => useStreamsStore());
|
||||
const newStream = { id: 1, name: 'Stream 1', url: 'http://example.com/1' };
|
||||
|
||||
act(() => {
|
||||
result.current.addStream(newStream);
|
||||
});
|
||||
|
||||
expect(result.current.streams).toEqual([newStream]);
|
||||
});
|
||||
|
||||
it('should update stream', () => {
|
||||
useStreamsStore.setState({
|
||||
streams: [
|
||||
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
|
||||
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamsStore());
|
||||
const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' };
|
||||
|
||||
act(() => {
|
||||
result.current.updateStream(updatedStream);
|
||||
});
|
||||
|
||||
expect(result.current.streams).toEqual([
|
||||
{ id: 1, name: 'Updated Stream', url: 'http://example.com/updated' },
|
||||
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not modify other streams when updating', () => {
|
||||
useStreamsStore.setState({
|
||||
streams: [
|
||||
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
|
||||
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamsStore());
|
||||
const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' };
|
||||
|
||||
act(() => {
|
||||
result.current.updateStream(updatedStream);
|
||||
});
|
||||
|
||||
expect(result.current.streams[1]).toEqual({ id: 2, name: 'Stream 2', url: 'http://example.com/2' });
|
||||
});
|
||||
|
||||
it('should not modify streams when updating non-existent stream', () => {
|
||||
const initialStreams = [
|
||||
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
|
||||
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
|
||||
];
|
||||
|
||||
useStreamsStore.setState({
|
||||
streams: initialStreams,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamsStore());
|
||||
const nonExistentStream = { id: 999, name: 'Non-existent', url: 'http://example.com/999' };
|
||||
|
||||
act(() => {
|
||||
result.current.updateStream(nonExistentStream);
|
||||
});
|
||||
|
||||
expect(result.current.streams).toEqual(initialStreams);
|
||||
});
|
||||
|
||||
it('should remove single stream', () => {
|
||||
useStreamsStore.setState({
|
||||
streams: [
|
||||
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
|
||||
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
|
||||
{ id: 3, name: 'Stream 3', url: 'http://example.com/3' },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeStreams([2]);
|
||||
});
|
||||
|
||||
expect(result.current.streams).toEqual([
|
||||
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
|
||||
{ id: 3, name: 'Stream 3', url: 'http://example.com/3' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove multiple streams', () => {
|
||||
useStreamsStore.setState({
|
||||
streams: [
|
||||
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
|
||||
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
|
||||
{ id: 3, name: 'Stream 3', url: 'http://example.com/3' },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeStreams([1, 3]);
|
||||
});
|
||||
|
||||
expect(result.current.streams).toEqual([
|
||||
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle removing non-existent streams', () => {
|
||||
const initialStreams = [
|
||||
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
|
||||
{ id: 2, name: 'Stream 2', url: 'http://example.com/2' },
|
||||
];
|
||||
|
||||
useStreamsStore.setState({
|
||||
streams: initialStreams,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeStreams([999]);
|
||||
});
|
||||
|
||||
expect(result.current.streams).toEqual(initialStreams);
|
||||
});
|
||||
|
||||
it('should handle removing from empty streams', () => {
|
||||
const { result } = renderHook(() => useStreamsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeStreams([1, 2]);
|
||||
});
|
||||
|
||||
expect(result.current.streams).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty array when removing streams', () => {
|
||||
const initialStreams = [
|
||||
{ id: 1, name: 'Stream 1', url: 'http://example.com/1' },
|
||||
];
|
||||
|
||||
useStreamsStore.setState({
|
||||
streams: initialStreams,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeStreams([]);
|
||||
});
|
||||
|
||||
expect(result.current.streams).toEqual(initialStreams);
|
||||
});
|
||||
|
||||
it('should handle fetch with empty results', async () => {
|
||||
const mockResponse = {
|
||||
results: [],
|
||||
count: 0,
|
||||
};
|
||||
|
||||
api.getStreams.mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useStreamsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchStreams();
|
||||
});
|
||||
|
||||
expect(result.current.streams).toEqual([]);
|
||||
expect(result.current.count).toBe(0);
|
||||
});
|
||||
});
|
||||
349
frontend/src/store/__tests__/streamsTable.test.jsx
Normal file
349
frontend/src/store/__tests__/streamsTable.test.jsx
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import useStreamsTableStore from '../streamsTable';
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store = {};
|
||||
|
||||
return {
|
||||
getItem: vi.fn((key) => store[key] || null),
|
||||
setItem: vi.fn((key, value) => {
|
||||
store[key] = value.toString();
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
}),
|
||||
removeItem: vi.fn((key) => {
|
||||
delete store[key];
|
||||
})
|
||||
};
|
||||
})();
|
||||
|
||||
globalThis.localStorage = localStorageMock;
|
||||
|
||||
describe('useStreamsTableStore', () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
expect(result.current.streams).toEqual([]);
|
||||
expect(result.current.pageCount).toBe(0);
|
||||
expect(result.current.totalCount).toBe(0);
|
||||
expect(result.current.sorting).toEqual([{ id: 'name', desc: false }]);
|
||||
expect(result.current.pagination).toEqual({
|
||||
pageIndex: 0,
|
||||
pageSize: 50,
|
||||
});
|
||||
expect(result.current.selectedStreamIds).toEqual([]);
|
||||
expect(result.current.allQueryIds).toEqual([]);
|
||||
expect(result.current.lastQueryParams).toBeNull();
|
||||
});
|
||||
|
||||
it('should initialize pagination with localStorage value if available', () => {
|
||||
// Set localStorage BEFORE getting the store state
|
||||
localStorageMock.setItem('streams-page-size', JSON.stringify(100));
|
||||
|
||||
// Now update the store to re-read from localStorage
|
||||
useStreamsTableStore.setState({
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50,
|
||||
}
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
expect(result.current.pagination.pageSize).toBe(100);
|
||||
});
|
||||
|
||||
it('should use default page size if localStorage is empty', () => {
|
||||
// Ensure localStorage is empty
|
||||
localStorageMock.clear();
|
||||
|
||||
useStreamsTableStore.setState({
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50,
|
||||
}
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
expect(result.current.pagination.pageSize).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryStreams', () => {
|
||||
it('should update streams, totalCount, and pageCount', () => {
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
const mockResults = [
|
||||
{ id: 1, name: 'Stream 1' },
|
||||
{ id: 2, name: 'Stream 2' },
|
||||
];
|
||||
const mockCount = 100;
|
||||
const mockParams = new URLSearchParams({ page_size: '50' });
|
||||
|
||||
act(() => {
|
||||
result.current.queryStreams(
|
||||
{ results: mockResults, count: mockCount },
|
||||
mockParams
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.streams).toEqual(mockResults);
|
||||
expect(result.current.totalCount).toBe(100);
|
||||
expect(result.current.pageCount).toBe(2); // Math.ceil(100 / 50)
|
||||
});
|
||||
|
||||
it('should calculate pageCount correctly with different page sizes', () => {
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
const mockParams = new URLSearchParams({ page_size: '25' });
|
||||
|
||||
act(() => {
|
||||
result.current.queryStreams(
|
||||
{ results: [], count: 75 },
|
||||
mockParams
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.pageCount).toBe(3); // Math.ceil(75 / 25)
|
||||
});
|
||||
|
||||
it('should handle empty results', () => {
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
const mockParams = new URLSearchParams({ page_size: '50' });
|
||||
|
||||
act(() => {
|
||||
result.current.queryStreams(
|
||||
{ results: [], count: 0 },
|
||||
mockParams
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.streams).toEqual([]);
|
||||
expect(result.current.totalCount).toBe(0);
|
||||
expect(result.current.pageCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAllQueryIds', () => {
|
||||
it('should update allQueryIds', () => {
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
const mockIds = [1, 2, 3, 4, 5];
|
||||
|
||||
act(() => {
|
||||
result.current.setAllQueryIds(mockIds);
|
||||
});
|
||||
|
||||
expect(result.current.allQueryIds).toEqual(mockIds);
|
||||
});
|
||||
|
||||
it('should replace previous allQueryIds', () => {
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setAllQueryIds([1, 2, 3]);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setAllQueryIds([4, 5, 6]);
|
||||
});
|
||||
|
||||
expect(result.current.allQueryIds).toEqual([4, 5, 6]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSelectedStreamIds', () => {
|
||||
it('should update selectedStreamIds', () => {
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
const mockIds = [1, 3, 5];
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedStreamIds(mockIds);
|
||||
});
|
||||
|
||||
expect(result.current.selectedStreamIds).toEqual(mockIds);
|
||||
});
|
||||
|
||||
it('should handle empty selection', () => {
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedStreamIds([1, 2, 3]);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedStreamIds([]);
|
||||
});
|
||||
|
||||
expect(result.current.selectedStreamIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setPagination', () => {
|
||||
it('should update pagination state', () => {
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
const newPagination = {
|
||||
pageIndex: 2,
|
||||
pageSize: 100,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.setPagination(newPagination);
|
||||
});
|
||||
|
||||
expect(result.current.pagination).toEqual(newPagination);
|
||||
});
|
||||
|
||||
it('should replace entire pagination object', () => {
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setPagination({ pageIndex: 1, pageSize: 25 });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setPagination({ pageIndex: 3, pageSize: 75 });
|
||||
});
|
||||
|
||||
expect(result.current.pagination).toEqual({ pageIndex: 3, pageSize: 75 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSorting', () => {
|
||||
it('should update sorting state', () => {
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
const newSorting = [{ id: 'created_at', desc: true }];
|
||||
|
||||
act(() => {
|
||||
result.current.setSorting(newSorting);
|
||||
});
|
||||
|
||||
expect(result.current.sorting).toEqual(newSorting);
|
||||
});
|
||||
|
||||
it('should handle multiple sorting columns', () => {
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
const newSorting = [
|
||||
{ id: 'name', desc: false },
|
||||
{ id: 'created_at', desc: true },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.setSorting(newSorting);
|
||||
});
|
||||
|
||||
expect(result.current.sorting).toEqual(newSorting);
|
||||
});
|
||||
|
||||
it('should handle empty sorting array', () => {
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSorting([]);
|
||||
});
|
||||
|
||||
expect(result.current.sorting).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLastQueryParams', () => {
|
||||
it('should update lastQueryParams', () => {
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
const mockParams = new URLSearchParams({ page: '1', search: 'test' });
|
||||
|
||||
act(() => {
|
||||
result.current.setLastQueryParams(mockParams);
|
||||
});
|
||||
|
||||
expect(result.current.lastQueryParams).toBe(mockParams);
|
||||
});
|
||||
|
||||
it('should handle null value', () => {
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setLastQueryParams(new URLSearchParams());
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setLastQueryParams(null);
|
||||
});
|
||||
|
||||
expect(result.current.lastQueryParams).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
it('should handle a typical query flow', () => {
|
||||
const { result } = renderHook(() => useStreamsTableStore());
|
||||
|
||||
// Set pagination
|
||||
act(() => {
|
||||
result.current.setPagination({ pageIndex: 0, pageSize: 25 });
|
||||
});
|
||||
|
||||
// Set sorting
|
||||
act(() => {
|
||||
result.current.setSorting([{ id: 'created_at', desc: true }]);
|
||||
});
|
||||
|
||||
// Query streams
|
||||
const mockResults = [
|
||||
{ id: 1, name: 'Stream 1' },
|
||||
{ id: 2, name: 'Stream 2' },
|
||||
];
|
||||
const mockParams = new URLSearchParams({ page_size: '25' });
|
||||
|
||||
act(() => {
|
||||
result.current.queryStreams(
|
||||
{ results: mockResults, count: 50 },
|
||||
mockParams
|
||||
);
|
||||
});
|
||||
|
||||
// Set query IDs
|
||||
act(() => {
|
||||
result.current.setAllQueryIds([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
// Select streams
|
||||
act(() => {
|
||||
result.current.setSelectedStreamIds([1, 2]);
|
||||
});
|
||||
|
||||
// Set last query params
|
||||
act(() => {
|
||||
result.current.setLastQueryParams(mockParams);
|
||||
});
|
||||
|
||||
expect(result.current.streams).toEqual(mockResults);
|
||||
expect(result.current.totalCount).toBe(50);
|
||||
expect(result.current.pageCount).toBe(2);
|
||||
expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 25 });
|
||||
expect(result.current.sorting).toEqual([{ id: 'created_at', desc: true }]);
|
||||
expect(result.current.allQueryIds).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(result.current.selectedStreamIds).toEqual([1, 2]);
|
||||
expect(result.current.lastQueryParams).toBe(mockParams);
|
||||
});
|
||||
});
|
||||
});
|
||||
755
frontend/src/store/__tests__/useVODStore.test.jsx
Normal file
755
frontend/src/store/__tests__/useVODStore.test.jsx
Normal file
|
|
@ -0,0 +1,755 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import useVODStore from '../useVODStore';
|
||||
import api from '../../api';
|
||||
|
||||
vi.mock('../../api');
|
||||
|
||||
describe('useVODStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useVODStore.setState({
|
||||
content: {},
|
||||
currentPageContent: [],
|
||||
episodes: {},
|
||||
categories: {},
|
||||
loading: false,
|
||||
error: null,
|
||||
filters: {
|
||||
type: 'all',
|
||||
search: '',
|
||||
category: '',
|
||||
},
|
||||
currentPage: 1,
|
||||
totalCount: 0,
|
||||
pageSize: 24,
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
expect(result.current.content).toEqual({});
|
||||
expect(result.current.currentPageContent).toEqual([]);
|
||||
expect(result.current.episodes).toEqual({});
|
||||
expect(result.current.categories).toEqual({});
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
expect(result.current.filters).toEqual({
|
||||
type: 'all',
|
||||
search: '',
|
||||
category: '',
|
||||
});
|
||||
expect(result.current.currentPage).toBe(1);
|
||||
expect(result.current.totalCount).toBe(0);
|
||||
expect(result.current.pageSize).toBe(24);
|
||||
});
|
||||
|
||||
it('should set filters and reset to first page', () => {
|
||||
useVODStore.setState({ currentPage: 5 });
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setFilters({ search: 'test', category: 'action' });
|
||||
});
|
||||
|
||||
expect(result.current.filters).toEqual({
|
||||
type: 'all',
|
||||
search: 'test',
|
||||
category: 'action',
|
||||
});
|
||||
expect(result.current.currentPage).toBe(1);
|
||||
});
|
||||
|
||||
it('should set page', () => {
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setPage(3);
|
||||
});
|
||||
|
||||
expect(result.current.currentPage).toBe(3);
|
||||
});
|
||||
|
||||
it('should set page size and reset to first page', () => {
|
||||
useVODStore.setState({ currentPage: 3 });
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setPageSize(50);
|
||||
});
|
||||
|
||||
expect(result.current.pageSize).toBe(50);
|
||||
expect(result.current.currentPage).toBe(1);
|
||||
});
|
||||
|
||||
it('should fetch all content successfully', async () => {
|
||||
const mockResponse = {
|
||||
results: [
|
||||
{ id: 1, name: 'Movie 1', content_type: 'movie' },
|
||||
{ id: 2, name: 'Series 1', content_type: 'series' },
|
||||
],
|
||||
count: 2,
|
||||
};
|
||||
|
||||
api.getAllContent.mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchContent();
|
||||
});
|
||||
|
||||
expect(api.getAllContent).toHaveBeenCalled();
|
||||
expect(result.current.currentPageContent).toEqual([
|
||||
{ id: 1, name: 'Movie 1', content_type: 'movie', contentType: 'movie' },
|
||||
{ id: 2, name: 'Series 1', content_type: 'series', contentType: 'series' },
|
||||
]);
|
||||
expect(result.current.totalCount).toBe(2);
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should fetch only movies when filter type is movies', async () => {
|
||||
const mockResponse = {
|
||||
results: [
|
||||
{ id: 1, name: 'Movie 1' },
|
||||
{ id: 2, name: 'Movie 2' },
|
||||
],
|
||||
count: 2,
|
||||
};
|
||||
|
||||
api.getMovies.mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setFilters({ type: 'movies' });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchContent();
|
||||
});
|
||||
|
||||
expect(api.getMovies).toHaveBeenCalled();
|
||||
expect(result.current.currentPageContent).toEqual([
|
||||
{ id: 1, name: 'Movie 1', contentType: 'movie' },
|
||||
{ id: 2, name: 'Movie 2', contentType: 'movie' },
|
||||
]);
|
||||
expect(result.current.totalCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should fetch only series when filter type is series', async () => {
|
||||
const mockResponse = {
|
||||
results: [
|
||||
{ id: 1, name: 'Series 1' },
|
||||
{ id: 2, name: 'Series 2' },
|
||||
],
|
||||
count: 2,
|
||||
};
|
||||
|
||||
api.getSeries.mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setFilters({ type: 'series' });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchContent();
|
||||
});
|
||||
|
||||
expect(api.getSeries).toHaveBeenCalled();
|
||||
expect(result.current.currentPageContent).toEqual([
|
||||
{ id: 1, name: 'Series 1', contentType: 'series' },
|
||||
{ id: 2, name: 'Series 2', contentType: 'series' },
|
||||
]);
|
||||
expect(result.current.totalCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle fetch content error', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
api.getAllContent.mockRejectedValue(mockError);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchContent();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Failed to load content.');
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch content:', mockError);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle invalid response format', async () => {
|
||||
api.getAllContent.mockResolvedValue({ results: 'not-an-array' });
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchContent();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Failed to load content.');
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should fetch movie details successfully', async () => {
|
||||
const mockResponse = {
|
||||
id: 1,
|
||||
name: 'Test Movie',
|
||||
description: 'A test movie',
|
||||
year: 2023,
|
||||
url: 'http://example.com/movie.mp4',
|
||||
};
|
||||
|
||||
api.getMovieDetails.mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
let movieDetails;
|
||||
await act(async () => {
|
||||
movieDetails = await result.current.fetchMovieDetails(1);
|
||||
});
|
||||
|
||||
expect(api.getMovieDetails).toHaveBeenCalledWith(1);
|
||||
expect(movieDetails.id).toBe(1);
|
||||
expect(movieDetails.name).toBe('Test Movie');
|
||||
expect(movieDetails.stream_url).toBe('http://example.com/movie.mp4');
|
||||
expect(result.current.content['movie_1']).toBeDefined();
|
||||
expect(result.current.content['movie_1'].contentType).toBe('movie');
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle fetch movie details error', async () => {
|
||||
const mockError = new Error('Not found');
|
||||
api.getMovieDetails.mockRejectedValue(mockError);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.fetchMovieDetails(999);
|
||||
} catch (error) {
|
||||
expect(error).toBe(mockError);
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Failed to load movie details.');
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details:', mockError);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should fetch movie details from provider without merging to store', async () => {
|
||||
const mockResponse = {
|
||||
id: 1,
|
||||
name: 'Provider Movie',
|
||||
plot: 'From provider',
|
||||
stream_url: 'http://provider.com/movie.mp4',
|
||||
backdrop_path: ['path1', 'path2'],
|
||||
};
|
||||
|
||||
api.getMovieProviderInfo.mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
let movieDetails;
|
||||
await act(async () => {
|
||||
movieDetails = await result.current.fetchMovieDetailsFromProvider(1);
|
||||
});
|
||||
|
||||
expect(api.getMovieProviderInfo).toHaveBeenCalledWith(1);
|
||||
expect(movieDetails.name).toBe('Provider Movie');
|
||||
expect(movieDetails.description).toBe('From provider');
|
||||
expect(movieDetails.backdrop_path).toEqual(['path1', 'path2']);
|
||||
expect(result.current.content['movie_1']).toBeUndefined();
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle fetch movie provider error', async () => {
|
||||
const mockError = new Error('Provider error');
|
||||
api.getMovieProviderInfo.mockRejectedValue(mockError);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.fetchMovieDetailsFromProvider(1);
|
||||
} catch (error) {
|
||||
expect(error).toBe(mockError);
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Failed to load movie details from provider.');
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details from provider:', mockError);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should fetch movie providers successfully', async () => {
|
||||
const mockProviders = [
|
||||
{ id: 1, name: 'Provider 1' },
|
||||
{ id: 2, name: 'Provider 2' },
|
||||
];
|
||||
|
||||
api.getMovieProviders.mockResolvedValue(mockProviders);
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
let providers;
|
||||
await act(async () => {
|
||||
providers = await result.current.fetchMovieProviders(1);
|
||||
});
|
||||
|
||||
expect(api.getMovieProviders).toHaveBeenCalledWith(1);
|
||||
expect(providers).toEqual(mockProviders);
|
||||
});
|
||||
|
||||
it('should handle fetch movie providers error', async () => {
|
||||
const mockError = new Error('Providers error');
|
||||
api.getMovieProviders.mockRejectedValue(mockError);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.fetchMovieProviders(1);
|
||||
} catch (error) {
|
||||
expect(error).toBe(mockError);
|
||||
}
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie providers:', mockError);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should fetch series providers successfully', async () => {
|
||||
const mockProviders = [{ id: 1, name: 'Series Provider 1' }];
|
||||
|
||||
api.getSeriesProviders.mockResolvedValue(mockProviders);
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
let providers;
|
||||
await act(async () => {
|
||||
providers = await result.current.fetchSeriesProviders(1);
|
||||
});
|
||||
|
||||
expect(api.getSeriesProviders).toHaveBeenCalledWith(1);
|
||||
expect(providers).toEqual(mockProviders);
|
||||
});
|
||||
|
||||
it('should handle fetch series providers error', async () => {
|
||||
const mockError = new Error('Series providers error');
|
||||
api.getSeriesProviders.mockRejectedValue(mockError);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.fetchSeriesProviders(1);
|
||||
} catch (error) {
|
||||
expect(error).toBe(mockError);
|
||||
}
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series providers:', mockError);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should fetch series info successfully', async () => {
|
||||
const mockResponse = {
|
||||
id: 1,
|
||||
name: 'Test Series',
|
||||
description: 'A test series',
|
||||
year: 2023,
|
||||
cover: 'http://example.com/cover.jpg',
|
||||
episodes: {
|
||||
1: [
|
||||
{
|
||||
id: 101,
|
||||
title: 'Episode 1',
|
||||
episode_number: 1,
|
||||
plot: 'First episode',
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
title: 'Episode 2',
|
||||
episode_number: 2,
|
||||
plot: 'Second episode',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
api.getSeriesInfo.mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
let seriesInfo;
|
||||
await act(async () => {
|
||||
seriesInfo = await result.current.fetchSeriesInfo(1);
|
||||
});
|
||||
|
||||
expect(api.getSeriesInfo).toHaveBeenCalledWith(1);
|
||||
expect(seriesInfo.id).toBe(1);
|
||||
expect(seriesInfo.name).toBe('Test Series');
|
||||
expect(seriesInfo.episodesList).toHaveLength(2);
|
||||
expect(result.current.content['series_1']).toBeDefined();
|
||||
expect(result.current.content['series_1'].contentType).toBe('series');
|
||||
expect(result.current.episodes[101]).toBeDefined();
|
||||
expect(result.current.episodes[102]).toBeDefined();
|
||||
expect(result.current.episodes[101].name).toBe('Episode 1');
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle fetch series info error', async () => {
|
||||
const mockError = new Error('Series not found');
|
||||
api.getSeriesInfo.mockRejectedValue(mockError);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.fetchSeriesInfo(999);
|
||||
} catch (error) {
|
||||
expect(error).toBe(mockError);
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Failed to load series details.');
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series info:', mockError);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should fetch categories successfully with array response', async () => {
|
||||
const mockCategories = [
|
||||
{ id: 1, name: 'Action' },
|
||||
{ id: 2, name: 'Comedy' },
|
||||
];
|
||||
|
||||
api.getVODCategories.mockResolvedValue(mockCategories);
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchCategories();
|
||||
});
|
||||
|
||||
expect(api.getVODCategories).toHaveBeenCalled();
|
||||
expect(result.current.categories).toEqual({
|
||||
1: { id: 1, name: 'Action' },
|
||||
2: { id: 2, name: 'Comedy' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch categories successfully with paginated response', async () => {
|
||||
const mockResponse = {
|
||||
results: [
|
||||
{ id: 1, name: 'Drama' },
|
||||
{ id: 2, name: 'Thriller' },
|
||||
],
|
||||
};
|
||||
|
||||
api.getVODCategories.mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchCategories();
|
||||
});
|
||||
|
||||
expect(result.current.categories).toEqual({
|
||||
1: { id: 1, name: 'Drama' },
|
||||
2: { id: 2, name: 'Thriller' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle fetch categories error', async () => {
|
||||
const mockError = new Error('Categories error');
|
||||
api.getVODCategories.mockRejectedValue(mockError);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchCategories();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Failed to load categories.');
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD categories:', mockError);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should add movie to content', () => {
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
const movie = { id: 1, name: 'New Movie' };
|
||||
|
||||
act(() => {
|
||||
result.current.addMovie(movie);
|
||||
});
|
||||
|
||||
expect(result.current.content['movie_1']).toEqual({
|
||||
id: 1,
|
||||
name: 'New Movie',
|
||||
contentType: 'movie',
|
||||
});
|
||||
});
|
||||
|
||||
it('should update movie in content', () => {
|
||||
useVODStore.setState({
|
||||
content: {
|
||||
movie_1: { id: 1, name: 'Old Movie', contentType: 'movie' },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
const updatedMovie = { id: 1, name: 'Updated Movie' };
|
||||
|
||||
act(() => {
|
||||
result.current.updateMovie(updatedMovie);
|
||||
});
|
||||
|
||||
expect(result.current.content['movie_1']).toEqual({
|
||||
id: 1,
|
||||
name: 'Updated Movie',
|
||||
contentType: 'movie',
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove movie from content', () => {
|
||||
useVODStore.setState({
|
||||
content: {
|
||||
movie_1: { id: 1, name: 'Movie to Remove', contentType: 'movie' },
|
||||
movie_2: { id: 2, name: 'Movie to Keep', contentType: 'movie' },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeMovie(1);
|
||||
});
|
||||
|
||||
expect(result.current.content['movie_1']).toBeUndefined();
|
||||
expect(result.current.content['movie_2']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should add series to content', () => {
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
const series = { id: 1, name: 'New Series' };
|
||||
|
||||
act(() => {
|
||||
result.current.addSeries(series);
|
||||
});
|
||||
|
||||
expect(result.current.content['series_1']).toEqual({
|
||||
id: 1,
|
||||
name: 'New Series',
|
||||
contentType: 'series',
|
||||
});
|
||||
});
|
||||
|
||||
it('should update series in content', () => {
|
||||
useVODStore.setState({
|
||||
content: {
|
||||
series_1: { id: 1, name: 'Old Series', contentType: 'series' },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
const updatedSeries = { id: 1, name: 'Updated Series' };
|
||||
|
||||
act(() => {
|
||||
result.current.updateSeries(updatedSeries);
|
||||
});
|
||||
|
||||
expect(result.current.content['series_1']).toEqual({
|
||||
id: 1,
|
||||
name: 'Updated Series',
|
||||
contentType: 'series',
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove series from content', () => {
|
||||
useVODStore.setState({
|
||||
content: {
|
||||
series_1: { id: 1, name: 'Series to Remove', contentType: 'series' },
|
||||
series_2: { id: 2, name: 'Series to Keep', contentType: 'series' },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeSeries(1);
|
||||
});
|
||||
|
||||
expect(result.current.content['series_1']).toBeUndefined();
|
||||
expect(result.current.content['series_2']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should get filtered content from current page', () => {
|
||||
const mockContent = [
|
||||
{ id: 1, name: 'Movie 1', contentType: 'movie' },
|
||||
{ id: 2, name: 'Series 1', contentType: 'series' },
|
||||
];
|
||||
|
||||
useVODStore.setState({
|
||||
currentPageContent: mockContent,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
const filtered = result.current.getFilteredContent();
|
||||
|
||||
expect(filtered).toEqual(mockContent);
|
||||
});
|
||||
|
||||
it('should get only movies from content', () => {
|
||||
useVODStore.setState({
|
||||
content: {
|
||||
movie_1: { id: 1, name: 'Movie 1', contentType: 'movie' },
|
||||
series_1: { id: 2, name: 'Series 1', contentType: 'series' },
|
||||
movie_2: { id: 3, name: 'Movie 2', contentType: 'movie' },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
const movies = result.current.getMovies();
|
||||
|
||||
expect(movies).toHaveLength(2);
|
||||
expect(movies.every((item) => item.contentType === 'movie')).toBe(true);
|
||||
});
|
||||
|
||||
it('should get only series from content', () => {
|
||||
useVODStore.setState({
|
||||
content: {
|
||||
movie_1: { id: 1, name: 'Movie 1', contentType: 'movie' },
|
||||
series_1: { id: 2, name: 'Series 1', contentType: 'series' },
|
||||
series_2: { id: 3, name: 'Series 2', contentType: 'series' },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
const series = result.current.getSeries();
|
||||
|
||||
expect(series).toHaveLength(2);
|
||||
expect(series.every((item) => item.contentType === 'series')).toBe(true);
|
||||
});
|
||||
|
||||
it('should clear all content', () => {
|
||||
useVODStore.setState({
|
||||
content: {
|
||||
movie_1: { id: 1, name: 'Movie 1', contentType: 'movie' },
|
||||
series_1: { id: 2, name: 'Series 1', contentType: 'series' },
|
||||
},
|
||||
totalCount: 2,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
act(() => {
|
||||
result.current.clearContent();
|
||||
});
|
||||
|
||||
expect(result.current.content).toEqual({});
|
||||
expect(result.current.totalCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle fetch content with search filter', async () => {
|
||||
const mockResponse = {
|
||||
results: [{ id: 1, name: 'Searched Movie', content_type: 'movie' }],
|
||||
count: 1,
|
||||
};
|
||||
|
||||
api.getAllContent.mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setFilters({ search: 'Searched' });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchContent();
|
||||
});
|
||||
|
||||
expect(api.getAllContent).toHaveBeenCalled();
|
||||
const callArgs = api.getAllContent.mock.calls[0][0];
|
||||
expect(callArgs.get('search')).toBe('Searched');
|
||||
});
|
||||
|
||||
it('should handle fetch content with category filter', async () => {
|
||||
const mockResponse = {
|
||||
results: [{ id: 1, name: 'Action Movie', content_type: 'movie' }],
|
||||
count: 1,
|
||||
};
|
||||
|
||||
api.getAllContent.mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setFilters({ category: 'action' });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchContent();
|
||||
});
|
||||
|
||||
const callArgs = api.getAllContent.mock.calls[0][0];
|
||||
expect(callArgs.get('category')).toBe('action');
|
||||
});
|
||||
|
||||
it('should set loading state during fetch', async () => {
|
||||
let resolvePromise;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
api.getAllContent.mockReturnValue(promise);
|
||||
|
||||
const { result } = renderHook(() => useVODStore());
|
||||
|
||||
act(() => {
|
||||
result.current.fetchContent();
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.error).toBe(null);
|
||||
|
||||
await act(async () => {
|
||||
resolvePromise({ results: [], count: 0 });
|
||||
await promise;
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
182
frontend/src/store/__tests__/useVideoStore.test.jsx
Normal file
182
frontend/src/store/__tests__/useVideoStore.test.jsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import useVideoStore from '../useVideoStore';
|
||||
|
||||
describe('useVideoStore', () => {
|
||||
beforeEach(() => {
|
||||
useVideoStore.setState({
|
||||
isVisible: false,
|
||||
streamUrl: null,
|
||||
contentType: 'live',
|
||||
metadata: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useVideoStore());
|
||||
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
expect(result.current.streamUrl).toBe(null);
|
||||
expect(result.current.contentType).toBe('live');
|
||||
expect(result.current.metadata).toBe(null);
|
||||
});
|
||||
|
||||
it('should show video with live stream', () => {
|
||||
const { result } = renderHook(() => useVideoStore());
|
||||
const streamUrl = 'http://example.com/stream.ts';
|
||||
|
||||
act(() => {
|
||||
result.current.showVideo(streamUrl);
|
||||
});
|
||||
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
expect(result.current.streamUrl).toBe(streamUrl);
|
||||
expect(result.current.contentType).toBe('live');
|
||||
expect(result.current.metadata).toBe(null);
|
||||
});
|
||||
|
||||
it('should show video with VOD content', () => {
|
||||
const { result } = renderHook(() => useVideoStore());
|
||||
const streamUrl = 'http://example.com/video.mp4';
|
||||
const metadata = { title: 'Test Video', duration: 120 };
|
||||
|
||||
act(() => {
|
||||
result.current.showVideo(streamUrl, 'vod', metadata);
|
||||
});
|
||||
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
expect(result.current.streamUrl).toBe(streamUrl);
|
||||
expect(result.current.contentType).toBe('vod');
|
||||
expect(result.current.metadata).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should show video with custom content type', () => {
|
||||
const { result } = renderHook(() => useVideoStore());
|
||||
const streamUrl = 'http://example.com/video.mkv';
|
||||
|
||||
act(() => {
|
||||
result.current.showVideo(streamUrl, 'vod');
|
||||
});
|
||||
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
expect(result.current.streamUrl).toBe(streamUrl);
|
||||
expect(result.current.contentType).toBe('vod');
|
||||
expect(result.current.metadata).toBe(null);
|
||||
});
|
||||
|
||||
it('should hide video and reset state', () => {
|
||||
const { result } = renderHook(() => useVideoStore());
|
||||
|
||||
act(() => {
|
||||
result.current.showVideo('http://example.com/stream.ts', 'vod', { title: 'Test' });
|
||||
});
|
||||
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.hideVideo();
|
||||
});
|
||||
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
expect(result.current.streamUrl).toBe(null);
|
||||
expect(result.current.contentType).toBe('live');
|
||||
expect(result.current.metadata).toBe(null);
|
||||
});
|
||||
|
||||
it('should update stream when showing different video', () => {
|
||||
const { result } = renderHook(() => useVideoStore());
|
||||
const firstUrl = 'http://example.com/stream1.ts';
|
||||
const secondUrl = 'http://example.com/stream2.ts';
|
||||
|
||||
act(() => {
|
||||
result.current.showVideo(firstUrl);
|
||||
});
|
||||
|
||||
expect(result.current.streamUrl).toBe(firstUrl);
|
||||
|
||||
act(() => {
|
||||
result.current.showVideo(secondUrl);
|
||||
});
|
||||
|
||||
expect(result.current.streamUrl).toBe(secondUrl);
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle showing video with null metadata explicitly', () => {
|
||||
const { result } = renderHook(() => useVideoStore());
|
||||
const streamUrl = 'http://example.com/stream.ts';
|
||||
|
||||
act(() => {
|
||||
result.current.showVideo(streamUrl, 'live', null);
|
||||
});
|
||||
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
expect(result.current.streamUrl).toBe(streamUrl);
|
||||
expect(result.current.contentType).toBe('live');
|
||||
expect(result.current.metadata).toBe(null);
|
||||
});
|
||||
|
||||
it('should preserve metadata when showing VOD content', () => {
|
||||
const { result } = renderHook(() => useVideoStore());
|
||||
const metadata = {
|
||||
title: 'Test Video',
|
||||
duration: 120,
|
||||
thumbnailUrl: 'http://example.com/thumb.jpg'
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.showVideo('http://example.com/video.mp4', 'vod', metadata);
|
||||
});
|
||||
|
||||
expect(result.current.metadata).toEqual(metadata);
|
||||
expect(result.current.metadata.title).toBe('Test Video');
|
||||
expect(result.current.metadata.duration).toBe(120);
|
||||
});
|
||||
|
||||
it('should override previous metadata when showing new video', () => {
|
||||
const { result } = renderHook(() => useVideoStore());
|
||||
const firstMetadata = { title: 'First Video' };
|
||||
const secondMetadata = { title: 'Second Video' };
|
||||
|
||||
act(() => {
|
||||
result.current.showVideo('http://example.com/first.mp4', 'vod', firstMetadata);
|
||||
});
|
||||
|
||||
expect(result.current.metadata).toEqual(firstMetadata);
|
||||
|
||||
act(() => {
|
||||
result.current.showVideo('http://example.com/second.mp4', 'vod', secondMetadata);
|
||||
});
|
||||
|
||||
expect(result.current.metadata).toEqual(secondMetadata);
|
||||
});
|
||||
|
||||
it('should handle hiding video when already hidden', () => {
|
||||
const { result } = renderHook(() => useVideoStore());
|
||||
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.hideVideo();
|
||||
});
|
||||
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
expect(result.current.streamUrl).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle showing video multiple times consecutively', () => {
|
||||
const { result } = renderHook(() => useVideoStore());
|
||||
const url1 = 'http://example.com/stream1.ts';
|
||||
const url2 = 'http://example.com/stream2.ts';
|
||||
const url3 = 'http://example.com/stream3.ts';
|
||||
|
||||
act(() => {
|
||||
result.current.showVideo(url1);
|
||||
result.current.showVideo(url2);
|
||||
result.current.showVideo(url3);
|
||||
});
|
||||
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
expect(result.current.streamUrl).toBe(url3);
|
||||
});
|
||||
});
|
||||
277
frontend/src/store/__tests__/userAgents.test.jsx
Normal file
277
frontend/src/store/__tests__/userAgents.test.jsx
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import useUserAgentsStore from '../userAgents';
|
||||
import api from '../../api';
|
||||
|
||||
vi.mock('../../api');
|
||||
|
||||
describe('useUserAgentsStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useUserAgentsStore.setState({
|
||||
userAgents: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useUserAgentsStore());
|
||||
|
||||
expect(result.current.userAgents).toEqual([]);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should fetch user agents successfully', async () => {
|
||||
const mockUserAgents = [
|
||||
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
|
||||
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
|
||||
];
|
||||
|
||||
api.getUserAgents.mockResolvedValue(mockUserAgents);
|
||||
|
||||
const { result } = renderHook(() => useUserAgentsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchUserAgents();
|
||||
});
|
||||
|
||||
expect(api.getUserAgents).toHaveBeenCalled();
|
||||
expect(result.current.userAgents).toEqual(mockUserAgents);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle fetch user agents error', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
api.getUserAgents.mockRejectedValue(mockError);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useUserAgentsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchUserAgents();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Failed to load userAgents.');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch userAgents:', mockError);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should set loading state during fetch', async () => {
|
||||
let resolvePromise;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
api.getUserAgents.mockReturnValue(promise);
|
||||
|
||||
const { result } = renderHook(() => useUserAgentsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.fetchUserAgents();
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.error).toBe(null);
|
||||
|
||||
await act(async () => {
|
||||
resolvePromise([]);
|
||||
await promise;
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should add user agent', () => {
|
||||
useUserAgentsStore.setState({
|
||||
userAgents: [{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserAgentsStore());
|
||||
const newUserAgent = { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' };
|
||||
|
||||
act(() => {
|
||||
result.current.addUserAgent(newUserAgent);
|
||||
});
|
||||
|
||||
expect(result.current.userAgents).toEqual([
|
||||
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
|
||||
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add user agent to empty user agents', () => {
|
||||
const { result } = renderHook(() => useUserAgentsStore());
|
||||
const newUserAgent = { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' };
|
||||
|
||||
act(() => {
|
||||
result.current.addUserAgent(newUserAgent);
|
||||
});
|
||||
|
||||
expect(result.current.userAgents).toEqual([newUserAgent]);
|
||||
});
|
||||
|
||||
it('should update user agent', () => {
|
||||
useUserAgentsStore.setState({
|
||||
userAgents: [
|
||||
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
|
||||
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserAgentsStore());
|
||||
const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' };
|
||||
|
||||
act(() => {
|
||||
result.current.updateUserAgent(updatedUserAgent);
|
||||
});
|
||||
|
||||
expect(result.current.userAgents).toEqual([
|
||||
{ id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' },
|
||||
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not modify other user agents when updating', () => {
|
||||
useUserAgentsStore.setState({
|
||||
userAgents: [
|
||||
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
|
||||
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserAgentsStore());
|
||||
const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' };
|
||||
|
||||
act(() => {
|
||||
result.current.updateUserAgent(updatedUserAgent);
|
||||
});
|
||||
|
||||
expect(result.current.userAgents[1]).toEqual({ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' });
|
||||
});
|
||||
|
||||
it('should not modify user agents when updating non-existent user agent', () => {
|
||||
const initialUserAgents = [
|
||||
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
|
||||
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
|
||||
];
|
||||
|
||||
useUserAgentsStore.setState({
|
||||
userAgents: initialUserAgents,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserAgentsStore());
|
||||
const nonExistentUserAgent = { id: 999, name: 'Non-existent', string: 'Mozilla/5.0...' };
|
||||
|
||||
act(() => {
|
||||
result.current.updateUserAgent(nonExistentUserAgent);
|
||||
});
|
||||
|
||||
expect(result.current.userAgents).toEqual(initialUserAgents);
|
||||
});
|
||||
|
||||
it('should remove single user agent', () => {
|
||||
useUserAgentsStore.setState({
|
||||
userAgents: [
|
||||
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
|
||||
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
|
||||
{ id: 3, name: 'Safari', string: 'Mozilla/5.0...' },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserAgentsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeUserAgents([2]);
|
||||
});
|
||||
|
||||
expect(result.current.userAgents).toEqual([
|
||||
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
|
||||
{ id: 3, name: 'Safari', string: 'Mozilla/5.0...' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove multiple user agents', () => {
|
||||
useUserAgentsStore.setState({
|
||||
userAgents: [
|
||||
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
|
||||
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
|
||||
{ id: 3, name: 'Safari', string: 'Mozilla/5.0...' },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserAgentsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeUserAgents([1, 3]);
|
||||
});
|
||||
|
||||
expect(result.current.userAgents).toEqual([
|
||||
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle removing non-existent user agents', () => {
|
||||
const initialUserAgents = [
|
||||
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
|
||||
{ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' },
|
||||
];
|
||||
|
||||
useUserAgentsStore.setState({
|
||||
userAgents: initialUserAgents,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserAgentsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeUserAgents([999]);
|
||||
});
|
||||
|
||||
expect(result.current.userAgents).toEqual(initialUserAgents);
|
||||
});
|
||||
|
||||
it('should handle removing from empty user agents', () => {
|
||||
const { result } = renderHook(() => useUserAgentsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeUserAgents([1, 2]);
|
||||
});
|
||||
|
||||
expect(result.current.userAgents).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty array when removing user agents', () => {
|
||||
const initialUserAgents = [
|
||||
{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' },
|
||||
];
|
||||
|
||||
useUserAgentsStore.setState({
|
||||
userAgents: initialUserAgents,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserAgentsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeUserAgents([]);
|
||||
});
|
||||
|
||||
expect(result.current.userAgents).toEqual(initialUserAgents);
|
||||
});
|
||||
|
||||
it('should handle fetch with empty results', async () => {
|
||||
api.getUserAgents.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useUserAgentsStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchUserAgents();
|
||||
});
|
||||
|
||||
expect(result.current.userAgents).toEqual([]);
|
||||
});
|
||||
});
|
||||
258
frontend/src/store/__tests__/users.test.jsx
Normal file
258
frontend/src/store/__tests__/users.test.jsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import useUsersStore from '../users';
|
||||
import api from '../../api';
|
||||
|
||||
vi.mock('../../api');
|
||||
|
||||
describe('useUsersStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useUsersStore.setState({
|
||||
users: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useUsersStore());
|
||||
|
||||
expect(result.current.users).toEqual([]);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should fetch users successfully', async () => {
|
||||
const mockUsers = [
|
||||
{ id: 1, name: 'User 1', email: 'user1@example.com' },
|
||||
{ id: 2, name: 'User 2', email: 'user2@example.com' },
|
||||
];
|
||||
|
||||
api.getUsers.mockResolvedValue(mockUsers);
|
||||
|
||||
const { result } = renderHook(() => useUsersStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchUsers();
|
||||
});
|
||||
|
||||
expect(api.getUsers).toHaveBeenCalled();
|
||||
expect(result.current.users).toEqual(mockUsers);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle fetch users error', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
api.getUsers.mockRejectedValue(mockError);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useUsersStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchUsers();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Failed to load users.');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch users:', mockError);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should set loading state during fetch', async () => {
|
||||
let resolvePromise;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
api.getUsers.mockReturnValue(promise);
|
||||
|
||||
const { result } = renderHook(() => useUsersStore());
|
||||
|
||||
act(() => {
|
||||
result.current.fetchUsers();
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.error).toBe(null);
|
||||
|
||||
await act(async () => {
|
||||
resolvePromise([]);
|
||||
await promise;
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should add user', () => {
|
||||
useUsersStore.setState({
|
||||
users: [{ id: 1, name: 'User 1', email: 'user1@example.com' }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUsersStore());
|
||||
const newUser = { id: 2, name: 'User 2', email: 'user2@example.com' };
|
||||
|
||||
act(() => {
|
||||
result.current.addUser(newUser);
|
||||
});
|
||||
|
||||
expect(result.current.users).toEqual([
|
||||
{ id: 1, name: 'User 1', email: 'user1@example.com' },
|
||||
{ id: 2, name: 'User 2', email: 'user2@example.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add user to empty users', () => {
|
||||
const { result } = renderHook(() => useUsersStore());
|
||||
const newUser = { id: 1, name: 'User 1', email: 'user1@example.com' };
|
||||
|
||||
act(() => {
|
||||
result.current.addUser(newUser);
|
||||
});
|
||||
|
||||
expect(result.current.users).toEqual([newUser]);
|
||||
});
|
||||
|
||||
it('should update user', () => {
|
||||
useUsersStore.setState({
|
||||
users: [
|
||||
{ id: 1, name: 'User 1', email: 'user1@example.com' },
|
||||
{ id: 2, name: 'User 2', email: 'user2@example.com' },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUsersStore());
|
||||
const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' };
|
||||
|
||||
act(() => {
|
||||
result.current.updateUser(updatedUser);
|
||||
});
|
||||
|
||||
expect(result.current.users).toEqual([
|
||||
{ id: 1, name: 'Updated User', email: 'updated@example.com' },
|
||||
{ id: 2, name: 'User 2', email: 'user2@example.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not modify other users when updating', () => {
|
||||
useUsersStore.setState({
|
||||
users: [
|
||||
{ id: 1, name: 'User 1', email: 'user1@example.com' },
|
||||
{ id: 2, name: 'User 2', email: 'user2@example.com' },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUsersStore());
|
||||
const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' };
|
||||
|
||||
act(() => {
|
||||
result.current.updateUser(updatedUser);
|
||||
});
|
||||
|
||||
expect(result.current.users[1]).toEqual({ id: 2, name: 'User 2', email: 'user2@example.com' });
|
||||
});
|
||||
|
||||
it('should not modify users when updating non-existent user', () => {
|
||||
const initialUsers = [
|
||||
{ id: 1, name: 'User 1', email: 'user1@example.com' },
|
||||
{ id: 2, name: 'User 2', email: 'user2@example.com' },
|
||||
];
|
||||
|
||||
useUsersStore.setState({
|
||||
users: initialUsers,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUsersStore());
|
||||
const nonExistentUser = { id: 999, name: 'Non-existent', email: 'none@example.com' };
|
||||
|
||||
act(() => {
|
||||
result.current.updateUser(nonExistentUser);
|
||||
});
|
||||
|
||||
expect(result.current.users).toEqual(initialUsers);
|
||||
});
|
||||
|
||||
it('should remove user', () => {
|
||||
useUsersStore.setState({
|
||||
users: [
|
||||
{ id: 1, name: 'User 1', email: 'user1@example.com' },
|
||||
{ id: 2, name: 'User 2', email: 'user2@example.com' },
|
||||
{ id: 3, name: 'User 3', email: 'user3@example.com' },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUsersStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeUser(2);
|
||||
});
|
||||
|
||||
expect(result.current.users).toEqual([
|
||||
{ id: 1, name: 'User 1', email: 'user1@example.com' },
|
||||
{ id: 3, name: 'User 3', email: 'user3@example.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle removing non-existent user', () => {
|
||||
const initialUsers = [
|
||||
{ id: 1, name: 'User 1', email: 'user1@example.com' },
|
||||
{ id: 2, name: 'User 2', email: 'user2@example.com' },
|
||||
];
|
||||
|
||||
useUsersStore.setState({
|
||||
users: initialUsers,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUsersStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeUser(999);
|
||||
});
|
||||
|
||||
expect(result.current.users).toEqual(initialUsers);
|
||||
});
|
||||
|
||||
it('should handle removing from empty users', () => {
|
||||
const { result } = renderHook(() => useUsersStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeUser(1);
|
||||
});
|
||||
|
||||
expect(result.current.users).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle fetch with empty results', async () => {
|
||||
api.getUsers.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useUsersStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchUsers();
|
||||
});
|
||||
|
||||
expect(result.current.users).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not modify other users when removing', () => {
|
||||
useUsersStore.setState({
|
||||
users: [
|
||||
{ id: 1, name: 'User 1', email: 'user1@example.com' },
|
||||
{ id: 2, name: 'User 2', email: 'user2@example.com' },
|
||||
{ id: 3, name: 'User 3', email: 'user3@example.com' },
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUsersStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeUser(2);
|
||||
});
|
||||
|
||||
expect(result.current.users[0]).toEqual({ id: 1, name: 'User 1', email: 'user1@example.com' });
|
||||
expect(result.current.users[1]).toEqual({ id: 3, name: 'User 3', email: 'user3@example.com' });
|
||||
});
|
||||
});
|
||||
550
frontend/src/store/__tests__/vodLogos.test.jsx
Normal file
550
frontend/src/store/__tests__/vodLogos.test.jsx
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import useVODLogosStore from '../vodLogos';
|
||||
import api from '../../api';
|
||||
|
||||
vi.mock('../../api');
|
||||
|
||||
describe('useVODLogosStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useVODLogosStore.setState({
|
||||
vodLogos: {},
|
||||
logos: [],
|
||||
isLoading: false,
|
||||
hasLoaded: false,
|
||||
error: null,
|
||||
totalCount: 0,
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
expect(result.current.vodLogos).toEqual({});
|
||||
expect(result.current.logos).toEqual([]);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.hasLoaded).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
expect(result.current.totalCount).toBe(0);
|
||||
expect(result.current.currentPage).toBe(1);
|
||||
expect(result.current.pageSize).toBe(25);
|
||||
});
|
||||
|
||||
it('should set VOD logos with normalized structure', () => {
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
const mockLogos = [
|
||||
{ id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
|
||||
{ id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' },
|
||||
];
|
||||
|
||||
act(() => {
|
||||
result.current.setVODLogos(mockLogos, 2);
|
||||
});
|
||||
|
||||
expect(result.current.vodLogos).toEqual({
|
||||
1: { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
|
||||
2: { id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' },
|
||||
});
|
||||
expect(result.current.totalCount).toBe(2);
|
||||
expect(result.current.hasLoaded).toBe(true);
|
||||
});
|
||||
|
||||
it('should fetch VOD logos successfully with array response', async () => {
|
||||
const mockLogos = [
|
||||
{ id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
|
||||
{ id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' },
|
||||
];
|
||||
|
||||
api.getVODLogos.mockResolvedValue(mockLogos);
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchVODLogos();
|
||||
});
|
||||
|
||||
expect(api.getVODLogos).toHaveBeenCalled();
|
||||
expect(result.current.vodLogos).toEqual({
|
||||
1: { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
|
||||
2: { id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' },
|
||||
});
|
||||
expect(result.current.logos).toEqual(mockLogos);
|
||||
expect(result.current.totalCount).toBe(2);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.hasLoaded).toBe(true);
|
||||
});
|
||||
|
||||
it('should fetch VOD logos successfully with paginated response', async () => {
|
||||
const mockLogos = [
|
||||
{ id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
|
||||
];
|
||||
const mockResponse = {
|
||||
results: mockLogos,
|
||||
count: 10,
|
||||
};
|
||||
|
||||
api.getVODLogos.mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchVODLogos({ page: 1, page_size: 25 });
|
||||
});
|
||||
|
||||
expect(result.current.vodLogos).toEqual({
|
||||
1: { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' },
|
||||
});
|
||||
expect(result.current.logos).toEqual(mockLogos);
|
||||
expect(result.current.totalCount).toBe(10);
|
||||
});
|
||||
|
||||
it('should handle fetch VOD logos error', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
api.getVODLogos.mockRejectedValue(mockError);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.fetchVODLogos();
|
||||
} catch (error) {
|
||||
// Expected error
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Failed to load VOD logos.');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD logos:', mockError);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should set loading state during fetch', async () => {
|
||||
let resolvePromise;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
api.getVODLogos.mockReturnValue(promise);
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
act(() => {
|
||||
result.current.fetchVODLogos();
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.error).toBe(null);
|
||||
|
||||
await act(async () => {
|
||||
resolvePromise([]);
|
||||
await promise;
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('should remove single VOD logo from state', () => {
|
||||
useVODLogosStore.setState({
|
||||
vodLogos: {
|
||||
1: { id: 1, name: 'Logo 1' },
|
||||
2: { id: 2, name: 'Logo 2' },
|
||||
3: { id: 3, name: 'Logo 3' },
|
||||
},
|
||||
logos: [
|
||||
{ id: 1, name: 'Logo 1' },
|
||||
{ id: 2, name: 'Logo 2' },
|
||||
{ id: 3, name: 'Logo 3' },
|
||||
],
|
||||
totalCount: 3,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeVODLogo(2);
|
||||
});
|
||||
|
||||
expect(result.current.vodLogos).toEqual({
|
||||
1: { id: 1, name: 'Logo 1' },
|
||||
3: { id: 3, name: 'Logo 3' },
|
||||
});
|
||||
expect(result.current.logos).toEqual([
|
||||
{ id: 1, name: 'Logo 1' },
|
||||
{ id: 3, name: 'Logo 3' },
|
||||
]);
|
||||
expect(result.current.totalCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should remove multiple VOD logos from state using _removeLogosFromState', () => {
|
||||
useVODLogosStore.setState({
|
||||
vodLogos: {
|
||||
1: { id: 1, name: 'Logo 1' },
|
||||
2: { id: 2, name: 'Logo 2' },
|
||||
3: { id: 3, name: 'Logo 3' },
|
||||
},
|
||||
logos: [
|
||||
{ id: 1, name: 'Logo 1' },
|
||||
{ id: 2, name: 'Logo 2' },
|
||||
{ id: 3, name: 'Logo 3' },
|
||||
],
|
||||
totalCount: 3,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
act(() => {
|
||||
result.current._removeLogosFromState([1, 3]);
|
||||
});
|
||||
|
||||
expect(result.current.vodLogos).toEqual({
|
||||
2: { id: 2, name: 'Logo 2' },
|
||||
});
|
||||
expect(result.current.logos).toEqual([
|
||||
{ id: 2, name: 'Logo 2' },
|
||||
]);
|
||||
expect(result.current.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should delete single VOD logo successfully', async () => {
|
||||
useVODLogosStore.setState({
|
||||
vodLogos: {
|
||||
1: { id: 1, name: 'Logo 1' },
|
||||
2: { id: 2, name: 'Logo 2' },
|
||||
},
|
||||
logos: [
|
||||
{ id: 1, name: 'Logo 1' },
|
||||
{ id: 2, name: 'Logo 2' },
|
||||
],
|
||||
totalCount: 2,
|
||||
});
|
||||
|
||||
api.deleteVODLogo.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteVODLogo(1);
|
||||
});
|
||||
|
||||
expect(api.deleteVODLogo).toHaveBeenCalledWith(1);
|
||||
expect(result.current.vodLogos).toEqual({
|
||||
2: { id: 2, name: 'Logo 2' },
|
||||
});
|
||||
expect(result.current.logos).toEqual([
|
||||
{ id: 2, name: 'Logo 2' },
|
||||
]);
|
||||
expect(result.current.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle delete VOD logo error', async () => {
|
||||
const mockError = new Error('Delete failed');
|
||||
api.deleteVODLogo.mockRejectedValue(mockError);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.deleteVODLogo(1);
|
||||
} catch (error) {
|
||||
expect(error).toBe(mockError);
|
||||
}
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logo:', mockError);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should delete multiple VOD logos successfully', async () => {
|
||||
useVODLogosStore.setState({
|
||||
vodLogos: {
|
||||
1: { id: 1, name: 'Logo 1' },
|
||||
2: { id: 2, name: 'Logo 2' },
|
||||
3: { id: 3, name: 'Logo 3' },
|
||||
},
|
||||
logos: [
|
||||
{ id: 1, name: 'Logo 1' },
|
||||
{ id: 2, name: 'Logo 2' },
|
||||
{ id: 3, name: 'Logo 3' },
|
||||
],
|
||||
totalCount: 3,
|
||||
});
|
||||
|
||||
api.deleteVODLogos.mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteVODLogos([1, 2]);
|
||||
});
|
||||
|
||||
expect(api.deleteVODLogos).toHaveBeenCalledWith([1, 2]);
|
||||
expect(result.current.vodLogos).toEqual({
|
||||
3: { id: 3, name: 'Logo 3' },
|
||||
});
|
||||
expect(result.current.logos).toEqual([
|
||||
{ id: 3, name: 'Logo 3' },
|
||||
]);
|
||||
expect(result.current.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle delete multiple VOD logos error', async () => {
|
||||
const mockError = new Error('Bulk delete failed');
|
||||
api.deleteVODLogos.mockRejectedValue(mockError);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.deleteVODLogos([1, 2]);
|
||||
} catch (error) {
|
||||
expect(error).toBe(mockError);
|
||||
}
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logos:', mockError);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should cleanup unused VOD logos and refresh', async () => {
|
||||
useVODLogosStore.setState({
|
||||
currentPage: 2,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const mockCleanupResult = { deleted: 5 };
|
||||
const mockRefreshedLogos = [{ id: 1, name: 'Logo 1' }];
|
||||
|
||||
api.cleanupUnusedVODLogos.mockResolvedValue(mockCleanupResult);
|
||||
api.getVODLogos.mockResolvedValue(mockRefreshedLogos);
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
const cleanupResult = await result.current.cleanupUnusedVODLogos();
|
||||
expect(cleanupResult).toEqual(mockCleanupResult);
|
||||
});
|
||||
|
||||
expect(api.cleanupUnusedVODLogos).toHaveBeenCalled();
|
||||
expect(api.getVODLogos).toHaveBeenCalledWith({
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
});
|
||||
expect(result.current.logos).toEqual(mockRefreshedLogos);
|
||||
});
|
||||
|
||||
it('should handle cleanup unused VOD logos error', async () => {
|
||||
const mockError = new Error('Cleanup failed');
|
||||
api.cleanupUnusedVODLogos.mockRejectedValue(mockError);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.cleanupUnusedVODLogos();
|
||||
} catch (error) {
|
||||
expect(error).toBe(mockError);
|
||||
}
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to cleanup unused VOD logos:', mockError);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should clear VOD logos', () => {
|
||||
useVODLogosStore.setState({
|
||||
vodLogos: {
|
||||
1: { id: 1, name: 'Logo 1' },
|
||||
},
|
||||
logos: [{ id: 1, name: 'Logo 1' }],
|
||||
hasLoaded: true,
|
||||
totalCount: 1,
|
||||
error: 'Some error',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
act(() => {
|
||||
result.current.clearVODLogos();
|
||||
});
|
||||
|
||||
expect(result.current.vodLogos).toEqual({});
|
||||
expect(result.current.logos).toEqual([]);
|
||||
expect(result.current.hasLoaded).toBe(false);
|
||||
expect(result.current.totalCount).toBe(0);
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle removing non-existent logo', () => {
|
||||
useVODLogosStore.setState({
|
||||
vodLogos: {
|
||||
1: { id: 1, name: 'Logo 1' },
|
||||
},
|
||||
logos: [{ id: 1, name: 'Logo 1' }],
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
act(() => {
|
||||
result.current.removeVODLogo(999);
|
||||
});
|
||||
|
||||
expect(result.current.vodLogos).toEqual({
|
||||
1: { id: 1, name: 'Logo 1' },
|
||||
});
|
||||
expect(result.current.logos).toEqual([{ id: 1, name: 'Logo 1' }]);
|
||||
expect(result.current.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle fetch with empty response', async () => {
|
||||
api.getVODLogos.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchVODLogos();
|
||||
});
|
||||
|
||||
expect(result.current.vodLogos).toEqual({});
|
||||
expect(result.current.logos).toEqual([]);
|
||||
expect(result.current.totalCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should not allow totalCount to go below zero', () => {
|
||||
useVODLogosStore.setState({
|
||||
vodLogos: {
|
||||
1: { id: 1, name: 'Logo 1' },
|
||||
},
|
||||
logos: [{ id: 1, name: 'Logo 1' }],
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
act(() => {
|
||||
result.current._removeLogosFromState([1, 2, 3]); // Remove more than exist
|
||||
});
|
||||
|
||||
expect(result.current.totalCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle _removeLogosFromState with empty array', () => {
|
||||
const initialState = {
|
||||
vodLogos: {
|
||||
1: { id: 1, name: 'Logo 1' },
|
||||
},
|
||||
logos: [{ id: 1, name: 'Logo 1' }],
|
||||
totalCount: 1,
|
||||
};
|
||||
|
||||
useVODLogosStore.setState(initialState);
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
act(() => {
|
||||
result.current._removeLogosFromState([]);
|
||||
});
|
||||
|
||||
expect(result.current.vodLogos).toEqual(initialState.vodLogos);
|
||||
expect(result.current.logos).toEqual(initialState.logos);
|
||||
expect(result.current.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should fetch with custom params', async () => {
|
||||
const mockLogos = [{ id: 1, name: 'Logo 1' }];
|
||||
api.getVODLogos.mockResolvedValue(mockLogos);
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchVODLogos({ search: 'test', page: 2 });
|
||||
});
|
||||
|
||||
expect(api.getVODLogos).toHaveBeenCalledWith({ search: 'test', page: 2 });
|
||||
});
|
||||
|
||||
it('should get unused logos count successfully', async () => {
|
||||
const mockResponse = {
|
||||
results: [{ id: 1, name: 'Unused Logo' }],
|
||||
count: 42,
|
||||
};
|
||||
|
||||
api.getVODLogos.mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
let unusedCount;
|
||||
await act(async () => {
|
||||
unusedCount = await result.current.getUnusedLogosCount();
|
||||
});
|
||||
|
||||
expect(api.getVODLogos).toHaveBeenCalledWith({
|
||||
used: 'false',
|
||||
page_size: 1,
|
||||
});
|
||||
expect(unusedCount).toBe(42);
|
||||
});
|
||||
|
||||
it('should return 0 when unused logos count response has no count', async () => {
|
||||
api.getVODLogos.mockResolvedValue({ results: [] });
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
let unusedCount;
|
||||
await act(async () => {
|
||||
unusedCount = await result.current.getUnusedLogosCount();
|
||||
});
|
||||
|
||||
expect(unusedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle get unused logos count error', async () => {
|
||||
const mockError = new Error('Failed to fetch count');
|
||||
api.getVODLogos.mockRejectedValue(mockError);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.getUnusedLogosCount();
|
||||
} catch (error) {
|
||||
expect(error).toBe(mockError);
|
||||
}
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch unused logos count:', mockError);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should update currentPage and pageSize state', () => {
|
||||
const { result } = renderHook(() => useVODLogosStore());
|
||||
|
||||
act(() => {
|
||||
useVODLogosStore.setState({
|
||||
currentPage: 3,
|
||||
pageSize: 50,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.currentPage).toBe(3);
|
||||
expect(result.current.pageSize).toBe(50);
|
||||
});
|
||||
});
|
||||
195
frontend/src/store/__tests__/warnings.test.jsx
Normal file
195
frontend/src/store/__tests__/warnings.test.jsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import useWarningsStore from '../warnings';
|
||||
|
||||
describe('useWarningsStore', () => {
|
||||
beforeEach(() => {
|
||||
useWarningsStore.setState({
|
||||
suppressedWarnings: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useWarningsStore());
|
||||
|
||||
expect(result.current.suppressedWarnings).toEqual({});
|
||||
});
|
||||
|
||||
it('should suppress a warning', () => {
|
||||
const { result } = renderHook(() => useWarningsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.suppressWarning('deleteStream');
|
||||
});
|
||||
|
||||
expect(result.current.suppressedWarnings).toEqual({
|
||||
deleteStream: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should suppress multiple warnings', () => {
|
||||
const { result } = renderHook(() => useWarningsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.suppressWarning('deleteStream');
|
||||
result.current.suppressWarning('deleteProfile');
|
||||
});
|
||||
|
||||
expect(result.current.suppressedWarnings).toEqual({
|
||||
deleteStream: true,
|
||||
deleteProfile: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should suppress warning with explicit true value', () => {
|
||||
const { result } = renderHook(() => useWarningsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.suppressWarning('deleteStream', true);
|
||||
});
|
||||
|
||||
expect(result.current.suppressedWarnings.deleteStream).toBe(true);
|
||||
});
|
||||
|
||||
it('should unsuppress a warning with explicit false value', () => {
|
||||
const { result } = renderHook(() => useWarningsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.suppressWarning('deleteStream');
|
||||
});
|
||||
|
||||
expect(result.current.suppressedWarnings.deleteStream).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.suppressWarning('deleteStream', false);
|
||||
});
|
||||
|
||||
expect(result.current.suppressedWarnings.deleteStream).toBe(false);
|
||||
});
|
||||
|
||||
it('should check if warning is suppressed', () => {
|
||||
const { result } = renderHook(() => useWarningsStore());
|
||||
|
||||
expect(result.current.isWarningSuppressed('deleteStream')).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.suppressWarning('deleteStream');
|
||||
});
|
||||
|
||||
expect(result.current.isWarningSuppressed('deleteStream')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-existent warning', () => {
|
||||
const { result } = renderHook(() => useWarningsStore());
|
||||
|
||||
expect(result.current.isWarningSuppressed('nonExistentAction')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset all suppressions', () => {
|
||||
const { result } = renderHook(() => useWarningsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.suppressWarning('deleteStream');
|
||||
result.current.suppressWarning('deleteProfile');
|
||||
result.current.suppressWarning('deleteUser');
|
||||
});
|
||||
|
||||
expect(result.current.suppressedWarnings).toEqual({
|
||||
deleteStream: true,
|
||||
deleteProfile: true,
|
||||
deleteUser: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.resetSuppressions();
|
||||
});
|
||||
|
||||
expect(result.current.suppressedWarnings).toEqual({});
|
||||
});
|
||||
|
||||
it('should maintain other suppressions when adding new one', () => {
|
||||
const { result } = renderHook(() => useWarningsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.suppressWarning('deleteStream');
|
||||
result.current.suppressWarning('deleteProfile');
|
||||
});
|
||||
|
||||
expect(result.current.suppressedWarnings).toEqual({
|
||||
deleteStream: true,
|
||||
deleteProfile: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle resetting when already empty', () => {
|
||||
const { result } = renderHook(() => useWarningsStore());
|
||||
|
||||
expect(result.current.suppressedWarnings).toEqual({});
|
||||
|
||||
act(() => {
|
||||
result.current.resetSuppressions();
|
||||
});
|
||||
|
||||
expect(result.current.suppressedWarnings).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle checking suppression after unsuppressing', () => {
|
||||
const { result } = renderHook(() => useWarningsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.suppressWarning('deleteStream', true);
|
||||
});
|
||||
|
||||
expect(result.current.isWarningSuppressed('deleteStream')).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.suppressWarning('deleteStream', false);
|
||||
});
|
||||
|
||||
expect(result.current.isWarningSuppressed('deleteStream')).toBe(false);
|
||||
});
|
||||
|
||||
it('should preserve other warnings when unsuppressing one', () => {
|
||||
const { result } = renderHook(() => useWarningsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.suppressWarning('deleteStream');
|
||||
result.current.suppressWarning('deleteProfile');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.suppressWarning('deleteStream', false);
|
||||
});
|
||||
|
||||
expect(result.current.suppressedWarnings).toEqual({
|
||||
deleteStream: false,
|
||||
deleteProfile: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle suppressing same warning multiple times', () => {
|
||||
const { result } = renderHook(() => useWarningsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.suppressWarning('deleteStream');
|
||||
result.current.suppressWarning('deleteStream');
|
||||
});
|
||||
|
||||
expect(result.current.suppressedWarnings).toEqual({
|
||||
deleteStream: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle different action keys independently', () => {
|
||||
const { result } = renderHook(() => useWarningsStore());
|
||||
|
||||
act(() => {
|
||||
result.current.suppressWarning('action1');
|
||||
result.current.suppressWarning('action2', false);
|
||||
});
|
||||
|
||||
expect(result.current.isWarningSuppressed('action1')).toBe(true);
|
||||
expect(result.current.isWarningSuppressed('action2')).toBe(false);
|
||||
expect(result.current.isWarningSuppressed('action3')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api';
|
||||
import useSettingsStore from './settings';
|
||||
import useChannelsStore from './channels';
|
||||
import usePlaylistsStore from './playlists';
|
||||
|
|
@ -104,20 +103,16 @@ const useAuthStore = create((set, get) => ({
|
|||
|
||||
getToken: async () => {
|
||||
const tokenExpiration = localStorage.getItem('tokenExpiration');
|
||||
let accessToken = null;
|
||||
if (isTokenExpired(tokenExpiration)) {
|
||||
accessToken = await get().getRefreshToken();
|
||||
} else {
|
||||
accessToken = localStorage.getItem('accessToken');
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
return isTokenExpired(tokenExpiration)
|
||||
? await get().getRefreshToken()
|
||||
: localStorage.getItem('accessToken');
|
||||
},
|
||||
|
||||
// Action to login
|
||||
login: async ({ username, password }) => {
|
||||
try {
|
||||
const response = await api.login(username, password);
|
||||
const response = await API.login(username, password);
|
||||
if (response.access) {
|
||||
const expiration = decodeToken(response.access);
|
||||
set({
|
||||
|
|
@ -143,8 +138,8 @@ const useAuthStore = create((set, get) => ({
|
|||
if (!refreshToken) return false;
|
||||
|
||||
try {
|
||||
const data = await api.refreshToken(refreshToken);
|
||||
if (data && data.access) {
|
||||
const data = await API.refreshToken(refreshToken);
|
||||
if (data?.access) {
|
||||
set({
|
||||
accessToken: data.access,
|
||||
tokenExpiration: decodeToken(data.access),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,91 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { showNotification } from '../utils/notificationUtils.js';
|
||||
|
||||
const defaultProfiles = { 0: { id: '0', name: 'All', channels: new Set() } };
|
||||
|
||||
const reduceChannels = (channels) => {
|
||||
const channelsByUUID = {};
|
||||
const channelsByID = channels.reduce((acc, channel) => {
|
||||
acc[channel.id] = channel;
|
||||
channelsByUUID[channel.uuid] = channel.id;
|
||||
return acc;
|
||||
}, {});
|
||||
return { channelsByUUID, channelsByID };
|
||||
}
|
||||
|
||||
const showNotificationIfNewChannel = (currentStats, oldChannels, ch, channelsByUUID, channels) => {
|
||||
if (currentStats.channels) {
|
||||
if (oldChannels[ch.channel_id] === undefined) {
|
||||
// Add null checks to prevent accessing properties on undefined
|
||||
const channelId = channelsByUUID[ch.channel_id];
|
||||
const channel = channelId ? channels[channelId] : null;
|
||||
|
||||
if (channel) {
|
||||
showNotification({
|
||||
title: 'New channel streaming',
|
||||
message: channel.name,
|
||||
color: 'blue.5',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showNotificationIfNewClient = (currentStats, oldClients, client) => {
|
||||
// This check prevents the notifications if streams are active on page load
|
||||
if (currentStats.channels) {
|
||||
if (oldClients[client.client_id] === undefined) {
|
||||
showNotification({
|
||||
title: 'New client started streaming',
|
||||
message: `Client streaming from ${client.ip_address}`,
|
||||
color: 'blue.5',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showNotificationIfChannelStopped = (currentStats, oldChannels, newChannels, channelsByUUID, channels) => {
|
||||
// This check prevents the notifications if streams are active on page load
|
||||
if (currentStats.channels) {
|
||||
for (const uuid in oldChannels) {
|
||||
if (newChannels[uuid] === undefined) {
|
||||
// Add null check for channel name
|
||||
const channelId = channelsByUUID[uuid];
|
||||
const channel = channelId && channels[channelId];
|
||||
|
||||
if (channel) {
|
||||
showNotification({
|
||||
title: 'Channel streaming stopped',
|
||||
message: channel.name,
|
||||
color: 'blue.5',
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Channel streaming stopped',
|
||||
message: `Channel (${uuid})`,
|
||||
color: 'blue.5',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showNotificationIfClientStopped = (currentStats, oldClients, newClients) => {
|
||||
if (currentStats.channels) {
|
||||
for (const clientId in oldClients) {
|
||||
if (newClients[clientId] === undefined) {
|
||||
showNotification({
|
||||
title: 'Client stopped streaming',
|
||||
message: `Client stopped streaming from ${oldClients[clientId].ip_address}`,
|
||||
color: 'blue.5',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const useChannelsStore = create((set, get) => ({
|
||||
channels: [],
|
||||
channelsByUUID: {},
|
||||
|
|
@ -28,12 +110,7 @@ const useChannelsStore = create((set, get) => ({
|
|||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const channels = await api.getChannels();
|
||||
const channelsByUUID = {};
|
||||
const channelsByID = channels.reduce((acc, channel) => {
|
||||
acc[channel.id] = channel;
|
||||
channelsByUUID[channel.uuid] = channel.id;
|
||||
return acc;
|
||||
}, {});
|
||||
const { channelsByUUID, channelsByID } = reduceChannels(channels);
|
||||
set({
|
||||
channels: channelsByID,
|
||||
channelsByUUID,
|
||||
|
|
@ -113,16 +190,7 @@ const useChannelsStore = create((set, get) => ({
|
|||
|
||||
addChannels: (newChannels) =>
|
||||
set((state) => {
|
||||
const channelsByUUID = {};
|
||||
const profileChannels = new Set();
|
||||
|
||||
const channelsByID = newChannels.reduce((acc, channel) => {
|
||||
acc[channel.id] = channel;
|
||||
channelsByUUID[channel.uuid] = channel.id;
|
||||
profileChannels.add(channel.id);
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
const { channelsByUUID, channelsByID } = reduceChannels(newChannels);
|
||||
|
||||
// Don't automatically add to all profiles anymore - let the backend handle profile assignments
|
||||
// Just maintain the existing profile structure
|
||||
|
|
@ -160,12 +228,8 @@ const useChannelsStore = create((set, get) => ({
|
|||
);
|
||||
return;
|
||||
}
|
||||
const channelsByUUID = {};
|
||||
const updatedChannels = channels.reduce((acc, chan) => {
|
||||
channelsByUUID[chan.uuid] = chan.id;
|
||||
acc[chan.id] = chan;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const { channelsByUUID, updatedChannels } = reduceChannels(channels);
|
||||
|
||||
set((state) => ({
|
||||
channels: {
|
||||
|
|
@ -249,12 +313,9 @@ const useChannelsStore = create((set, get) => ({
|
|||
delete updatedProfiles[id];
|
||||
}
|
||||
|
||||
let additionalUpdates = {};
|
||||
if (profileIds.includes(state.selectedProfileId)) {
|
||||
additionalUpdates = {
|
||||
selectedProfileId: '0',
|
||||
};
|
||||
}
|
||||
const additionalUpdates = profileIds.includes(state.selectedProfileId)
|
||||
? { selectedProfileId: '0' }
|
||||
: {};
|
||||
|
||||
return {
|
||||
profiles: updatedProfiles,
|
||||
|
|
@ -296,14 +357,12 @@ const useChannelsStore = create((set, get) => ({
|
|||
channels: currentChannelsSet,
|
||||
};
|
||||
|
||||
const updates = {
|
||||
return {
|
||||
profiles: {
|
||||
...state.profiles,
|
||||
[profileId]: updatedProfile,
|
||||
},
|
||||
};
|
||||
|
||||
return updates;
|
||||
}),
|
||||
|
||||
setChannelsPageSelection: (channelsPageSelection) =>
|
||||
|
|
@ -324,71 +383,24 @@ const useChannelsStore = create((set, get) => ({
|
|||
channelsByUUID,
|
||||
} = state;
|
||||
const newClients = {};
|
||||
|
||||
const newChannels = stats.channels.reduce((acc, ch) => {
|
||||
acc[ch.channel_id] = ch;
|
||||
if (currentStats.channels) {
|
||||
if (oldChannels[ch.channel_id] === undefined) {
|
||||
// Add null checks to prevent accessing properties on undefined
|
||||
const channelId = channelsByUUID[ch.channel_id];
|
||||
const channel = channelId ? channels[channelId] : null;
|
||||
|
||||
if (channel) {
|
||||
notifications.show({
|
||||
title: 'New channel streaming',
|
||||
message: channel.name,
|
||||
color: 'blue.5',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
ch.clients.map((client) => {
|
||||
newClients[client.client_id] = client;
|
||||
// This check prevents the notifications if streams are active on page load
|
||||
if (currentStats.channels) {
|
||||
if (oldClients[client.client_id] === undefined) {
|
||||
notifications.show({
|
||||
title: 'New client started streaming',
|
||||
message: `Client streaming from ${client.ip_address}`,
|
||||
color: 'blue.5',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
// This check prevents the notifications if streams are active on page load
|
||||
if (currentStats.channels) {
|
||||
for (const uuid in oldChannels) {
|
||||
if (newChannels[uuid] === undefined) {
|
||||
// Add null check for channel name
|
||||
const channelId = channelsByUUID[uuid];
|
||||
const channel = channelId && channels[channelId];
|
||||
|
||||
if (channel) {
|
||||
notifications.show({
|
||||
title: 'Channel streaming stopped',
|
||||
message: channel.name,
|
||||
color: 'blue.5',
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Channel streaming stopped',
|
||||
message: `Channel (${uuid})`,
|
||||
color: 'blue.5',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const clientId in oldClients) {
|
||||
if (newClients[clientId] === undefined) {
|
||||
notifications.show({
|
||||
title: 'Client stopped streaming',
|
||||
message: `Client stopped streaming from ${oldClients[clientId].ip_address}`,
|
||||
color: 'blue.5',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
stats.channels.forEach(ch => {
|
||||
showNotificationIfNewChannel(currentStats, oldChannels, ch, channelsByUUID, channels);
|
||||
|
||||
ch.clients.forEach(client => {
|
||||
newClients[client.client_id] = client;
|
||||
showNotificationIfNewClient(currentStats, oldClients, client);
|
||||
});
|
||||
});
|
||||
|
||||
showNotificationIfChannelStopped(currentStats, oldChannels, newChannels, channelsByUUID, channels);
|
||||
showNotificationIfClientStopped(currentStats, oldClients, newClients);
|
||||
|
||||
return {
|
||||
stats,
|
||||
activeChannels: newChannels,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import API from '../api';
|
||||
|
||||
const useChannelsTableStore = create((set, get) => ({
|
||||
channels: [],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api';
|
||||
|
||||
const determineEPGStatus = (data, currentEpg) => {
|
||||
if (data.status) return data.status;
|
||||
if (data.action === 'downloading') return 'fetching';
|
||||
if (data.action === 'parsing_channels' || data.action === 'parsing_programs') return 'parsing';
|
||||
if (data.progress === 100) return 'success';
|
||||
return currentEpg?.status || 'idle';
|
||||
};
|
||||
|
||||
const useEPGsStore = create((set) => ({
|
||||
epgs: {},
|
||||
tvgs: [],
|
||||
|
|
@ -92,7 +100,7 @@ const useEPGsStore = create((set) => ({
|
|||
}
|
||||
|
||||
// Create a new refreshProgress object that includes the current update
|
||||
const newRefreshProgress = {
|
||||
const refreshProgress = {
|
||||
...state.refreshProgress,
|
||||
[data.source]: {
|
||||
action: data.action,
|
||||
|
|
@ -106,45 +114,30 @@ const useEPGsStore = create((set) => ({
|
|||
|
||||
// Set the EPG source status based on the update
|
||||
// First prioritize explicit status values from the backend
|
||||
const sourceStatus = data.status
|
||||
? data.status // Use explicit status if provided
|
||||
: data.action === 'downloading'
|
||||
? 'fetching'
|
||||
: data.action === 'parsing_channels' ||
|
||||
data.action === 'parsing_programs'
|
||||
? 'parsing'
|
||||
: data.progress === 100
|
||||
? 'success' // Mark as success when progress is 100%
|
||||
: state.epgs[data.source]?.status || 'idle';
|
||||
const status = determineEPGStatus(data, state.epgs[data.source]);
|
||||
|
||||
// Only update epgs object if status or last_message actually changed
|
||||
// This prevents unnecessary re-renders on every progress update
|
||||
const currentEpg = state.epgs[data.source];
|
||||
const newLastMessage =
|
||||
data.status === 'error'
|
||||
? data.error || 'Unknown error'
|
||||
: currentEpg?.last_message;
|
||||
const lastMessage = data.status === 'error'
|
||||
? (data.error || 'Unknown error')
|
||||
: state.epgs[data.source]?.last_message;
|
||||
|
||||
let newEpgs = state.epgs;
|
||||
if (
|
||||
currentEpg &&
|
||||
(currentEpg.status !== sourceStatus ||
|
||||
currentEpg.last_message !== newLastMessage)
|
||||
) {
|
||||
newEpgs = {
|
||||
const currentEpg = state.epgs[data.source];
|
||||
const shouldUpdateEpg = currentEpg &&
|
||||
(currentEpg.status !== status || currentEpg.last_message !== lastMessage);
|
||||
|
||||
const epgs = shouldUpdateEpg
|
||||
? {
|
||||
...state.epgs,
|
||||
[data.source]: {
|
||||
...currentEpg,
|
||||
status: sourceStatus,
|
||||
last_message: newLastMessage,
|
||||
status,
|
||||
last_message: lastMessage,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
: state.epgs;
|
||||
|
||||
return {
|
||||
refreshProgress: newRefreshProgress,
|
||||
epgs: newEpgs,
|
||||
};
|
||||
return { refreshProgress, epgs };
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api';
|
||||
|
||||
const getLogosArray = (response) => {
|
||||
return Array.isArray(response)
|
||||
? response
|
||||
: response.results || [];
|
||||
}
|
||||
|
||||
const useLogosStore = create((set, get) => ({
|
||||
logos: {},
|
||||
channelLogos: {}, // Separate cache for channel forms to avoid reloading
|
||||
|
|
@ -24,13 +30,12 @@ const useLogosStore = create((set, get) => ({
|
|||
|
||||
// Add to channelLogos if the user has loaded channel logos
|
||||
// This means they're using channel forms and the new logo should be available there
|
||||
let newChannelLogos = state.channelLogos;
|
||||
if (state.hasLoadedChannelLogos) {
|
||||
newChannelLogos = {
|
||||
...state.channelLogos,
|
||||
[newLogo.id]: { ...newLogo },
|
||||
};
|
||||
}
|
||||
const newChannelLogos = state.hasLoadedChannelLogos
|
||||
? {
|
||||
...state.channelLogos,
|
||||
[newLogo.id]: { ...newLogo },
|
||||
}
|
||||
: state.channelLogos;
|
||||
|
||||
return {
|
||||
logos: newLogos,
|
||||
|
|
@ -67,15 +72,12 @@ const useLogosStore = create((set, get) => ({
|
|||
|
||||
// Smart loading methods
|
||||
fetchLogos: async (pageSize = 100) => {
|
||||
// Don't fetch if logo fetching is not allowed yet
|
||||
if (!get().allowLogoFetching) return [];
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await api.getLogos({ page_size: pageSize });
|
||||
|
||||
// Handle both paginated and non-paginated responses
|
||||
const logos = Array.isArray(response) ? response : response.results || [];
|
||||
const logos = getLogosArray(response);
|
||||
|
||||
set({
|
||||
logos: logos.reduce((acc, logo) => {
|
||||
|
|
@ -109,9 +111,7 @@ const useLogosStore = create((set, get) => ({
|
|||
const response = await api.getLogos({ no_pagination: 'true' });
|
||||
|
||||
// Handle both paginated and non-paginated responses
|
||||
const logosArray = Array.isArray(response)
|
||||
? response
|
||||
: response.results || [];
|
||||
const logosArray = getLogosArray(response);
|
||||
|
||||
set({
|
||||
logos: logosArray.reduce((acc, logo) => {
|
||||
|
|
@ -139,7 +139,7 @@ const useLogosStore = create((set, get) => ({
|
|||
});
|
||||
|
||||
// Handle both paginated and non-paginated responses
|
||||
const logos = Array.isArray(response) ? response : response.results || [];
|
||||
const logos = getLogosArray(response);
|
||||
|
||||
set((state) => ({
|
||||
logos: {
|
||||
|
|
@ -190,7 +190,7 @@ const useLogosStore = create((set, get) => ({
|
|||
const response = await api.getLogosByIds(missingIds);
|
||||
|
||||
// Handle both paginated and non-paginated responses
|
||||
const logos = Array.isArray(response) ? response : response.results || [];
|
||||
const logos = getLogosArray(response);
|
||||
|
||||
set((state) => ({
|
||||
logos: {
|
||||
|
|
@ -262,9 +262,7 @@ const useLogosStore = create((set, get) => ({
|
|||
try {
|
||||
// Use the API directly to avoid interfering with the main isLoading state
|
||||
const response = await api.getLogos({ no_pagination: 'true' });
|
||||
const logosArray = Array.isArray(response)
|
||||
? response
|
||||
: response.results || [];
|
||||
const logosArray = getLogosArray(response);
|
||||
|
||||
// Process logos in smaller chunks to avoid blocking the main thread
|
||||
const chunkSize = 1000;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,124 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api';
|
||||
|
||||
const getFetchContentParams = (state) => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', state.currentPage);
|
||||
params.append('page_size', state.pageSize);
|
||||
|
||||
if (state.filters.search) {
|
||||
params.append('search', state.filters.search);
|
||||
}
|
||||
|
||||
if (state.filters.category) {
|
||||
params.append('category', state.filters.category);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
const getMovieDetails = (response, movieId) => {
|
||||
return {
|
||||
id: response.id || movieId,
|
||||
name: response.name || '',
|
||||
description: response.description || '',
|
||||
year: response.year || null,
|
||||
genre: response.genre || '',
|
||||
rating: response.rating || '',
|
||||
duration_secs: response.duration_secs || null,
|
||||
stream_url: response.url || '',
|
||||
logo: response.logo_url || null,
|
||||
type: 'movie',
|
||||
director: response.director || '',
|
||||
actors: response.actors || '',
|
||||
country: response.country || '',
|
||||
tmdb_id: response.tmdb_id || '',
|
||||
imdb_id: response.imdb_id || '',
|
||||
m3u_account: response.m3u_account || '',
|
||||
};
|
||||
}
|
||||
|
||||
const getMovieDetailsWithProvider = (response, movieId) => {
|
||||
return {
|
||||
id: response.id || movieId,
|
||||
name: response.name || '',
|
||||
description: response.description || response.plot || '',
|
||||
year: response.year || null,
|
||||
genre: response.genre || '',
|
||||
rating: response.rating || '',
|
||||
duration_secs: response.duration_secs || null,
|
||||
stream_url: response.stream_url || '',
|
||||
logo: response.logo || response.cover || null,
|
||||
type: 'movie',
|
||||
director: response.director || '',
|
||||
actors: response.actors || response.cast || '',
|
||||
country: response.country || '',
|
||||
tmdb_id: response.tmdb_id || '',
|
||||
youtube_trailer: response.youtube_trailer || '',
|
||||
// Additional provider fields
|
||||
backdrop_path: response.backdrop_path || [],
|
||||
release_date: response.release_date || response.releasedate || '',
|
||||
movie_image: response.movie_image || null,
|
||||
o_name: response.o_name || '',
|
||||
age: response.age || '',
|
||||
episode_run_time: response.episode_run_time || null,
|
||||
bitrate: response.bitrate || 0,
|
||||
video: response.video || {},
|
||||
audio: response.audio || {},
|
||||
};
|
||||
}
|
||||
|
||||
const getSeriesDetails = (response, seriesId) => {
|
||||
return {
|
||||
id: response.id || seriesId,
|
||||
name: response.name || '',
|
||||
description: response.description || response.custom_properties?.plot || '',
|
||||
year: response.year || null,
|
||||
genre: response.genre || '',
|
||||
rating: response.rating || '',
|
||||
logo: response.cover || null,
|
||||
type: 'series',
|
||||
director: response.custom_properties?.director || '',
|
||||
cast: response.custom_properties?.cast || '',
|
||||
country: response.country || '',
|
||||
tmdb_id: response.tmdb_id || '',
|
||||
imdb_id: response.imdb_id || '',
|
||||
episode_count: response.episode_count || 0,
|
||||
// Additional provider fields
|
||||
backdrop_path: response.custom_properties?.backdrop_path || [],
|
||||
release_date: response.release_date || '',
|
||||
series_image: response.series_image || null,
|
||||
o_name: response.o_name || '',
|
||||
age: response.age || '',
|
||||
m3u_account: response.m3u_account || '',
|
||||
youtube_trailer: response.custom_properties?.youtube_trailer || '',
|
||||
};
|
||||
}
|
||||
|
||||
const getEpisodeDetails = (episode, seasonNumber, seriesInfo) => {
|
||||
return {
|
||||
id: episode.id,
|
||||
stream_id: episode.id,
|
||||
name: episode.title || '',
|
||||
description: episode.plot || '',
|
||||
season_number: parseInt(seasonNumber) || 0,
|
||||
episode_number: episode.episode_number || 0,
|
||||
duration_secs: episode.duration_secs || null,
|
||||
rating: episode.rating || '',
|
||||
container_extension: episode.container_extension || '',
|
||||
series: {
|
||||
id: seriesInfo.id,
|
||||
name: seriesInfo.name,
|
||||
},
|
||||
type: 'episode',
|
||||
uuid: episode.uuid,
|
||||
logo: episode.movie_image ? { url: episode.movie_image } : null,
|
||||
air_date: episode.air_date || null,
|
||||
movie_image: episode.movie_image || null,
|
||||
tmdb_id: episode.tmdb_id || '',
|
||||
imdb_id: episode.imdb_id || '',
|
||||
};
|
||||
}
|
||||
|
||||
const useVODStore = create((set, get) => ({
|
||||
content: {}, // Store for individual content details (when fetching movie/series details)
|
||||
currentPageContent: [], // Store the current page's results
|
||||
|
|
@ -39,17 +157,7 @@ const useVODStore = create((set, get) => ({
|
|||
set({ loading: true, error: null });
|
||||
const state = get();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', state.currentPage);
|
||||
params.append('page_size', state.pageSize);
|
||||
|
||||
if (state.filters.search) {
|
||||
params.append('search', state.filters.search);
|
||||
}
|
||||
|
||||
if (state.filters.category) {
|
||||
params.append('category', state.filters.category);
|
||||
}
|
||||
const params = getFetchContentParams(state);
|
||||
|
||||
let allResults = [];
|
||||
let totalCount = 0;
|
||||
|
|
@ -58,12 +166,14 @@ const useVODStore = create((set, get) => ({
|
|||
// Fetch only movies
|
||||
const response = await api.getMovies(params);
|
||||
const results = response.results || response;
|
||||
|
||||
allResults = results.map((item) => ({ ...item, contentType: 'movie' }));
|
||||
totalCount = response.count || results.length;
|
||||
} else if (state.filters.type === 'series') {
|
||||
// Fetch only series
|
||||
const response = await api.getSeries(params);
|
||||
const results = response.results || response;
|
||||
|
||||
allResults = results.map((item) => ({
|
||||
...item,
|
||||
contentType: 'series',
|
||||
|
|
@ -73,16 +183,9 @@ const useVODStore = create((set, get) => ({
|
|||
// Use the new unified backend endpoint for 'all' view
|
||||
const response = await api.getAllContent(params);
|
||||
console.log('getAllContent response:', response);
|
||||
console.log('response type:', typeof response);
|
||||
console.log(
|
||||
'response keys:',
|
||||
response ? Object.keys(response) : 'no response'
|
||||
);
|
||||
|
||||
const results = response.results || response;
|
||||
console.log('results:', results);
|
||||
console.log('results type:', typeof results);
|
||||
console.log('results is array:', Array.isArray(results));
|
||||
|
||||
// Check if results is actually an array before calling map
|
||||
if (!Array.isArray(results)) {
|
||||
|
|
@ -110,54 +213,13 @@ const useVODStore = create((set, get) => ({
|
|||
}
|
||||
},
|
||||
|
||||
fetchSeriesEpisodes: async (seriesId) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await api.getSeriesEpisodes(seriesId);
|
||||
|
||||
set((state) => ({
|
||||
episodes: {
|
||||
...state.episodes,
|
||||
...response.reduce((acc, episode) => {
|
||||
acc[episode.id] = episode;
|
||||
return acc;
|
||||
}, {}),
|
||||
},
|
||||
loading: false,
|
||||
}));
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch series episodes:', error);
|
||||
set({ error: 'Failed to load episodes.', loading: false });
|
||||
throw error; // Re-throw to allow calling component to handle
|
||||
}
|
||||
},
|
||||
|
||||
fetchMovieDetails: async (movieId) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await api.getMovieDetails(movieId);
|
||||
|
||||
// Transform the response data to match our expected format
|
||||
const movieDetails = {
|
||||
id: response.id || movieId,
|
||||
name: response.name || '',
|
||||
description: response.description || '',
|
||||
year: response.year || null,
|
||||
genre: response.genre || '',
|
||||
rating: response.rating || '',
|
||||
duration_secs: response.duration_secs || null,
|
||||
stream_url: response.url || '',
|
||||
logo: response.logo_url || null,
|
||||
type: 'movie',
|
||||
director: response.director || '',
|
||||
actors: response.actors || '',
|
||||
country: response.country || '',
|
||||
tmdb_id: response.tmdb_id || '',
|
||||
imdb_id: response.imdb_id || '',
|
||||
m3u_account: response.m3u_account || '',
|
||||
};
|
||||
const movieDetails = getMovieDetails(response, movieId);
|
||||
console.log('Fetched Movie Details:', movieDetails);
|
||||
set((state) => ({
|
||||
content: {
|
||||
|
|
@ -184,33 +246,7 @@ const useVODStore = create((set, get) => ({
|
|||
const response = await api.getMovieProviderInfo(movieId);
|
||||
|
||||
// Transform the response data to match our expected format
|
||||
const movieDetails = {
|
||||
id: response.id || movieId,
|
||||
name: response.name || '',
|
||||
description: response.description || response.plot || '',
|
||||
year: response.year || null,
|
||||
genre: response.genre || '',
|
||||
rating: response.rating || '',
|
||||
duration_secs: response.duration_secs || null,
|
||||
stream_url: response.stream_url || '',
|
||||
logo: response.logo || response.cover || null,
|
||||
type: 'movie',
|
||||
director: response.director || '',
|
||||
actors: response.actors || response.cast || '',
|
||||
country: response.country || '',
|
||||
tmdb_id: response.tmdb_id || '',
|
||||
youtube_trailer: response.youtube_trailer || '',
|
||||
// Additional provider fields
|
||||
backdrop_path: response.backdrop_path || [],
|
||||
release_date: response.release_date || response.releasedate || '',
|
||||
movie_image: response.movie_image || null,
|
||||
o_name: response.o_name || '',
|
||||
age: response.age || '',
|
||||
episode_run_time: response.episode_run_time || null,
|
||||
bitrate: response.bitrate || 0,
|
||||
video: response.video || {},
|
||||
audio: response.audio || {},
|
||||
};
|
||||
const movieDetails = getMovieDetailsWithProvider(response, movieId);
|
||||
|
||||
set({ loading: false }); // Only update loading state
|
||||
|
||||
|
|
@ -316,31 +352,7 @@ const useVODStore = create((set, get) => ({
|
|||
const response = await api.getSeriesInfo(seriesId);
|
||||
|
||||
// Transform the response data to match our expected format
|
||||
const seriesInfo = {
|
||||
id: response.id || seriesId,
|
||||
name: response.name || '',
|
||||
description:
|
||||
response.description || response.custom_properties?.plot || '',
|
||||
year: response.year || null,
|
||||
genre: response.genre || '',
|
||||
rating: response.rating || '',
|
||||
logo: response.cover || null,
|
||||
type: 'series',
|
||||
director: response.custom_properties?.director || '',
|
||||
cast: response.custom_properties?.cast || '',
|
||||
country: response.country || '',
|
||||
tmdb_id: response.tmdb_id || '',
|
||||
imdb_id: response.imdb_id || '',
|
||||
episode_count: response.episode_count || 0,
|
||||
// Additional provider fields
|
||||
backdrop_path: response.custom_properties?.backdrop_path || [],
|
||||
release_date: response.release_date || '',
|
||||
series_image: response.series_image || null,
|
||||
o_name: response.o_name || '',
|
||||
age: response.age || '',
|
||||
m3u_account: response.m3u_account || '',
|
||||
youtube_trailer: response.custom_properties?.youtube_trailer || '',
|
||||
};
|
||||
const seriesInfo = getSeriesDetails(response, seriesId);
|
||||
|
||||
let episodesData = {};
|
||||
|
||||
|
|
@ -349,29 +361,11 @@ const useVODStore = create((set, get) => ({
|
|||
Object.entries(response.episodes).forEach(
|
||||
([seasonNumber, seasonEpisodes]) => {
|
||||
seasonEpisodes.forEach((episode) => {
|
||||
const episodeData = {
|
||||
id: episode.id,
|
||||
stream_id: episode.id,
|
||||
name: episode.title || '',
|
||||
description: episode.plot || '',
|
||||
season_number: parseInt(seasonNumber) || 0,
|
||||
episode_number: episode.episode_number || 0,
|
||||
duration_secs: episode.duration_secs || null,
|
||||
rating: episode.rating || '',
|
||||
container_extension: episode.container_extension || '',
|
||||
series: {
|
||||
id: seriesInfo.id,
|
||||
name: seriesInfo.name,
|
||||
},
|
||||
type: 'episode',
|
||||
uuid: episode.uuid,
|
||||
logo: episode.movie_image ? { url: episode.movie_image } : null,
|
||||
air_date: episode.air_date || null,
|
||||
movie_image: episode.movie_image || null,
|
||||
tmdb_id: episode.tmdb_id || '',
|
||||
imdb_id: episode.imdb_id || '',
|
||||
};
|
||||
episodesData[episode.id] = episodeData;
|
||||
episodesData[episode.id] = getEpisodeDetails(
|
||||
episode,
|
||||
seasonNumber,
|
||||
seriesInfo
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,29 @@ const useVODLogosStore = create((set) => ({
|
|||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
|
||||
_removeLogosFromState: (logoIds) => {
|
||||
set((state) => {
|
||||
const newVODLogos = { ...state.vodLogos };
|
||||
const logoIdSet = new Set(Array.isArray(logoIds) ? logoIds : [logoIds]);
|
||||
|
||||
let removedCount = 0;
|
||||
logoIdSet.forEach((id) => {
|
||||
if (newVODLogos[id]) {
|
||||
delete newVODLogos[id];
|
||||
removedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const newLogos = state.logos.filter((logo) => !logoIdSet.has(logo.id));
|
||||
|
||||
return {
|
||||
vodLogos: newVODLogos,
|
||||
logos: newLogos,
|
||||
totalCount: Math.max(0, state.totalCount - removedCount),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setVODLogos: (logos, totalCount = 0) => {
|
||||
set({
|
||||
vodLogos: logos.reduce((acc, logo) => {
|
||||
|
|
@ -22,15 +45,10 @@ const useVODLogosStore = create((set) => ({
|
|||
});
|
||||
},
|
||||
|
||||
removeVODLogo: (logoId) =>
|
||||
set((state) => {
|
||||
const newVODLogos = { ...state.vodLogos };
|
||||
delete newVODLogos[logoId];
|
||||
return {
|
||||
vodLogos: newVODLogos,
|
||||
totalCount: Math.max(0, state.totalCount - 1),
|
||||
};
|
||||
}),
|
||||
removeVODLogo: (logoId) => {
|
||||
const state = useVODLogosStore.getState();
|
||||
state._removeLogosFromState(logoId);
|
||||
},
|
||||
|
||||
fetchVODLogos: async (params = {}) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
|
@ -62,16 +80,8 @@ const useVODLogosStore = create((set) => ({
|
|||
deleteVODLogo: async (logoId) => {
|
||||
try {
|
||||
await api.deleteVODLogo(logoId);
|
||||
set((state) => {
|
||||
const newVODLogos = { ...state.vodLogos };
|
||||
delete newVODLogos[logoId];
|
||||
const newLogos = state.logos.filter((logo) => logo.id !== logoId);
|
||||
return {
|
||||
vodLogos: newVODLogos,
|
||||
logos: newLogos,
|
||||
totalCount: Math.max(0, state.totalCount - 1),
|
||||
};
|
||||
});
|
||||
const state = useVODLogosStore.getState();
|
||||
state._removeLogosFromState(logoId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete VOD logo:', error);
|
||||
throw error;
|
||||
|
|
@ -81,17 +91,8 @@ const useVODLogosStore = create((set) => ({
|
|||
deleteVODLogos: async (logoIds) => {
|
||||
try {
|
||||
await api.deleteVODLogos(logoIds);
|
||||
set((state) => {
|
||||
const newVODLogos = { ...state.vodLogos };
|
||||
logoIds.forEach((id) => delete newVODLogos[id]);
|
||||
const logoIdSet = new Set(logoIds);
|
||||
const newLogos = state.logos.filter((logo) => !logoIdSet.has(logo.id));
|
||||
return {
|
||||
vodLogos: newVODLogos,
|
||||
logos: newLogos,
|
||||
totalCount: Math.max(0, state.totalCount - logoIds.length),
|
||||
};
|
||||
});
|
||||
const state = useVODLogosStore.getState();
|
||||
state._removeLogosFromState(logoIds);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete VOD logos:', error);
|
||||
throw error;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue