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 1/5] 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); + }); +}); From cf1625fab91568beb3cadcdd95f852c530c18a4f Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:41:25 -0800 Subject: [PATCH 2/5] Slight refactoring --- frontend/src/hooks/useLocalStorage.jsx | 2 +- frontend/src/store/auth.jsx | 17 +- frontend/src/store/channels.jsx | 196 ++++++++++---------- frontend/src/store/channelsTable.jsx | 3 - frontend/src/store/epgs.jsx | 55 +++--- frontend/src/store/logos.jsx | 33 ++-- frontend/src/store/useVODStore.jsx | 240 +++++++++++++------------ frontend/src/store/vodLogos.jsx | 61 +++---- 8 files changed, 312 insertions(+), 295 deletions(-) diff --git a/frontend/src/hooks/useLocalStorage.jsx b/frontend/src/hooks/useLocalStorage.jsx index 450c55df..d559d960 100644 --- a/frontend/src/hooks/useLocalStorage.jsx +++ b/frontend/src/hooks/useLocalStorage.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; const useLocalStorage = (key, defaultValue) => { const localKey = key; diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx index 8fe943b7..e97e7e53 100644 --- a/frontend/src/store/auth.jsx +++ b/frontend/src/store/auth.jsx @@ -1,5 +1,4 @@ import { create } from 'zustand'; -import api from '../api'; import useSettingsStore from './settings'; import useChannelsStore from './channels'; import usePlaylistsStore from './playlists'; @@ -82,20 +81,16 @@ const useAuthStore = create((set, get) => ({ getToken: async () => { const tokenExpiration = localStorage.getItem('tokenExpiration'); - let accessToken = null; - if (isTokenExpired(tokenExpiration)) { - accessToken = await get().getRefreshToken(); - } else { - accessToken = localStorage.getItem('accessToken'); - } - return accessToken; + return isTokenExpired(tokenExpiration) + ? await get().getRefreshToken() + : localStorage.getItem('accessToken'); }, // Action to login login: async ({ username, password }) => { try { - const response = await api.login(username, password); + const response = await API.login(username, password); if (response.access) { const expiration = decodeToken(response.access); set({ @@ -121,8 +116,8 @@ const useAuthStore = create((set, get) => ({ if (!refreshToken) return false; // Add explicit return here try { - const data = await api.refreshToken(refreshToken); - if (data && data.access) { + const data = await API.refreshToken(refreshToken); + if (data?.access) { set({ accessToken: data.access, tokenExpiration: decodeToken(data.access), diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index 9fb958b2..a95959b2 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -1,9 +1,91 @@ import { create } from 'zustand'; import api from '../api'; -import { notifications } from '@mantine/notifications'; +import { showNotification } from '../utils/notificationUtils.js'; const defaultProfiles = { 0: { id: '0', name: 'All', channels: new Set() } }; +const reduceChannels = (channels) => { + const channelsByUUID = {}; + const channelsByID = channels.reduce((acc, channel) => { + acc[channel.id] = channel; + channelsByUUID[channel.uuid] = channel.id; + return acc; + }, {}); + return { channelsByUUID, channelsByID }; +} + +const showNotificationIfNewChannel = (currentStats, oldChannels, ch, channelsByUUID, channels) => { + if (currentStats.channels) { + if (oldChannels[ch.channel_id] === undefined) { + // Add null checks to prevent accessing properties on undefined + const channelId = channelsByUUID[ch.channel_id]; + const channel = channelId ? channels[channelId] : null; + + if (channel) { + showNotification({ + title: 'New channel streaming', + message: channel.name, + color: 'blue.5', + }); + } + } + } +} + +const showNotificationIfNewClient = (currentStats, oldClients, client) => { + // This check prevents the notifications if streams are active on page load + if (currentStats.channels) { + if (oldClients[client.client_id] === undefined) { + showNotification({ + title: 'New client started streaming', + message: `Client streaming from ${client.ip_address}`, + color: 'blue.5', + }); + } + } +} + +const showNotificationIfChannelStopped = (currentStats, oldChannels, newChannels, channelsByUUID, channels) => { + // This check prevents the notifications if streams are active on page load + if (currentStats.channels) { + for (const uuid in oldChannels) { + if (newChannels[uuid] === undefined) { + // Add null check for channel name + const channelId = channelsByUUID[uuid]; + const channel = channelId && channels[channelId]; + + if (channel) { + showNotification({ + title: 'Channel streaming stopped', + message: channel.name, + color: 'blue.5', + }); + } else { + showNotification({ + title: 'Channel streaming stopped', + message: `Channel (${uuid})`, + color: 'blue.5', + }); + } + } + } + } +} + +const showNotificationIfClientStopped = (currentStats, oldClients, newClients) => { + if (currentStats.channels) { + for (const clientId in oldClients) { + if (newClients[clientId] === undefined) { + showNotification({ + title: 'Client stopped streaming', + message: `Client stopped streaming from ${oldClients[clientId].ip_address}`, + color: 'blue.5', + }); + } + } + } +} + const useChannelsStore = create((set, get) => ({ channels: [], channelsByUUID: {}, @@ -28,12 +110,7 @@ const useChannelsStore = create((set, get) => ({ set({ isLoading: true, error: null }); try { const channels = await api.getChannels(); - const channelsByUUID = {}; - const channelsByID = channels.reduce((acc, channel) => { - acc[channel.id] = channel; - channelsByUUID[channel.uuid] = channel.id; - return acc; - }, {}); + const { channelsByUUID, channelsByID } = reduceChannels(channels); set({ channels: channelsByID, channelsByUUID, @@ -113,16 +190,7 @@ const useChannelsStore = create((set, get) => ({ addChannels: (newChannels) => set((state) => { - const channelsByUUID = {}; - const profileChannels = new Set(); - - const channelsByID = newChannels.reduce((acc, channel) => { - acc[channel.id] = channel; - channelsByUUID[channel.uuid] = channel.id; - profileChannels.add(channel.id); - - return acc; - }, {}); + const { channelsByUUID, channelsByID } = reduceChannels(newChannels); // Don't automatically add to all profiles anymore - let the backend handle profile assignments // Just maintain the existing profile structure @@ -160,12 +228,8 @@ const useChannelsStore = create((set, get) => ({ ); return; } - const channelsByUUID = {}; - const updatedChannels = channels.reduce((acc, chan) => { - channelsByUUID[chan.uuid] = chan.id; - acc[chan.id] = chan; - return acc; - }, {}); + + const { channelsByUUID, updatedChannels } = reduceChannels(channels); set((state) => ({ channels: { @@ -249,12 +313,9 @@ const useChannelsStore = create((set, get) => ({ delete updatedProfiles[id]; } - let additionalUpdates = {}; - if (profileIds.includes(state.selectedProfileId)) { - additionalUpdates = { - selectedProfileId: '0', - }; - } + const additionalUpdates = profileIds.includes(state.selectedProfileId) + ? { selectedProfileId: '0' } + : {}; return { profiles: updatedProfiles, @@ -296,14 +357,12 @@ const useChannelsStore = create((set, get) => ({ channels: currentChannelsSet, }; - const updates = { + return { profiles: { ...state.profiles, [profileId]: updatedProfile, }, }; - - return updates; }), setChannelsPageSelection: (channelsPageSelection) => @@ -324,71 +383,24 @@ const useChannelsStore = create((set, get) => ({ channelsByUUID, } = state; const newClients = {}; + const newChannels = stats.channels.reduce((acc, ch) => { acc[ch.channel_id] = ch; - if (currentStats.channels) { - if (oldChannels[ch.channel_id] === undefined) { - // Add null checks to prevent accessing properties on undefined - const channelId = channelsByUUID[ch.channel_id]; - const channel = channelId ? channels[channelId] : null; - - if (channel) { - notifications.show({ - title: 'New channel streaming', - message: channel.name, - color: 'blue.5', - }); - } - } - } - ch.clients.map((client) => { - newClients[client.client_id] = client; - // This check prevents the notifications if streams are active on page load - if (currentStats.channels) { - if (oldClients[client.client_id] === undefined) { - notifications.show({ - title: 'New client started streaming', - message: `Client streaming from ${client.ip_address}`, - color: 'blue.5', - }); - } - } - }); return acc; }, {}); - // This check prevents the notifications if streams are active on page load - if (currentStats.channels) { - for (const uuid in oldChannels) { - if (newChannels[uuid] === undefined) { - // Add null check for channel name - const channelId = channelsByUUID[uuid]; - const channel = channelId && channels[channelId]; - if (channel) { - notifications.show({ - title: 'Channel streaming stopped', - message: channel.name, - color: 'blue.5', - }); - } else { - notifications.show({ - title: 'Channel streaming stopped', - message: `Channel (${uuid})`, - color: 'blue.5', - }); - } - } - } - for (const clientId in oldClients) { - if (newClients[clientId] === undefined) { - notifications.show({ - title: 'Client stopped streaming', - message: `Client stopped streaming from ${oldClients[clientId].ip_address}`, - color: 'blue.5', - }); - } - } - } + stats.channels.forEach(ch => { + showNotificationIfNewChannel(currentStats, oldChannels, ch, channelsByUUID, channels); + + ch.clients.forEach(client => { + newClients[client.client_id] = client; + showNotificationIfNewClient(currentStats, oldClients, client); + }); + }); + + showNotificationIfChannelStopped(currentStats, oldChannels, newChannels, channelsByUUID, channels); + showNotificationIfClientStopped(currentStats, oldClients, newClients); + return { stats, activeChannels: newChannels, diff --git a/frontend/src/store/channelsTable.jsx b/frontend/src/store/channelsTable.jsx index 75e14c63..c205a9be 100644 --- a/frontend/src/store/channelsTable.jsx +++ b/frontend/src/store/channelsTable.jsx @@ -1,7 +1,4 @@ import { create } from 'zustand'; -import api from '../api'; -import { notifications } from '@mantine/notifications'; -import API from '../api'; const useChannelsTableStore = create((set, get) => ({ channels: [], diff --git a/frontend/src/store/epgs.jsx b/frontend/src/store/epgs.jsx index 1760bc45..e8f5e2ad 100644 --- a/frontend/src/store/epgs.jsx +++ b/frontend/src/store/epgs.jsx @@ -1,6 +1,14 @@ import { create } from 'zustand'; import api from '../api'; +const determineEPGStatus = (data, currentEpg) => { + if (data.status) return data.status; + if (data.action === 'downloading') return 'fetching'; + if (data.action === 'parsing_channels' || data.action === 'parsing_programs') return 'parsing'; + if (data.progress === 100) return 'success'; + return currentEpg?.status || 'idle'; +}; + const useEPGsStore = create((set) => ({ epgs: {}, tvgs: [], @@ -92,7 +100,7 @@ const useEPGsStore = create((set) => ({ } // Create a new refreshProgress object that includes the current update - const newRefreshProgress = { + const refreshProgress = { ...state.refreshProgress, [data.source]: { action: data.action, @@ -106,45 +114,30 @@ const useEPGsStore = create((set) => ({ // Set the EPG source status based on the update // First prioritize explicit status values from the backend - const sourceStatus = data.status - ? data.status // Use explicit status if provided - : data.action === 'downloading' - ? 'fetching' - : data.action === 'parsing_channels' || - data.action === 'parsing_programs' - ? 'parsing' - : data.progress === 100 - ? 'success' // Mark as success when progress is 100% - : state.epgs[data.source]?.status || 'idle'; + const status = determineEPGStatus(data, state.epgs[data.source]); // Only update epgs object if status or last_message actually changed // This prevents unnecessary re-renders on every progress update - const currentEpg = state.epgs[data.source]; - const newLastMessage = - data.status === 'error' - ? data.error || 'Unknown error' - : currentEpg?.last_message; + const lastMessage = data.status === 'error' + ? (data.error || 'Unknown error') + : state.epgs[data.source]?.last_message; - let newEpgs = state.epgs; - if ( - currentEpg && - (currentEpg.status !== sourceStatus || - currentEpg.last_message !== newLastMessage) - ) { - newEpgs = { + const currentEpg = state.epgs[data.source]; + const shouldUpdateEpg = currentEpg && + (currentEpg.status !== status || currentEpg.last_message !== lastMessage); + + const epgs = shouldUpdateEpg + ? { ...state.epgs, [data.source]: { ...currentEpg, - status: sourceStatus, - last_message: newLastMessage, + status, + last_message: lastMessage, }, - }; - } + } + : state.epgs; - return { - refreshProgress: newRefreshProgress, - epgs: newEpgs, - }; + return { refreshProgress, epgs }; }), })); diff --git a/frontend/src/store/logos.jsx b/frontend/src/store/logos.jsx index 5843b113..5e09cf02 100644 --- a/frontend/src/store/logos.jsx +++ b/frontend/src/store/logos.jsx @@ -1,6 +1,12 @@ import { create } from 'zustand'; import api from '../api'; +const getLogosArray = (response) => { + return Array.isArray(response) + ? response + : response.results || []; +} + const useLogosStore = create((set, get) => ({ logos: {}, channelLogos: {}, // Separate cache for channel forms to avoid reloading @@ -24,13 +30,12 @@ const useLogosStore = create((set, get) => ({ // Add to channelLogos if the user has loaded channel logos // This means they're using channel forms and the new logo should be available there - let newChannelLogos = state.channelLogos; - if (state.hasLoadedChannelLogos) { - newChannelLogos = { - ...state.channelLogos, - [newLogo.id]: { ...newLogo }, - }; - } + const newChannelLogos = state.hasLoadedChannelLogos + ? { + ...state.channelLogos, + [newLogo.id]: { ...newLogo }, + } + : state.channelLogos; return { logos: newLogos, @@ -75,7 +80,7 @@ const useLogosStore = create((set, get) => ({ const response = await api.getLogos({ page_size: pageSize }); // Handle both paginated and non-paginated responses - const logos = Array.isArray(response) ? response : response.results || []; + const logos = getLogosArray(response); set({ logos: logos.reduce((acc, logo) => { @@ -109,9 +114,7 @@ const useLogosStore = create((set, get) => ({ const response = await api.getLogos({ no_pagination: 'true' }); // Handle both paginated and non-paginated responses - const logosArray = Array.isArray(response) - ? response - : response.results || []; + const logosArray = getLogosArray(response); set({ logos: logosArray.reduce((acc, logo) => { @@ -139,7 +142,7 @@ const useLogosStore = create((set, get) => ({ }); // Handle both paginated and non-paginated responses - const logos = Array.isArray(response) ? response : response.results || []; + const logos = getLogosArray(response); set((state) => ({ logos: { @@ -190,7 +193,7 @@ const useLogosStore = create((set, get) => ({ const response = await api.getLogosByIds(missingIds); // Handle both paginated and non-paginated responses - const logos = Array.isArray(response) ? response : response.results || []; + const logos = getLogosArray(response); set((state) => ({ logos: { @@ -262,9 +265,7 @@ const useLogosStore = create((set, get) => ({ try { // Use the API directly to avoid interfering with the main isLoading state const response = await api.getLogos({ no_pagination: 'true' }); - const logosArray = Array.isArray(response) - ? response - : response.results || []; + const logosArray = getLogosArray(response); // Process logos in smaller chunks to avoid blocking the main thread const chunkSize = 1000; diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx index 43edb1c9..defbfe17 100644 --- a/frontend/src/store/useVODStore.jsx +++ b/frontend/src/store/useVODStore.jsx @@ -1,6 +1,124 @@ import { create } from 'zustand'; import api from '../api'; +const getFetchContentParams = (state) => { + const params = new URLSearchParams(); + params.append('page', state.currentPage); + params.append('page_size', state.pageSize); + + if (state.filters.search) { + params.append('search', state.filters.search); + } + + if (state.filters.category) { + params.append('category', state.filters.category); + } + return params; +} + +const getMovieDetails = (response, movieId) => { + return { + id: response.id || movieId, + name: response.name || '', + description: response.description || '', + year: response.year || null, + genre: response.genre || '', + rating: response.rating || '', + duration_secs: response.duration_secs || null, + stream_url: response.url || '', + logo: response.logo_url || null, + type: 'movie', + director: response.director || '', + actors: response.actors || '', + country: response.country || '', + tmdb_id: response.tmdb_id || '', + imdb_id: response.imdb_id || '', + m3u_account: response.m3u_account || '', + }; +} + +const getMovieDetailsWithProvider = (response, movieId) => { + return { + id: response.id || movieId, + name: response.name || '', + description: response.description || response.plot || '', + year: response.year || null, + genre: response.genre || '', + rating: response.rating || '', + duration_secs: response.duration_secs || null, + stream_url: response.stream_url || '', + logo: response.logo || response.cover || null, + type: 'movie', + director: response.director || '', + actors: response.actors || response.cast || '', + country: response.country || '', + tmdb_id: response.tmdb_id || '', + youtube_trailer: response.youtube_trailer || '', + // Additional provider fields + backdrop_path: response.backdrop_path || [], + release_date: response.release_date || response.releasedate || '', + movie_image: response.movie_image || null, + o_name: response.o_name || '', + age: response.age || '', + episode_run_time: response.episode_run_time || null, + bitrate: response.bitrate || 0, + video: response.video || {}, + audio: response.audio || {}, + }; +} + +const getSeriesDetails = (response, seriesId) => { + return { + id: response.id || seriesId, + name: response.name || '', + description: response.description || response.custom_properties?.plot || '', + year: response.year || null, + genre: response.genre || '', + rating: response.rating || '', + logo: response.cover || null, + type: 'series', + director: response.custom_properties?.director || '', + cast: response.custom_properties?.cast || '', + country: response.country || '', + tmdb_id: response.tmdb_id || '', + imdb_id: response.imdb_id || '', + episode_count: response.episode_count || 0, + // Additional provider fields + backdrop_path: response.custom_properties?.backdrop_path || [], + release_date: response.release_date || '', + series_image: response.series_image || null, + o_name: response.o_name || '', + age: response.age || '', + m3u_account: response.m3u_account || '', + youtube_trailer: response.custom_properties?.youtube_trailer || '', + }; +} + +const getEpisodeDetails = (episode, seasonNumber, seriesInfo) => { + return { + id: episode.id, + stream_id: episode.id, + name: episode.title || '', + description: episode.plot || '', + season_number: parseInt(seasonNumber) || 0, + episode_number: episode.episode_number || 0, + duration_secs: episode.duration_secs || null, + rating: episode.rating || '', + container_extension: episode.container_extension || '', + series: { + id: seriesInfo.id, + name: seriesInfo.name, + }, + type: 'episode', + uuid: episode.uuid, + logo: episode.movie_image ? { url: episode.movie_image } : null, + air_date: episode.air_date || null, + movie_image: episode.movie_image || null, + tmdb_id: episode.tmdb_id || '', + imdb_id: episode.imdb_id || '', + }; +} + const useVODStore = create((set, get) => ({ content: {}, // Store for individual content details (when fetching movie/series details) currentPageContent: [], // Store the current page's results @@ -39,17 +157,7 @@ const useVODStore = create((set, get) => ({ set({ loading: true, error: null }); const state = get(); - const params = new URLSearchParams(); - params.append('page', state.currentPage); - params.append('page_size', state.pageSize); - - if (state.filters.search) { - params.append('search', state.filters.search); - } - - if (state.filters.category) { - params.append('category', state.filters.category); - } + const params = getFetchContentParams(state); let allResults = []; let totalCount = 0; @@ -58,12 +166,14 @@ const useVODStore = create((set, get) => ({ // Fetch only movies const response = await api.getMovies(params); const results = response.results || response; + allResults = results.map((item) => ({ ...item, contentType: 'movie' })); totalCount = response.count || results.length; } else if (state.filters.type === 'series') { // Fetch only series const response = await api.getSeries(params); const results = response.results || response; + allResults = results.map((item) => ({ ...item, contentType: 'series', @@ -73,16 +183,9 @@ const useVODStore = create((set, get) => ({ // Use the new unified backend endpoint for 'all' view const response = await api.getAllContent(params); console.log('getAllContent response:', response); - console.log('response type:', typeof response); - console.log( - 'response keys:', - response ? Object.keys(response) : 'no response' - ); const results = response.results || response; console.log('results:', results); - console.log('results type:', typeof results); - console.log('results is array:', Array.isArray(results)); // Check if results is actually an array before calling map if (!Array.isArray(results)) { @@ -140,24 +243,7 @@ const useVODStore = create((set, get) => ({ const response = await api.getMovieDetails(movieId); // Transform the response data to match our expected format - const movieDetails = { - id: response.id || movieId, - name: response.name || '', - description: response.description || '', - year: response.year || null, - genre: response.genre || '', - rating: response.rating || '', - duration_secs: response.duration_secs || null, - stream_url: response.url || '', - logo: response.logo_url || null, - type: 'movie', - director: response.director || '', - actors: response.actors || '', - country: response.country || '', - tmdb_id: response.tmdb_id || '', - imdb_id: response.imdb_id || '', - m3u_account: response.m3u_account || '', - }; + const movieDetails = getMovieDetails(response, movieId); console.log('Fetched Movie Details:', movieDetails); set((state) => ({ content: { @@ -184,33 +270,7 @@ const useVODStore = create((set, get) => ({ const response = await api.getMovieProviderInfo(movieId); // Transform the response data to match our expected format - const movieDetails = { - id: response.id || movieId, - name: response.name || '', - description: response.description || response.plot || '', - year: response.year || null, - genre: response.genre || '', - rating: response.rating || '', - duration_secs: response.duration_secs || null, - stream_url: response.stream_url || '', - logo: response.logo || response.cover || null, - type: 'movie', - director: response.director || '', - actors: response.actors || response.cast || '', - country: response.country || '', - tmdb_id: response.tmdb_id || '', - youtube_trailer: response.youtube_trailer || '', - // Additional provider fields - backdrop_path: response.backdrop_path || [], - release_date: response.release_date || response.releasedate || '', - movie_image: response.movie_image || null, - o_name: response.o_name || '', - age: response.age || '', - episode_run_time: response.episode_run_time || null, - bitrate: response.bitrate || 0, - video: response.video || {}, - audio: response.audio || {}, - }; + const movieDetails = getMovieDetailsWithProvider(response, movieId); set({ loading: false }); // Only update loading state @@ -316,31 +376,7 @@ const useVODStore = create((set, get) => ({ const response = await api.getSeriesInfo(seriesId); // Transform the response data to match our expected format - const seriesInfo = { - id: response.id || seriesId, - name: response.name || '', - description: - response.description || response.custom_properties?.plot || '', - year: response.year || null, - genre: response.genre || '', - rating: response.rating || '', - logo: response.cover || null, - type: 'series', - director: response.custom_properties?.director || '', - cast: response.custom_properties?.cast || '', - country: response.country || '', - tmdb_id: response.tmdb_id || '', - imdb_id: response.imdb_id || '', - episode_count: response.episode_count || 0, - // Additional provider fields - backdrop_path: response.custom_properties?.backdrop_path || [], - release_date: response.release_date || '', - series_image: response.series_image || null, - o_name: response.o_name || '', - age: response.age || '', - m3u_account: response.m3u_account || '', - youtube_trailer: response.custom_properties?.youtube_trailer || '', - }; + const seriesInfo = getSeriesDetails(response, seriesId); let episodesData = {}; @@ -349,29 +385,11 @@ const useVODStore = create((set, get) => ({ Object.entries(response.episodes).forEach( ([seasonNumber, seasonEpisodes]) => { seasonEpisodes.forEach((episode) => { - const episodeData = { - id: episode.id, - stream_id: episode.id, - name: episode.title || '', - description: episode.plot || '', - season_number: parseInt(seasonNumber) || 0, - episode_number: episode.episode_number || 0, - duration_secs: episode.duration_secs || null, - rating: episode.rating || '', - container_extension: episode.container_extension || '', - series: { - id: seriesInfo.id, - name: seriesInfo.name, - }, - type: 'episode', - uuid: episode.uuid, - logo: episode.movie_image ? { url: episode.movie_image } : null, - air_date: episode.air_date || null, - movie_image: episode.movie_image || null, - tmdb_id: episode.tmdb_id || '', - imdb_id: episode.imdb_id || '', - }; - episodesData[episode.id] = episodeData; + episodesData[episode.id] = getEpisodeDetails( + episode, + seasonNumber, + seriesInfo + ); }); } ); diff --git a/frontend/src/store/vodLogos.jsx b/frontend/src/store/vodLogos.jsx index 4df2dd17..34a30f2a 100644 --- a/frontend/src/store/vodLogos.jsx +++ b/frontend/src/store/vodLogos.jsx @@ -11,6 +11,29 @@ const useVODLogosStore = create((set) => ({ currentPage: 1, pageSize: 25, + _removeLogosFromState: (logoIds) => { + set((state) => { + const newVODLogos = { ...state.vodLogos }; + const logoIdSet = new Set(Array.isArray(logoIds) ? logoIds : [logoIds]); + + let removedCount = 0; + logoIdSet.forEach((id) => { + if (newVODLogos[id]) { + delete newVODLogos[id]; + removedCount++; + } + }); + + const newLogos = state.logos.filter((logo) => !logoIdSet.has(logo.id)); + + return { + vodLogos: newVODLogos, + logos: newLogos, + totalCount: Math.max(0, state.totalCount - removedCount), + }; + }); + }, + setVODLogos: (logos, totalCount = 0) => { set({ vodLogos: logos.reduce((acc, logo) => { @@ -22,15 +45,10 @@ const useVODLogosStore = create((set) => ({ }); }, - removeVODLogo: (logoId) => - set((state) => { - const newVODLogos = { ...state.vodLogos }; - delete newVODLogos[logoId]; - return { - vodLogos: newVODLogos, - totalCount: Math.max(0, state.totalCount - 1), - }; - }), + removeVODLogo: (logoId) => { + const state = useVODLogosStore.getState(); + state._removeLogosFromState(logoId); + }, fetchVODLogos: async (params = {}) => { set({ isLoading: true, error: null }); @@ -62,16 +80,8 @@ const useVODLogosStore = create((set) => ({ deleteVODLogo: async (logoId) => { try { await api.deleteVODLogo(logoId); - set((state) => { - const newVODLogos = { ...state.vodLogos }; - delete newVODLogos[logoId]; - const newLogos = state.logos.filter((logo) => logo.id !== logoId); - return { - vodLogos: newVODLogos, - logos: newLogos, - totalCount: Math.max(0, state.totalCount - 1), - }; - }); + const state = useVODLogosStore.getState(); + state._removeLogosFromState(logoId); } catch (error) { console.error('Failed to delete VOD logo:', error); throw error; @@ -81,17 +91,8 @@ const useVODLogosStore = create((set) => ({ deleteVODLogos: async (logoIds) => { try { await api.deleteVODLogos(logoIds); - set((state) => { - const newVODLogos = { ...state.vodLogos }; - logoIds.forEach((id) => delete newVODLogos[id]); - const logoIdSet = new Set(logoIds); - const newLogos = state.logos.filter((logo) => !logoIdSet.has(logo.id)); - return { - vodLogos: newVODLogos, - logos: newLogos, - totalCount: Math.max(0, state.totalCount - logoIds.length), - }; - }); + const state = useVODLogosStore.getState(); + state._removeLogosFromState(logoIds); } catch (error) { console.error('Failed to delete VOD logos:', error); throw error; From 525ae60157ff4ea9f4de4db63e36b8313e2ad8af Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:41:50 -0800 Subject: [PATCH 3/5] Removed functionality not implemented --- .../src/components/tables/StreamsTable.jsx | 2 -- frontend/src/store/logos.jsx | 3 --- frontend/src/store/useVODStore.jsx | 24 ------------------- 3 files changed, 29 deletions(-) diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 21b13baf..c3b2f677 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -702,8 +702,6 @@ const StreamsTable = ({ onReady }) => { channel_profile_ids: channelProfileIds, }); await API.requeryChannels(); - const fetchLogos = useChannelsStore.getState().fetchLogos; - fetchLogos(); }; // Handle confirming the single channel numbering modal diff --git a/frontend/src/store/logos.jsx b/frontend/src/store/logos.jsx index 5e09cf02..f821c424 100644 --- a/frontend/src/store/logos.jsx +++ b/frontend/src/store/logos.jsx @@ -72,9 +72,6 @@ const useLogosStore = create((set, get) => ({ // Smart loading methods fetchLogos: async (pageSize = 100) => { - // Don't fetch if logo fetching is not allowed yet - if (!get().allowLogoFetching) return []; - set({ isLoading: true, error: null }); try { const response = await api.getLogos({ page_size: pageSize }); diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx index defbfe17..0d8fe446 100644 --- a/frontend/src/store/useVODStore.jsx +++ b/frontend/src/store/useVODStore.jsx @@ -213,30 +213,6 @@ const useVODStore = create((set, get) => ({ } }, - fetchSeriesEpisodes: async (seriesId) => { - set({ loading: true, error: null }); - try { - const response = await api.getSeriesEpisodes(seriesId); - - set((state) => ({ - episodes: { - ...state.episodes, - ...response.reduce((acc, episode) => { - acc[episode.id] = episode; - return acc; - }, {}), - }, - loading: false, - })); - - return response; - } catch (error) { - console.error('Failed to fetch series episodes:', error); - set({ error: 'Failed to load episodes.', loading: false }); - throw error; // Re-throw to allow calling component to handle - } - }, - fetchMovieDetails: async (movieId) => { set({ loading: true, error: null }); try { From 04e2886ba53ea0655bf3587f2cd327c785323d6f Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:09:01 -0800 Subject: [PATCH 4/5] Updated tests after sync --- frontend/src/store/__tests__/auth.test.jsx | 170 +++++++++++++++++- .../store/__tests__/channelsTable.test.jsx | 107 +++++++++++ .../src/store/__tests__/settings.test.jsx | 149 +++++++++++++++ .../src/store/__tests__/vodLogos.test.jsx | 70 ++++++++ 4 files changed, 487 insertions(+), 9 deletions(-) diff --git a/frontend/src/store/__tests__/auth.test.jsx b/frontend/src/store/__tests__/auth.test.jsx index 4ae99e4b..7dc8277b 100644 --- a/frontend/src/store/__tests__/auth.test.jsx +++ b/frontend/src/store/__tests__/auth.test.jsx @@ -40,9 +40,6 @@ const localStorageMock = (() => { 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); @@ -161,7 +158,9 @@ describe('useAuthStore', () => { await result.current.login({ username: 'testuser', password: 'wrong' }); }); - expect(console.error).toHaveBeenCalledWith('Login failed:', expect.any(Error)); + expect(API.login).toHaveBeenCalledWith('testuser', 'wrong'); + expect(result.current.isAuthenticated).toBe(false); + expect(localStorageMock.setItem).not.toHaveBeenCalled(); }); }); @@ -183,7 +182,6 @@ describe('useAuthStore', () => { expect(API.refreshToken).toHaveBeenCalledWith('old-refresh-token'); expect(newToken).toBe(mockNewAccessToken); - expect(result.current.isAuthenticated).toBe(true); expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockNewAccessToken); }); @@ -212,7 +210,6 @@ describe('useAuthStore', () => { 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'); @@ -292,7 +289,6 @@ describe('useAuthStore', () => { 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(); }); @@ -435,8 +431,6 @@ describe('useAuthStore', () => { await act(async () => { await result.current.initData(); }); - - expect(console.error).toHaveBeenCalledWith('Error initializing data:', expect.any(Error)); }); }); @@ -476,4 +470,162 @@ describe('useAuthStore', () => { expect(result.current.superuserExists).toBe(false); }); }); + + describe('getRefreshToken edge cases', () => { + it('should return false if API response has no access token', async () => { + localStorageMock.getItem.mockReturnValue('refresh-token'); + API.refreshToken.mockResolvedValue({}); + + const { result } = renderHook(() => useAuthStore()); + + let response; + await act(async () => { + response = await result.current.getRefreshToken(); + }); + + expect(response).toBe(false); + }); + }); + + describe('login edge cases', () => { + it('should not update state if response has no access token', async () => { + API.login.mockResolvedValue({}); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.login({ username: 'testuser', password: 'password' }); + }); + + expect(result.current.accessToken).toBeNull(); + expect(localStorageMock.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('logout edge cases', () => { + it('should reset isInitializing flag on logout', async () => { + useAuthStore.setState({ isInitializing: true }); + API.logout.mockResolvedValue(); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.logout(); + }); + + expect(result.current.isInitializing).toBe(false); + }); + }); + + describe('initializeAuth edge cases', () => { + it('should return false if refresh token API call fails', async () => { + localStorageMock.getItem.mockReturnValue('refresh-token'); + API.refreshToken.mockRejectedValue(new Error('Token expired')); + API.logout.mockResolvedValue(); + + const { result } = renderHook(() => useAuthStore()); + + let initialized; + await act(async () => { + initialized = await result.current.initializeAuth(); + }); + + expect(initialized).toBe(false); + }); + + it('should return false if refresh returns no access token', async () => { + localStorageMock.getItem.mockReturnValue('refresh-token'); + API.refreshToken.mockResolvedValue({}); + + const { result } = renderHook(() => useAuthStore()); + + let initialized; + await act(async () => { + initialized = await result.current.initializeAuth(); + }); + + expect(initialized).toBe(false); + }); + }); + + describe('initData edge cases', () => { + it('should skip initialization if already initialized', async () => { + useAuthStore.setState({ isInitialized: true }); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.initData(); + }); + + expect(API.me).not.toHaveBeenCalled(); + }); + + it('should skip initialization if already initializing', async () => { + useAuthStore.setState({ isInitializing: true }); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.initData(); + }); + + expect(API.me).not.toHaveBeenCalled(); + }); + + it('should set isInitializing to false on error', async () => { + // Reset state before the test + useAuthStore.setState({ + isInitializing: false, + isInitialized: false + }); + + API.me.mockRejectedValue(new Error('API error')); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + try { + await result.current.initData(); + } catch { + // Expected error + } + }); + + expect(result.current.isInitializing).toBe(false); + expect(result.current.isInitialized).toBe(false); + }); + + it('should call fetchChannels in background after initialization', async () => { + const mockUser = { + username: 'admin', + email: 'admin@test.com', + user_level: USER_LEVELS.ADMIN, + }; + + const fetchChannels = vi.fn().mockResolvedValue(); + useChannelsStore.getState = () => ({ + fetchChannels, + fetchChannelGroups: vi.fn().mockResolvedValue(), + fetchChannelProfiles: vi.fn().mockResolvedValue(), + }); + + API.me.mockResolvedValue(mockUser); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.initData(); + }); + + // Wait for the background call to complete + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + }); + + // The background fetchChannels is called synchronously without await + // so we just need to verify it was called + expect(fetchChannels).toHaveBeenCalled(); + }); + }); }); diff --git a/frontend/src/store/__tests__/channelsTable.test.jsx b/frontend/src/store/__tests__/channelsTable.test.jsx index 171f0672..0bf3e31a 100644 --- a/frontend/src/store/__tests__/channelsTable.test.jsx +++ b/frontend/src/store/__tests__/channelsTable.test.jsx @@ -292,4 +292,111 @@ describe('useChannelsTableStore', () => { expect(result.current.sorting).toEqual([]); }); }); + + describe('isUnlocked', () => { + it('should initialize with default false value', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + expect(result.current.isUnlocked).toBe(false); + }); + }); + + describe('setIsUnlocked', () => { + it('should update isUnlocked to true', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + act(() => { + result.current.setIsUnlocked(true); + }); + + expect(result.current.isUnlocked).toBe(true); + }); + + it('should update isUnlocked to false', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + act(() => { + result.current.setIsUnlocked(true); + }); + + expect(result.current.isUnlocked).toBe(true); + + act(() => { + result.current.setIsUnlocked(false); + }); + + expect(result.current.isUnlocked).toBe(false); + }); + }); + + describe('updateChannel', () => { + it('should update an existing channel', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockChannels = [ + { id: 1, name: 'Channel 1', channel_number: 1 }, + { id: 2, name: 'Channel 2', channel_number: 2 }, + { id: 3, name: 'Channel 3', channel_number: 3 }, + ]; + + act(() => { + useChannelsTableStore.setState({ channels: mockChannels }); + }); + + const updatedChannel = { id: 2, name: 'Updated Channel 2', channel_number: 22 }; + + act(() => { + result.current.updateChannel(updatedChannel); + }); + + expect(result.current.channels).toEqual([ + { id: 1, name: 'Channel 1', channel_number: 1 }, + { id: 2, name: 'Updated Channel 2', channel_number: 22 }, + { id: 3, name: 'Channel 3', channel_number: 3 }, + ]); + }); + + it('should not modify channels when updating non-existent channel', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockChannels = [ + { id: 1, name: 'Channel 1', channel_number: 1 }, + { id: 2, name: 'Channel 2', channel_number: 2 }, + ]; + + act(() => { + useChannelsTableStore.setState({ channels: mockChannels }); + }); + + const updatedChannel = { id: 999, name: 'Non-existent', channel_number: 999 }; + + act(() => { + result.current.updateChannel(updatedChannel); + }); + + expect(result.current.channels).toEqual(mockChannels); + }); + + it('should preserve other channels when updating one channel', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockChannels = [ + { id: 1, name: 'Channel 1', channel_number: 1, streams: ['stream1'] }, + { id: 2, name: 'Channel 2', channel_number: 2, streams: ['stream2'] }, + ]; + + act(() => { + useChannelsTableStore.setState({ channels: mockChannels }); + }); + + const updatedChannel = { id: 1, name: 'Updated Channel 1', channel_number: 10 }; + + act(() => { + result.current.updateChannel(updatedChannel); + }); + + expect(result.current.channels[0]).toEqual(updatedChannel); + expect(result.current.channels[1]).toEqual(mockChannels[1]); + }); + }); }); diff --git a/frontend/src/store/__tests__/settings.test.jsx b/frontend/src/store/__tests__/settings.test.jsx index 59da4437..58f64724 100644 --- a/frontend/src/store/__tests__/settings.test.jsx +++ b/frontend/src/store/__tests__/settings.test.jsx @@ -16,6 +16,10 @@ describe('useSettingsStore', () => { country_name: '', env_mode: 'prod', }, + version: { + version: '', + timestamp: null, + }, isLoading: false, error: null, }); @@ -201,4 +205,149 @@ describe('useSettingsStore', () => { expect(result.current.settings).toEqual({}); }); + + it('should initialize version with default state', () => { + const { result } = renderHook(() => useSettingsStore()); + + expect(result.current.version).toEqual({ + version: '', + timestamp: null, + }); + }); + + it('should fetch version successfully', async () => { + const mockVersion = { + version: '1.2.3', + timestamp: '2024-01-01T00:00:00Z', + }; + + api.getVersion.mockResolvedValue(mockVersion); + + const { result } = renderHook(() => useSettingsStore()); + + let versionResult; + await act(async () => { + versionResult = await result.current.fetchVersion(); + }); + + expect(api.getVersion).toHaveBeenCalled(); + expect(result.current.version).toEqual({ + version: '1.2.3', + timestamp: '2024-01-01T00:00:00Z', + }); + expect(versionResult).toEqual({ + version: '1.2.3', + timestamp: '2024-01-01T00:00:00Z', + }); + }); + + it('should skip fetching version if already loaded', async () => { + useSettingsStore.setState({ + version: { + version: '1.0.0', + timestamp: '2023-01-01T00:00:00Z', + }, + }); + + api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' }); + + const { result } = renderHook(() => useSettingsStore()); + + let versionResult; + await act(async () => { + versionResult = await result.current.fetchVersion(); + }); + + expect(api.getVersion).not.toHaveBeenCalled(); + expect(result.current.version).toEqual({ + version: '1.0.0', + timestamp: '2023-01-01T00:00:00Z', + }); + expect(versionResult).toEqual({ + version: '1.0.0', + timestamp: '2023-01-01T00:00:00Z', + }); + }); + + it('should handle null version response', async () => { + api.getVersion.mockResolvedValue(null); + + const { result } = renderHook(() => useSettingsStore()); + + await act(async () => { + await result.current.fetchVersion(); + }); + + expect(result.current.version).toEqual({ + version: '', + timestamp: null, + }); + }); + + it('should handle fetch version error', async () => { + const mockError = new Error('Version fetch failed'); + api.getVersion.mockRejectedValue(mockError); + + const { result } = renderHook(() => useSettingsStore()); + + let versionResult; + await act(async () => { + versionResult = await result.current.fetchVersion(); + }); + + expect(versionResult).toEqual({ + version: '', + timestamp: null, + }); + }); + + it('should fetch version with settings when version not loaded', async () => { + const mockSettings = [{ key: 'setting1', value: 'value1' }]; + const mockEnv = { public_ip: '192.168.1.1' }; + const mockVersion = { version: '1.0.0', timestamp: '2024-01-01T00:00:00Z' }; + + api.getSettings.mockResolvedValue(mockSettings); + api.getEnvironmentSettings.mockResolvedValue(mockEnv); + api.getVersion.mockResolvedValue(mockVersion); + + const { result } = renderHook(() => useSettingsStore()); + + await act(async () => { + await result.current.fetchSettings(); + }); + + expect(api.getVersion).toHaveBeenCalled(); + expect(result.current.version).toEqual({ + version: '1.0.0', + timestamp: '2024-01-01T00:00:00Z', + }); + }); + + it('should skip fetching version with settings when already loaded', async () => { + useSettingsStore.setState({ + version: { + version: '1.0.0', + timestamp: '2023-01-01T00:00:00Z', + }, + }); + + const mockSettings = [{ key: 'setting1', value: 'value1' }]; + const mockEnv = { public_ip: '192.168.1.1' }; + + api.getSettings.mockResolvedValue(mockSettings); + api.getEnvironmentSettings.mockResolvedValue(mockEnv); + api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' }); + + const { result } = renderHook(() => useSettingsStore()); + + await act(async () => { + await result.current.fetchSettings(); + }); + + expect(api.getVersion).not.toHaveBeenCalled(); + expect(result.current.version).toEqual({ + version: '1.0.0', + timestamp: '2023-01-01T00:00:00Z', + }); + }); }); diff --git a/frontend/src/store/__tests__/vodLogos.test.jsx b/frontend/src/store/__tests__/vodLogos.test.jsx index 6ac3cc44..116cbf9d 100644 --- a/frontend/src/store/__tests__/vodLogos.test.jsx +++ b/frontend/src/store/__tests__/vodLogos.test.jsx @@ -477,4 +477,74 @@ describe('useVODLogosStore', () => { expect(api.getVODLogos).toHaveBeenCalledWith({ search: 'test', page: 2 }); }); + + it('should get unused logos count successfully', async () => { + const mockResponse = { + results: [{ id: 1, name: 'Unused Logo' }], + count: 42, + }; + + api.getVODLogos.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODLogosStore()); + + let unusedCount; + await act(async () => { + unusedCount = await result.current.getUnusedLogosCount(); + }); + + expect(api.getVODLogos).toHaveBeenCalledWith({ + used: 'false', + page_size: 1, + }); + expect(unusedCount).toBe(42); + }); + + it('should return 0 when unused logos count response has no count', async () => { + api.getVODLogos.mockResolvedValue({ results: [] }); + + const { result } = renderHook(() => useVODLogosStore()); + + let unusedCount; + await act(async () => { + unusedCount = await result.current.getUnusedLogosCount(); + }); + + expect(unusedCount).toBe(0); + }); + + it('should handle get unused logos count error', async () => { + const mockError = new Error('Failed to fetch count'); + api.getVODLogos.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + try { + await result.current.getUnusedLogosCount(); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch unused logos count:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should update currentPage and pageSize state', () => { + const { result } = renderHook(() => useVODLogosStore()); + + act(() => { + useVODLogosStore.setState({ + currentPage: 3, + pageSize: 50, + }); + }); + + expect(result.current.currentPage).toBe(3); + expect(result.current.pageSize).toBe(50); + }); }); From c8430760c419bd5efa64e7d9478d3c77f9a457a7 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 28 Jan 2026 13:15:26 -0800 Subject: [PATCH 5/5] Added tests after sync --- .../__tests__/useTablePreferences.test.jsx | 390 ++++++++++++++++++ .../src/store/__tests__/streamsTable.test.jsx | 349 ++++++++++++++++ 2 files changed, 739 insertions(+) create mode 100644 frontend/src/hooks/__tests__/useTablePreferences.test.jsx create mode 100644 frontend/src/store/__tests__/streamsTable.test.jsx diff --git a/frontend/src/hooks/__tests__/useTablePreferences.test.jsx b/frontend/src/hooks/__tests__/useTablePreferences.test.jsx new file mode 100644 index 00000000..2158df54 --- /dev/null +++ b/frontend/src/hooks/__tests__/useTablePreferences.test.jsx @@ -0,0 +1,390 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import useTablePreferences from '../useTablePreferences'; + +// Mock localStorage +const localStorageMock = (() => { + let store = {}; + + return { + getItem: vi.fn((key) => store[key] || null), + setItem: vi.fn((key, value) => { + store[key] = value.toString(); + }), + clear: vi.fn(() => { + store = {}; + }), + removeItem: vi.fn((key) => { + delete store[key]; + }) + }; +})(); + +global.localStorage = localStorageMock; + +describe('useTablePreferences', () => { + let consoleErrorSpy; + + beforeEach(() => { + // Spy on console.error + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + localStorageMock.clear(); + + // Mock window.addEventListener and removeEventListener + vi.spyOn(window, 'addEventListener'); + vi.spyOn(window, 'removeEventListener'); + }); + + afterEach(() => { + vi.clearAllMocks(); + consoleErrorSpy.mockRestore(); + }); + + describe('Initial State', () => { + it('should initialize with default values when localStorage is empty', () => { + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.headerPinned).toBe(false); + expect(result.current.tableSize).toBe('default'); + }); + + it('should initialize headerPinned from localStorage', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true })); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.headerPinned).toBe(true); + }); + + it('should initialize tableSize from localStorage', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' })); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.tableSize).toBe('compact'); + }); + + it('should initialize both preferences from localStorage', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true, tableSize: 'comfortable' })); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.headerPinned).toBe(true); + expect(result.current.tableSize).toBe('comfortable'); + }); + + it('should migrate tableSize from old localStorage location', () => { + localStorageMock.setItem('table-size', JSON.stringify('compact')); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.tableSize).toBe('compact'); + }); + + it('should prefer new localStorage location over old location', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'comfortable' })); + localStorageMock.setItem('table-size', JSON.stringify('compact')); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.tableSize).toBe('comfortable'); + }); + + it('should handle malformed JSON in localStorage gracefully', () => { + localStorageMock.setItem('table-preferences', 'invalid json'); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.headerPinned).toBe(false); + expect(result.current.tableSize).toBe('default'); + expect(consoleErrorSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('setHeaderPinned', () => { + it('should update headerPinned state', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(result.current.headerPinned).toBe(true); + }); + + it('should persist headerPinned to localStorage', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ headerPinned: true }) + ); + }); + + it('should preserve existing preferences when updating headerPinned', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' })); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ tableSize: 'compact', headerPinned: true }) + ); + }); + + it('should dispatch custom event when updating headerPinned', () => { + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent'); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'table-preferences-changed', + detail: { headerPinned: true }, + }) + ); + + dispatchEventSpy.mockRestore(); + }); + + it('should handle localStorage errors gracefully', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({})); // Ensure it exists + localStorageMock.setItem.mockImplementationOnce(() => { + throw new Error('Storage error'); + }); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(result.current.headerPinned).toBe(true); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error saving headerPinned to localStorage:', + expect.any(Error) + ); + }); + }); + + describe('setTableSize', () => { + it('should update tableSize state', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setTableSize('compact'); + }); + + expect(result.current.tableSize).toBe('compact'); + }); + + it('should persist tableSize to localStorage', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setTableSize('comfortable'); + }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ tableSize: 'comfortable' }) + ); + }); + + it('should preserve existing preferences when updating tableSize', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true })); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setTableSize('compact'); + }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ headerPinned: true, tableSize: 'compact' }) + ); + }); + + it('should dispatch custom event when updating tableSize', () => { + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent'); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setTableSize('compact'); + }); + + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'table-preferences-changed', + detail: { tableSize: 'compact' }, + }) + ); + + dispatchEventSpy.mockRestore(); + }); + + it('should handle localStorage errors gracefully', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({})); // Ensure it exists + localStorageMock.setItem.mockImplementationOnce(() => { + throw new Error('Storage error'); + }); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setTableSize('compact'); + }); + + expect(result.current.tableSize).toBe('compact'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error saving tableSize to localStorage:', + expect.any(Error) + ); + }); + }); + + describe('Event Listeners', () => { + it('should register event listener on mount', () => { + renderHook(() => useTablePreferences()); + + expect(window.addEventListener).toHaveBeenCalledWith( + 'table-preferences-changed', + expect.any(Function) + ); + }); + + it('should remove event listener on unmount', () => { + const { unmount } = renderHook(() => useTablePreferences()); + + unmount(); + + expect(window.removeEventListener).toHaveBeenCalledWith( + 'table-preferences-changed', + expect.any(Function) + ); + }); + + it('should update headerPinned from custom event', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + const event = new CustomEvent('table-preferences-changed', { + detail: { headerPinned: true }, + }); + window.dispatchEvent(event); + }); + + expect(result.current.headerPinned).toBe(true); + }); + + it('should update tableSize from custom event', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + const event = new CustomEvent('table-preferences-changed', { + detail: { tableSize: 'compact' }, + }); + window.dispatchEvent(event); + }); + + expect(result.current.tableSize).toBe('compact'); + }); + + it('should update both preferences from custom event', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + const event = new CustomEvent('table-preferences-changed', { + detail: { headerPinned: true, tableSize: 'comfortable' }, + }); + window.dispatchEvent(event); + }); + + expect(result.current.headerPinned).toBe(true); + expect(result.current.tableSize).toBe('comfortable'); + }); + + it('should not update if value is the same', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true })); + + const { result } = renderHook(() => useTablePreferences()); + const initialHeaderPinned = result.current.headerPinned; + + act(() => { + const event = new CustomEvent('table-preferences-changed', { + detail: { headerPinned: true }, + }); + window.dispatchEvent(event); + }); + + expect(result.current.headerPinned).toBe(initialHeaderPinned); + }); + }); + + describe('Integration Tests', () => { + it('should handle complete workflow', () => { + const { result } = renderHook(() => useTablePreferences()); + + // Update headerPinned + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(result.current.headerPinned).toBe(true); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ headerPinned: true }) + ); + + // Update tableSize + act(() => { + result.current.setTableSize('compact'); + }); + + expect(result.current.tableSize).toBe('compact'); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ headerPinned: true, tableSize: 'compact' }) + ); + + // Verify both preferences are maintained + expect(result.current.headerPinned).toBe(true); + expect(result.current.tableSize).toBe('compact'); + }); + + it('should sync changes across multiple hook instances via events', () => { + const { result: result1 } = renderHook(() => useTablePreferences()); + const { result: result2 } = renderHook(() => useTablePreferences()); + + // Update from first instance + act(() => { + result1.current.setHeaderPinned(true); + }); + + // Second instance should receive the event + act(() => { + const event = new CustomEvent('table-preferences-changed', { + detail: { headerPinned: true }, + }); + window.dispatchEvent(event); + }); + + expect(result2.current.headerPinned).toBe(true); + }); + }); +}); diff --git a/frontend/src/store/__tests__/streamsTable.test.jsx b/frontend/src/store/__tests__/streamsTable.test.jsx new file mode 100644 index 00000000..62818107 --- /dev/null +++ b/frontend/src/store/__tests__/streamsTable.test.jsx @@ -0,0 +1,349 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import useStreamsTableStore from '../streamsTable'; + +// Mock localStorage +const localStorageMock = (() => { + let store = {}; + + return { + getItem: vi.fn((key) => store[key] || null), + setItem: vi.fn((key, value) => { + store[key] = value.toString(); + }), + clear: vi.fn(() => { + store = {}; + }), + removeItem: vi.fn((key) => { + delete store[key]; + }) + }; +})(); + +globalThis.localStorage = localStorageMock; + +describe('useStreamsTableStore', () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Initial State', () => { + it('should initialize with default state', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + expect(result.current.streams).toEqual([]); + expect(result.current.pageCount).toBe(0); + expect(result.current.totalCount).toBe(0); + expect(result.current.sorting).toEqual([{ id: 'name', desc: false }]); + expect(result.current.pagination).toEqual({ + pageIndex: 0, + pageSize: 50, + }); + expect(result.current.selectedStreamIds).toEqual([]); + expect(result.current.allQueryIds).toEqual([]); + expect(result.current.lastQueryParams).toBeNull(); + }); + + it('should initialize pagination with localStorage value if available', () => { + // Set localStorage BEFORE getting the store state + localStorageMock.setItem('streams-page-size', JSON.stringify(100)); + + // Now update the store to re-read from localStorage + useStreamsTableStore.setState({ + pagination: { + pageIndex: 0, + pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50, + } + }); + + const { result } = renderHook(() => useStreamsTableStore()); + + expect(result.current.pagination.pageSize).toBe(100); + }); + + it('should use default page size if localStorage is empty', () => { + // Ensure localStorage is empty + localStorageMock.clear(); + + useStreamsTableStore.setState({ + pagination: { + pageIndex: 0, + pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50, + } + }); + + const { result } = renderHook(() => useStreamsTableStore()); + + expect(result.current.pagination.pageSize).toBe(50); + }); + }); + + describe('queryStreams', () => { + it('should update streams, totalCount, and pageCount', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockResults = [ + { id: 1, name: 'Stream 1' }, + { id: 2, name: 'Stream 2' }, + ]; + const mockCount = 100; + const mockParams = new URLSearchParams({ page_size: '50' }); + + act(() => { + result.current.queryStreams( + { results: mockResults, count: mockCount }, + mockParams + ); + }); + + expect(result.current.streams).toEqual(mockResults); + expect(result.current.totalCount).toBe(100); + expect(result.current.pageCount).toBe(2); // Math.ceil(100 / 50) + }); + + it('should calculate pageCount correctly with different page sizes', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockParams = new URLSearchParams({ page_size: '25' }); + + act(() => { + result.current.queryStreams( + { results: [], count: 75 }, + mockParams + ); + }); + + expect(result.current.pageCount).toBe(3); // Math.ceil(75 / 25) + }); + + it('should handle empty results', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockParams = new URLSearchParams({ page_size: '50' }); + + act(() => { + result.current.queryStreams( + { results: [], count: 0 }, + mockParams + ); + }); + + expect(result.current.streams).toEqual([]); + expect(result.current.totalCount).toBe(0); + expect(result.current.pageCount).toBe(0); + }); + }); + + describe('setAllQueryIds', () => { + it('should update allQueryIds', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockIds = [1, 2, 3, 4, 5]; + + act(() => { + result.current.setAllQueryIds(mockIds); + }); + + expect(result.current.allQueryIds).toEqual(mockIds); + }); + + it('should replace previous allQueryIds', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + act(() => { + result.current.setAllQueryIds([1, 2, 3]); + }); + + act(() => { + result.current.setAllQueryIds([4, 5, 6]); + }); + + expect(result.current.allQueryIds).toEqual([4, 5, 6]); + }); + }); + + describe('setSelectedStreamIds', () => { + it('should update selectedStreamIds', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockIds = [1, 3, 5]; + + act(() => { + result.current.setSelectedStreamIds(mockIds); + }); + + expect(result.current.selectedStreamIds).toEqual(mockIds); + }); + + it('should handle empty selection', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + act(() => { + result.current.setSelectedStreamIds([1, 2, 3]); + }); + + act(() => { + result.current.setSelectedStreamIds([]); + }); + + expect(result.current.selectedStreamIds).toEqual([]); + }); + }); + + describe('setPagination', () => { + it('should update pagination state', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const newPagination = { + pageIndex: 2, + pageSize: 100, + }; + + act(() => { + result.current.setPagination(newPagination); + }); + + expect(result.current.pagination).toEqual(newPagination); + }); + + it('should replace entire pagination object', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + act(() => { + result.current.setPagination({ pageIndex: 1, pageSize: 25 }); + }); + + act(() => { + result.current.setPagination({ pageIndex: 3, pageSize: 75 }); + }); + + expect(result.current.pagination).toEqual({ pageIndex: 3, pageSize: 75 }); + }); + }); + + describe('setSorting', () => { + it('should update sorting state', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const newSorting = [{ id: 'created_at', desc: true }]; + + act(() => { + result.current.setSorting(newSorting); + }); + + expect(result.current.sorting).toEqual(newSorting); + }); + + it('should handle multiple sorting columns', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const newSorting = [ + { id: 'name', desc: false }, + { id: 'created_at', desc: true }, + ]; + + act(() => { + result.current.setSorting(newSorting); + }); + + expect(result.current.sorting).toEqual(newSorting); + }); + + it('should handle empty sorting array', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + act(() => { + result.current.setSorting([]); + }); + + expect(result.current.sorting).toEqual([]); + }); + }); + + describe('setLastQueryParams', () => { + it('should update lastQueryParams', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockParams = new URLSearchParams({ page: '1', search: 'test' }); + + act(() => { + result.current.setLastQueryParams(mockParams); + }); + + expect(result.current.lastQueryParams).toBe(mockParams); + }); + + it('should handle null value', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + act(() => { + result.current.setLastQueryParams(new URLSearchParams()); + }); + + act(() => { + result.current.setLastQueryParams(null); + }); + + expect(result.current.lastQueryParams).toBeNull(); + }); + }); + + describe('Integration Tests', () => { + it('should handle a typical query flow', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + // Set pagination + act(() => { + result.current.setPagination({ pageIndex: 0, pageSize: 25 }); + }); + + // Set sorting + act(() => { + result.current.setSorting([{ id: 'created_at', desc: true }]); + }); + + // Query streams + const mockResults = [ + { id: 1, name: 'Stream 1' }, + { id: 2, name: 'Stream 2' }, + ]; + const mockParams = new URLSearchParams({ page_size: '25' }); + + act(() => { + result.current.queryStreams( + { results: mockResults, count: 50 }, + mockParams + ); + }); + + // Set query IDs + act(() => { + result.current.setAllQueryIds([1, 2, 3, 4, 5]); + }); + + // Select streams + act(() => { + result.current.setSelectedStreamIds([1, 2]); + }); + + // Set last query params + act(() => { + result.current.setLastQueryParams(mockParams); + }); + + expect(result.current.streams).toEqual(mockResults); + expect(result.current.totalCount).toBe(50); + expect(result.current.pageCount).toBe(2); + expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 25 }); + expect(result.current.sorting).toEqual([{ id: 'created_at', desc: true }]); + expect(result.current.allQueryIds).toEqual([1, 2, 3, 4, 5]); + expect(result.current.selectedStreamIds).toEqual([1, 2]); + expect(result.current.lastQueryParams).toBe(mockParams); + }); + }); +});