From bd4c9e1d9505bf817daa66a50edeb5ec6b93552b Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:39:04 -0800 Subject: [PATCH] Added unit tests --- .../hooks/__tests__/useLocalStorage.test.jsx | 110 +++ .../hooks/__tests__/useSmartLogos.test.jsx | 297 ++++++ frontend/src/store/__tests__/auth.test.jsx | 479 +++++++++ .../src/store/__tests__/channels.test.jsx | 442 +++++++++ .../store/__tests__/channelsTable.test.jsx | 295 ++++++ frontend/src/store/__tests__/epgs.test.jsx | 687 +++++++++++++ frontend/src/store/__tests__/logos.test.jsx | 916 ++++++++++++++++++ .../src/store/__tests__/playlists.test.jsx | 266 +++++ frontend/src/store/__tests__/plugins.test.jsx | 234 +++++ .../src/store/__tests__/settings.test.jsx | 204 ++++ .../store/__tests__/streamProfiles.test.jsx | 265 +++++ frontend/src/store/__tests__/streams.test.jsx | 289 ++++++ .../src/store/__tests__/useVODStore.test.jsx | 755 +++++++++++++++ .../store/__tests__/useVideoStore.test.jsx | 182 ++++ .../src/store/__tests__/userAgents.test.jsx | 277 ++++++ frontend/src/store/__tests__/users.test.jsx | 258 +++++ .../src/store/__tests__/vodLogos.test.jsx | 480 +++++++++ .../src/store/__tests__/warnings.test.jsx | 195 ++++ 18 files changed, 6631 insertions(+) create mode 100644 frontend/src/hooks/__tests__/useLocalStorage.test.jsx create mode 100644 frontend/src/hooks/__tests__/useSmartLogos.test.jsx create mode 100644 frontend/src/store/__tests__/auth.test.jsx create mode 100644 frontend/src/store/__tests__/channels.test.jsx create mode 100644 frontend/src/store/__tests__/channelsTable.test.jsx create mode 100644 frontend/src/store/__tests__/epgs.test.jsx create mode 100644 frontend/src/store/__tests__/logos.test.jsx create mode 100644 frontend/src/store/__tests__/playlists.test.jsx create mode 100644 frontend/src/store/__tests__/plugins.test.jsx create mode 100644 frontend/src/store/__tests__/settings.test.jsx create mode 100644 frontend/src/store/__tests__/streamProfiles.test.jsx create mode 100644 frontend/src/store/__tests__/streams.test.jsx create mode 100644 frontend/src/store/__tests__/useVODStore.test.jsx create mode 100644 frontend/src/store/__tests__/useVideoStore.test.jsx create mode 100644 frontend/src/store/__tests__/userAgents.test.jsx create mode 100644 frontend/src/store/__tests__/users.test.jsx create mode 100644 frontend/src/store/__tests__/vodLogos.test.jsx create mode 100644 frontend/src/store/__tests__/warnings.test.jsx diff --git a/frontend/src/hooks/__tests__/useLocalStorage.test.jsx b/frontend/src/hooks/__tests__/useLocalStorage.test.jsx new file mode 100644 index 00000000..8be21e28 --- /dev/null +++ b/frontend/src/hooks/__tests__/useLocalStorage.test.jsx @@ -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(); + }); +}); diff --git a/frontend/src/hooks/__tests__/useSmartLogos.test.jsx b/frontend/src/hooks/__tests__/useSmartLogos.test.jsx new file mode 100644 index 00000000..d8ef2711 --- /dev/null +++ b/frontend/src/hooks/__tests__/useSmartLogos.test.jsx @@ -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); + }); + }); + }); +}); diff --git a/frontend/src/store/__tests__/auth.test.jsx b/frontend/src/store/__tests__/auth.test.jsx new file mode 100644 index 00000000..4ae99e4b --- /dev/null +++ b/frontend/src/store/__tests__/auth.test.jsx @@ -0,0 +1,479 @@ +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; + +// Mock console methods +global.console.error = vi.fn(); + +// 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(console.error).toHaveBeenCalledWith('Login failed:', expect.any(Error)); + }); + }); + + 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(result.current.isAuthenticated).toBe(true); + 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(console.error).toHaveBeenCalledWith('Token refresh failed:', expect.any(Error)); + 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(console.error).toHaveBeenCalledWith('Logout API call failed:', expect.any(Error)); + 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(); + }); + + expect(console.error).toHaveBeenCalledWith('Error initializing data:', expect.any(Error)); + }); + }); + + 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); + }); + }); +}); diff --git a/frontend/src/store/__tests__/channels.test.jsx b/frontend/src/store/__tests__/channels.test.jsx new file mode 100644 index 00000000..6b452549 --- /dev/null +++ b/frontend/src/store/__tests__/channels.test.jsx @@ -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); + }); + }); +}); diff --git a/frontend/src/store/__tests__/channelsTable.test.jsx b/frontend/src/store/__tests__/channelsTable.test.jsx new file mode 100644 index 00000000..171f0672 --- /dev/null +++ b/frontend/src/store/__tests__/channelsTable.test.jsx @@ -0,0 +1,295 @@ +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([]); + }); + }); +}); diff --git a/frontend/src/store/__tests__/epgs.test.jsx b/frontend/src/store/__tests__/epgs.test.jsx new file mode 100644 index 00000000..05f136d3 --- /dev/null +++ b/frontend/src/store/__tests__/epgs.test.jsx @@ -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'); + }); + }); +}); diff --git a/frontend/src/store/__tests__/logos.test.jsx b/frontend/src/store/__tests__/logos.test.jsx new file mode 100644 index 00000000..59ac4283 --- /dev/null +++ b/frontend/src/store/__tests__/logos.test.jsx @@ -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); + }); + }); + }); +}); diff --git a/frontend/src/store/__tests__/playlists.test.jsx b/frontend/src/store/__tests__/playlists.test.jsx new file mode 100644 index 00000000..2d49791e --- /dev/null +++ b/frontend/src/store/__tests__/playlists.test.jsx @@ -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'); + }); +}); diff --git a/frontend/src/store/__tests__/plugins.test.jsx b/frontend/src/store/__tests__/plugins.test.jsx new file mode 100644 index 00000000..2db48095 --- /dev/null +++ b/frontend/src/store/__tests__/plugins.test.jsx @@ -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(); + }); +}); diff --git a/frontend/src/store/__tests__/settings.test.jsx b/frontend/src/store/__tests__/settings.test.jsx new file mode 100644 index 00000000..59da4437 --- /dev/null +++ b/frontend/src/store/__tests__/settings.test.jsx @@ -0,0 +1,204 @@ +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', + }, + 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({}); + }); +}); diff --git a/frontend/src/store/__tests__/streamProfiles.test.jsx b/frontend/src/store/__tests__/streamProfiles.test.jsx new file mode 100644 index 00000000..30380664 --- /dev/null +++ b/frontend/src/store/__tests__/streamProfiles.test.jsx @@ -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); + }); +}); diff --git a/frontend/src/store/__tests__/streams.test.jsx b/frontend/src/store/__tests__/streams.test.jsx new file mode 100644 index 00000000..e6ed3952 --- /dev/null +++ b/frontend/src/store/__tests__/streams.test.jsx @@ -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); + }); +}); diff --git a/frontend/src/store/__tests__/useVODStore.test.jsx b/frontend/src/store/__tests__/useVODStore.test.jsx new file mode 100644 index 00000000..5734e5a2 --- /dev/null +++ b/frontend/src/store/__tests__/useVODStore.test.jsx @@ -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); + }); +}); diff --git a/frontend/src/store/__tests__/useVideoStore.test.jsx b/frontend/src/store/__tests__/useVideoStore.test.jsx new file mode 100644 index 00000000..b1f87d6d --- /dev/null +++ b/frontend/src/store/__tests__/useVideoStore.test.jsx @@ -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); + }); +}); diff --git a/frontend/src/store/__tests__/userAgents.test.jsx b/frontend/src/store/__tests__/userAgents.test.jsx new file mode 100644 index 00000000..8e804bec --- /dev/null +++ b/frontend/src/store/__tests__/userAgents.test.jsx @@ -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([]); + }); +}); diff --git a/frontend/src/store/__tests__/users.test.jsx b/frontend/src/store/__tests__/users.test.jsx new file mode 100644 index 00000000..58b343ba --- /dev/null +++ b/frontend/src/store/__tests__/users.test.jsx @@ -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' }); + }); +}); diff --git a/frontend/src/store/__tests__/vodLogos.test.jsx b/frontend/src/store/__tests__/vodLogos.test.jsx new file mode 100644 index 00000000..6ac3cc44 --- /dev/null +++ b/frontend/src/store/__tests__/vodLogos.test.jsx @@ -0,0 +1,480 @@ +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 }); + }); +}); diff --git a/frontend/src/store/__tests__/warnings.test.jsx b/frontend/src/store/__tests__/warnings.test.jsx new file mode 100644 index 00000000..500fbd8c --- /dev/null +++ b/frontend/src/store/__tests__/warnings.test.jsx @@ -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); + }); +});