From 113f4c0bde1a30002b6c1f7f1a92d492646ecd8b Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 2 May 2026 01:44:15 -0700 Subject: [PATCH 01/15] Extracted utils --- .../forms/__tests__/ChannelGroupUtils.test.js | 152 ++++++ .../__tests__/LiveGroupFilterUtils.test.js | 273 +++++++++++ .../utils/forms/__tests__/LogoUtils.test.js | 404 ++++++++++++++++ .../forms/__tests__/M3uFilterUtils.test.js | 187 ++++++++ .../__tests__/M3uGroupFilterUtils.test.js | 352 ++++++++++++++ .../forms/__tests__/M3uProfileUtils.test.js | 451 ++++++++++++++++++ .../forms/__tests__/M3uProfilesUtils.test.js | 282 +++++++++++ .../utils/forms/__tests__/M3uUtils.test.js | 226 +++++++++ 8 files changed, 2327 insertions(+) create mode 100644 frontend/src/utils/forms/__tests__/ChannelGroupUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/LiveGroupFilterUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/LogoUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/M3uFilterUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/M3uGroupFilterUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/M3uProfileUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/M3uProfilesUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/M3uUtils.test.js diff --git a/frontend/src/utils/forms/__tests__/ChannelGroupUtils.test.js b/frontend/src/utils/forms/__tests__/ChannelGroupUtils.test.js new file mode 100644 index 00000000..3853a9d8 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/ChannelGroupUtils.test.js @@ -0,0 +1,152 @@ +// src/utils/forms/__tests__/ChannelGroupUtils.test.js +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + updateChannelGroup, + addChannelGroup, + deleteChannelGroup, + cleanupUnusedChannelGroups, +} from '../ChannelGroupUtils.js'; + +// ── API mock ─────────────────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + updateChannelGroup: vi.fn(), + addChannelGroup: vi.fn(), + deleteChannelGroup: vi.fn(), + cleanupUnusedChannelGroups: vi.fn(), + }, +})); + +import API from '../../../api.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeChannelGroup = (overrides = {}) => ({ + id: 'group-1', + name: 'Sports', + ...overrides, +}); + +const makeValues = (overrides = {}) => ({ + name: 'Updated Sports', + locked: false, + ...overrides, +}); + +describe('ChannelGroupUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── updateChannelGroup ───────────────────────────────────────────────────── + + describe('updateChannelGroup', () => { + it('calls API.updateChannelGroup with merged id and values', () => { + const group = makeChannelGroup(); + const values = makeValues(); + updateChannelGroup(group, values); + expect(API.updateChannelGroup).toHaveBeenCalledWith({ + id: 'group-1', + name: 'Updated Sports', + locked: false, + }); + }); + + it('returns the result of API.updateChannelGroup', () => { + const mockResult = { id: 'group-1', name: 'Updated Sports' }; + vi.mocked(API.updateChannelGroup).mockReturnValue(mockResult); + const result = updateChannelGroup(makeChannelGroup(), makeValues()); + expect(result).toBe(mockResult); + }); + + it('calls API.updateChannelGroup exactly once', () => { + updateChannelGroup(makeChannelGroup(), makeValues()); + expect(API.updateChannelGroup).toHaveBeenCalledTimes(1); + }); + }); + + // ── addChannelGroup ──────────────────────────────────────────────────────── + + describe('addChannelGroup', () => { + it('calls API.addChannelGroup with the provided values', () => { + const values = makeValues(); + addChannelGroup(values); + expect(API.addChannelGroup).toHaveBeenCalledWith(values); + }); + + it('returns the result of API.addChannelGroup', () => { + const mockResult = { id: 'group-2', name: 'Updated Sports' }; + vi.mocked(API.addChannelGroup).mockReturnValue(mockResult); + const result = addChannelGroup(makeValues()); + expect(result).toBe(mockResult); + }); + + it('calls API.addChannelGroup exactly once', () => { + addChannelGroup(makeValues()); + expect(API.addChannelGroup).toHaveBeenCalledTimes(1); + }); + + it('passes values through unmodified', () => { + const values = { name: 'News', locked: true, custom: 'data' }; + addChannelGroup(values); + expect(API.addChannelGroup).toHaveBeenCalledWith(values); + }); + }); + + // ── deleteChannelGroup ───────────────────────────────────────────────────── + + describe('deleteChannelGroup', () => { + it('calls API.deleteChannelGroup with the group id', () => { + const group = makeChannelGroup(); + deleteChannelGroup(group); + expect(API.deleteChannelGroup).toHaveBeenCalledWith('group-1'); + }); + + it('returns the result of API.deleteChannelGroup', () => { + const mockResult = { success: true }; + vi.mocked(API.deleteChannelGroup).mockReturnValue(mockResult); + const result = deleteChannelGroup(makeChannelGroup()); + expect(result).toBe(mockResult); + }); + + it('calls API.deleteChannelGroup exactly once', () => { + deleteChannelGroup(makeChannelGroup()); + expect(API.deleteChannelGroup).toHaveBeenCalledTimes(1); + }); + + it('uses only the id from the group object', () => { + const group = makeChannelGroup({ id: 'group-42', name: 'Movies' }); + deleteChannelGroup(group); + expect(API.deleteChannelGroup).toHaveBeenCalledWith('group-42'); + }); + }); + + // ── cleanupUnusedChannelGroups ───────────────────────────────────────────── + + describe('cleanupUnusedChannelGroups', () => { + it('calls API.cleanupUnusedChannelGroups', async () => { + vi.mocked(API.cleanupUnusedChannelGroups).mockResolvedValue(undefined); + await cleanupUnusedChannelGroups(); + expect(API.cleanupUnusedChannelGroups).toHaveBeenCalled(); + }); + + it('calls API.cleanupUnusedChannelGroups exactly once', async () => { + vi.mocked(API.cleanupUnusedChannelGroups).mockResolvedValue(undefined); + await cleanupUnusedChannelGroups(); + expect(API.cleanupUnusedChannelGroups).toHaveBeenCalledTimes(1); + }); + + it('returns the resolved value from API.cleanupUnusedChannelGroups', async () => { + const mockResult = { removed: 5 }; + vi.mocked(API.cleanupUnusedChannelGroups).mockResolvedValue(mockResult); + const result = await cleanupUnusedChannelGroups(); + expect(result).toEqual(mockResult); + }); + + it('propagates rejection from API.cleanupUnusedChannelGroups', async () => { + vi.mocked(API.cleanupUnusedChannelGroups).mockRejectedValue( + new Error('Server error') + ); + await expect(cleanupUnusedChannelGroups()).rejects.toThrow('Server error'); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/utils/forms/__tests__/LiveGroupFilterUtils.test.js b/frontend/src/utils/forms/__tests__/LiveGroupFilterUtils.test.js new file mode 100644 index 00000000..5d27bff5 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/LiveGroupFilterUtils.test.js @@ -0,0 +1,273 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + getEPGs, + getSelectedAdvancedOptions, + applyAdvancedOptionsChange, + getEpgSourceValue, + getEpgSourceData, +} from '../LiveGroupFilterUtils.js'; + +// ── API mock ───────────────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { getEPGs: vi.fn() }, +})); + +import API from '../../../api.js'; + +// ── Helpers ────────────────────────────────────────────────────────────────── +const makeEpgSource = (overrides = {}) => ({ + id: 1, + name: 'Source One', + source_type: 'xmltv', + ...overrides, +}); + +describe('LiveGroupFilterUtils', () => { + // ── getEPGs ──────────────────────────────────────────────────────────────── + + describe('getEPGs', () => { + it('delegates to API.getEPGs', () => { + const result = [makeEpgSource()]; + vi.mocked(API.getEPGs).mockResolvedValue(result); + expect(getEPGs()).resolves.toEqual(result); + expect(API.getEPGs).toHaveBeenCalledOnce(); + }); + }); + +// ── getSelectedAdvancedOptions ─────────────────────────────────────────────── + + describe('getSelectedAdvancedOptions', () => { + it('returns empty array when custom_properties is empty', () => { + expect(getSelectedAdvancedOptions({})).toEqual([]); + }); + + it('returns empty array when custom_properties is nullish', () => { + expect(getSelectedAdvancedOptions(null)).toEqual([]); + expect(getSelectedAdvancedOptions(undefined)).toEqual([]); + }); + + it('detects force_epg via custom_epg_id', () => { + expect(getSelectedAdvancedOptions({ custom_epg_id: 5 })).toContain('force_epg'); + }); + + it('detects force_epg via force_dummy_epg', () => { + expect(getSelectedAdvancedOptions({ force_dummy_epg: true })).toContain('force_epg'); + }); + + it('detects force_epg via force_epg_selected', () => { + expect(getSelectedAdvancedOptions({ force_epg_selected: true })).toContain('force_epg'); + }); + + it('detects group_override', () => { + expect(getSelectedAdvancedOptions({ group_override: null })).toContain('group_override'); + }); + + it('detects name_regex via name_regex_pattern', () => { + expect(getSelectedAdvancedOptions({ name_regex_pattern: '' })).toContain('name_regex'); + }); + + it('detects name_regex via name_replace_pattern', () => { + expect(getSelectedAdvancedOptions({ name_replace_pattern: '' })).toContain('name_regex'); + }); + + it('detects name_match_regex', () => { + expect(getSelectedAdvancedOptions({ name_match_regex: '' })).toContain('name_match_regex'); + }); + + it('detects profile_assignment via channel_profile_ids', () => { + expect(getSelectedAdvancedOptions({ channel_profile_ids: [] })).toContain('profile_assignment'); + }); + + it('detects channel_sort_order', () => { + expect(getSelectedAdvancedOptions({ channel_sort_order: 'name' })).toContain('channel_sort_order'); + }); + + it('detects stream_profile_assignment', () => { + expect(getSelectedAdvancedOptions({ stream_profile_id: null })).toContain('stream_profile_assignment'); + }); + + it('detects custom_logo', () => { + expect(getSelectedAdvancedOptions({ custom_logo_id: null })).toContain('custom_logo'); + }); + + it('returns multiple active options', () => { + const result = getSelectedAdvancedOptions({ + name_match_regex: '', + channel_sort_order: 'name', + }); + expect(result).toContain('name_match_regex'); + expect(result).toContain('channel_sort_order'); + expect(result).toHaveLength(2); + }); + }); + +// ── applyAdvancedOptionsChange ─────────────────────────────────────────────── + + describe('applyAdvancedOptionsChange', () => { + describe('adding options', () => { + it('adds force_epg defaults when newly selected', () => { + const result = applyAdvancedOptionsChange({}, ['force_epg']); + expect(result).toMatchObject({ force_dummy_epg: true }); + }); + + it('adds name_regex defaults when newly selected', () => { + const result = applyAdvancedOptionsChange({}, ['name_regex']); + expect(result).toMatchObject({ name_regex_pattern: '', name_replace_pattern: '' }); + }); + + it('adds channel_sort_order defaults including channel_sort_reverse', () => { + const result = applyAdvancedOptionsChange({}, ['channel_sort_order']); + expect(result).toMatchObject({ channel_sort_order: '', channel_sort_reverse: false }); + }); + + it('adds profile_assignment defaults', () => { + const result = applyAdvancedOptionsChange({}, ['profile_assignment']); + expect(result).toMatchObject({ channel_profile_ids: [] }); + }); + + it('adds custom_logo defaults', () => { + const result = applyAdvancedOptionsChange({}, ['custom_logo']); + expect(result).toMatchObject({ custom_logo_id: null }); + }); + + it('does not overwrite existing keys when option is already active', () => { + const prev = { name_match_regex: 'existing' }; + const result = applyAdvancedOptionsChange(prev, ['name_match_regex']); + expect(result.name_match_regex).toBe('existing'); + }); + + it('adds defaults for multiple options at once', () => { + const result = applyAdvancedOptionsChange({}, ['name_match_regex', 'custom_logo']); + expect(result).toMatchObject({ name_match_regex: '', custom_logo_id: null }); + }); + }); + + describe('removing options', () => { + it('removes force_epg keys when deselected', () => { + const prev = { force_dummy_epg: true, custom_epg_id: 3, force_epg_selected: true }; + const result = applyAdvancedOptionsChange(prev, []); + expect(result).not.toHaveProperty('force_dummy_epg'); + expect(result).not.toHaveProperty('custom_epg_id'); + expect(result).not.toHaveProperty('force_epg_selected'); + }); + + it('removes name_regex keys when deselected', () => { + const prev = { name_regex_pattern: 'foo', name_replace_pattern: 'bar' }; + const result = applyAdvancedOptionsChange(prev, []); + expect(result).not.toHaveProperty('name_regex_pattern'); + expect(result).not.toHaveProperty('name_replace_pattern'); + }); + + it('removes channel_sort_order and channel_sort_reverse when deselected', () => { + const prev = { channel_sort_order: 'name', channel_sort_reverse: true }; + const result = applyAdvancedOptionsChange(prev, []); + expect(result).not.toHaveProperty('channel_sort_order'); + expect(result).not.toHaveProperty('channel_sort_reverse'); + }); + + it('removes custom_logo_id when deselected', () => { + const prev = { custom_logo_id: 42 }; + const result = applyAdvancedOptionsChange(prev, []); + expect(result).not.toHaveProperty('custom_logo_id'); + }); + + it('does not remove keys for options that are still selected', () => { + const prev = { name_match_regex: 'foo', custom_logo_id: 1 }; + const result = applyAdvancedOptionsChange(prev, ['name_match_regex']); + expect(result).toHaveProperty('name_match_regex', 'foo'); + expect(result).not.toHaveProperty('custom_logo_id'); + }); + }); + + it('does not mutate the original object', () => { + const prev = { name_match_regex: 'foo' }; + applyAdvancedOptionsChange(prev, []); + expect(prev).toHaveProperty('name_match_regex', 'foo'); + }); + }); + +// ── getEpgSourceValue ──────────────────────────────────────────────────────── + + describe('getEpgSourceValue', () => { + it('returns custom_epg_id as string when set', () => { + const group = { custom_properties: { custom_epg_id: 7 } }; + expect(getEpgSourceValue(group)).toBe('7'); + }); + + it('returns "0" when force_dummy_epg is true and no custom_epg_id', () => { + const group = { custom_properties: { force_dummy_epg: true } }; + expect(getEpgSourceValue(group)).toBe('0'); + }); + + it('returns null when neither custom_epg_id nor force_dummy_epg is set', () => { + const group = { custom_properties: {} }; + expect(getEpgSourceValue(group)).toBeNull(); + }); + + it('prefers custom_epg_id over force_dummy_epg', () => { + const group = { custom_properties: { custom_epg_id: 3, force_dummy_epg: true } }; + expect(getEpgSourceValue(group)).toBe('3'); + }); + + it('returns null when custom_epg_id is explicitly null', () => { + const group = { custom_properties: { custom_epg_id: null } }; + expect(getEpgSourceValue(group)).toBeNull(); + }); + }); + +// ── getEpgSourceData ───────────────────────────────────────────────────────── + + describe('getEpgSourceData', () => { + it('always includes "No EPG (Disabled)" as the first entry', () => { + const result = getEpgSourceData([]); + expect(result[0]).toEqual({ value: '0', label: 'No EPG (Disabled)' }); + }); + + it('maps an xmltv source correctly', () => { + const result = getEpgSourceData([makeEpgSource({ id: 1, name: 'My XMLTV', source_type: 'xmltv' })]); + expect(result).toContainEqual({ value: '1', label: 'My XMLTV (XMLTV)' }); + }); + + it('maps a dummy source correctly', () => { + const result = getEpgSourceData([makeEpgSource({ id: 2, name: 'Dummy', source_type: 'dummy' })]); + expect(result).toContainEqual({ value: '2', label: 'Dummy (Dummy)' }); + }); + + it('maps a schedules_direct source correctly', () => { + const result = getEpgSourceData([ + makeEpgSource({ id: 3, name: 'SD', source_type: 'schedules_direct' }), + ]); + expect(result).toContainEqual({ value: '3', label: 'SD (Schedules Direct)' }); + }); + + it('falls back to raw source_type for unknown types', () => { + const result = getEpgSourceData([makeEpgSource({ id: 4, name: 'Other', source_type: 'iptv' })]); + expect(result).toContainEqual({ value: '4', label: 'Other (iptv)' }); + }); + + it('sorts sources alphabetically by name', () => { + const sources = [ + makeEpgSource({ id: 1, name: 'Zebra' }), + makeEpgSource({ id: 2, name: 'Apple' }), + makeEpgSource({ id: 3, name: 'Mango' }), + ]; + const result = getEpgSourceData(sources); + const labels = result.slice(1).map((r) => r.label.split(' (')[0]); + expect(labels).toEqual(['Apple', 'Mango', 'Zebra']); + }); + + it('does not mutate the original sources array', () => { + const sources = [ + makeEpgSource({ id: 1, name: 'Zebra' }), + makeEpgSource({ id: 2, name: 'Apple' }), + ]; + const original = [...sources]; + getEpgSourceData(sources); + expect(sources).toEqual(original); + }); + + it('returns only the "No EPG" entry when sources array is empty', () => { + expect(getEpgSourceData([])).toHaveLength(1); + }); + }); +}); diff --git a/frontend/src/utils/forms/__tests__/LogoUtils.test.js b/frontend/src/utils/forms/__tests__/LogoUtils.test.js new file mode 100644 index 00000000..be652758 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/LogoUtils.test.js @@ -0,0 +1,404 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + uploadLogo, + createLogo, + updateLogo, + getResolver, + getUploadErrorMessage, + getUpdateLogoErrorMessage, + validateFileSize, + releaseUrl, + getFilenameWithoutExtension, +} from '../LogoUtils.js'; + +// ── API mock ─────────────────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + uploadLogo: vi.fn(), + createLogo: vi.fn(), + updateLogo: vi.fn(), + }, +})); + +// ── @hookform/resolvers/yup mock ─────────────────────────────────────────────── +vi.mock('@hookform/resolvers/yup', () => ({ + yupResolver: vi.fn((schema) => ({ __resolver: true, schema })), +})); + +import API from '../../../api.js'; +import { yupResolver } from '@hookform/resolvers/yup'; + +describe('LogoUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── uploadLogo ───────────────────────────────────────────────────────────── + + describe('uploadLogo', () => { + const makeFile = () => new File(['content'], 'logo.png', { type: 'image/png' }); + const makeValues = (overrides = {}) => ({ name: 'My Logo', ...overrides }); + + it('calls API.uploadLogo with the selected file and values.name', async () => { + const file = makeFile(); + const values = makeValues(); + vi.mocked(API.uploadLogo).mockResolvedValue({ id: '1' }); + + await uploadLogo(file, values); + + expect(API.uploadLogo).toHaveBeenCalledWith(file, 'My Logo'); + }); + + it('returns the result of API.uploadLogo', async () => { + const mockResult = { id: '1', name: 'My Logo' }; + vi.mocked(API.uploadLogo).mockResolvedValue(mockResult); + + const result = await uploadLogo(makeFile(), makeValues()); + + expect(result).toEqual(mockResult); + }); + + it('calls API.uploadLogo exactly once', async () => { + vi.mocked(API.uploadLogo).mockResolvedValue({}); + + await uploadLogo(makeFile(), makeValues()); + + expect(API.uploadLogo).toHaveBeenCalledTimes(1); + }); + + it('propagates rejection from API.uploadLogo', async () => { + vi.mocked(API.uploadLogo).mockRejectedValue(new Error('Upload failed')); + + await expect(uploadLogo(makeFile(), makeValues())).rejects.toThrow('Upload failed'); + }); + }); + + // ── createLogo ───────────────────────────────────────────────────────────── + + describe('createLogo', () => { + const makeValues = () => ({ name: 'New Logo', url: 'https://example.com/logo.png' }); + + it('calls API.createLogo with the provided values', async () => { + const values = makeValues(); + vi.mocked(API.createLogo).mockResolvedValue({ id: '2' }); + + await createLogo(values); + + expect(API.createLogo).toHaveBeenCalledWith(values); + }); + + it('returns the result of API.createLogo', async () => { + const mockResult = { id: '2', name: 'New Logo' }; + vi.mocked(API.createLogo).mockResolvedValue(mockResult); + + const result = await createLogo(makeValues()); + + expect(result).toEqual(mockResult); + }); + + it('calls API.createLogo exactly once', async () => { + vi.mocked(API.createLogo).mockResolvedValue({}); + + await createLogo(makeValues()); + + expect(API.createLogo).toHaveBeenCalledTimes(1); + }); + + it('propagates rejection from API.createLogo', async () => { + vi.mocked(API.createLogo).mockRejectedValue(new Error('Create failed')); + + await expect(createLogo(makeValues())).rejects.toThrow('Create failed'); + }); + }); + + // ── updateLogo ───────────────────────────────────────────────────────────── + + describe('updateLogo', () => { + const makeLogo = (overrides = {}) => ({ id: 'logo-1', name: 'Old Logo', ...overrides }); + const makeValues = () => ({ name: 'Updated Logo', url: 'https://example.com/new.png' }); + + it('calls API.updateLogo with the logo id and values', async () => { + const logo = makeLogo(); + const values = makeValues(); + vi.mocked(API.updateLogo).mockResolvedValue({ id: 'logo-1' }); + + await updateLogo(logo, values); + + expect(API.updateLogo).toHaveBeenCalledWith('logo-1', values); + }); + + it('returns the result of API.updateLogo', async () => { + const mockResult = { id: 'logo-1', name: 'Updated Logo' }; + vi.mocked(API.updateLogo).mockResolvedValue(mockResult); + + const result = await updateLogo(makeLogo(), makeValues()); + + expect(result).toEqual(mockResult); + }); + + it('calls API.updateLogo exactly once', async () => { + vi.mocked(API.updateLogo).mockResolvedValue({}); + + await updateLogo(makeLogo(), makeValues()); + + expect(API.updateLogo).toHaveBeenCalledTimes(1); + }); + + it('uses only the id from the logo object', async () => { + const logo = makeLogo({ id: 'logo-99', name: 'Irrelevant' }); + vi.mocked(API.updateLogo).mockResolvedValue({}); + + await updateLogo(logo, makeValues()); + + expect(API.updateLogo).toHaveBeenCalledWith('logo-99', expect.anything()); + }); + + it('propagates rejection from API.updateLogo', async () => { + vi.mocked(API.updateLogo).mockRejectedValue(new Error('Update failed')); + + await expect(updateLogo(makeLogo(), makeValues())).rejects.toThrow('Update failed'); + }); + }); + + // ── getResolver ──────────────────────────────────────────────────────────── + + describe('getResolver', () => { + it('returns the result of yupResolver', () => { + const resolver = getResolver(); + + expect(yupResolver).toHaveBeenCalledTimes(1); + expect(resolver).toEqual(expect.objectContaining({ __resolver: true })); + }); + + it('passes a Yup schema to yupResolver', () => { + getResolver(); + + const [schema] = vi.mocked(yupResolver).mock.calls[0]; + expect(schema).toBeDefined(); + expect(typeof schema.validate).toBe('function'); + }); + + it('schema validates a valid name and URL', async () => { + getResolver(); + const [schema] = vi.mocked(yupResolver).mock.calls[0]; + + await expect( + schema.validate({ name: 'My Logo', url: 'https://example.com/logo.png' }) + ).resolves.not.toThrow(); + }); + + it('schema rejects missing name', async () => { + getResolver(); + const [schema] = vi.mocked(yupResolver).mock.calls[0]; + + await expect( + schema.validate({ name: '', url: 'https://example.com/logo.png' }) + ).rejects.toThrow('Name is required'); + }); + + it('schema rejects missing url', async () => { + getResolver(); + const [schema] = vi.mocked(yupResolver).mock.calls[0]; + + await expect( + schema.validate({ name: 'My Logo', url: '' }) + ).rejects.toThrow('URL is required'); + }); + + it('schema accepts a local /data/logos/ path as url', async () => { + getResolver(); + const [schema] = vi.mocked(yupResolver).mock.calls[0]; + + await expect( + schema.validate({ name: 'Local Logo', url: '/data/logos/my-logo.png' }) + ).resolves.not.toThrow(); + }); + + it('schema rejects an invalid url that is not a local path', async () => { + getResolver(); + const [schema] = vi.mocked(yupResolver).mock.calls[0]; + + await expect( + schema.validate({ name: 'Bad Logo', url: 'not-a-url' }) + ).rejects.toThrow('Must be a valid URL or local file path'); + }); + }); + + // ── getUploadErrorMessage ────────────────────────────────────────────────── + + describe('getUploadErrorMessage', () => { + it('returns timeout message for NETWORK_ERROR code', () => { + const result = getUploadErrorMessage({ code: 'NETWORK_ERROR' }); + expect(result).toBe('Upload timed out. Please try again.'); + }); + + it('returns timeout message when message includes "timeout"', () => { + const result = getUploadErrorMessage({ message: 'request timeout occurred' }); + expect(result).toBe('Upload timed out. Please try again.'); + }); + + it('returns file too large message for status 413', () => { + const result = getUploadErrorMessage({ status: 413 }); + expect(result).toBe('File too large. Please choose a smaller file.'); + }); + + it('returns body error message when body.error is present', () => { + const result = getUploadErrorMessage({ body: { error: 'Unsupported format' } }); + expect(result).toBe('Unsupported format'); + }); + + it('returns generic message when no specific condition matches', () => { + const result = getUploadErrorMessage({}); + expect(result).toBe('Failed to upload logo file'); + }); + + it('prioritizes NETWORK_ERROR over status 413', () => { + const result = getUploadErrorMessage({ code: 'NETWORK_ERROR', status: 413 }); + expect(result).toBe('Upload timed out. Please try again.'); + }); + + it('prioritizes status 413 over body.error', () => { + const result = getUploadErrorMessage({ status: 413, body: { error: 'Custom error' } }); + expect(result).toBe('File too large. Please choose a smaller file.'); + }); + }); + + // ── getUpdateLogoErrorMessage ────────────────────────────────────────────── + + describe('getUpdateLogoErrorMessage', () => { + const makeLogo = () => ({ id: 'logo-1', name: 'My Logo' }); + + it('returns timeout message for NETWORK_ERROR code', () => { + const result = getUpdateLogoErrorMessage(makeLogo(), { code: 'NETWORK_ERROR' }); + expect(result).toBe('Request timed out. Please try again.'); + }); + + it('returns timeout message when message includes "timeout"', () => { + const result = getUpdateLogoErrorMessage(makeLogo(), { message: 'connection timeout' }); + expect(result).toBe('Request timed out. Please try again.'); + }); + + it('returns response data error when present', () => { + const result = getUpdateLogoErrorMessage(makeLogo(), { + response: { data: { error: 'Duplicate name' } }, + }); + expect(result).toBe('Duplicate name'); + }); + + it('returns "Failed to update logo" when logo is provided and no specific error', () => { + const result = getUpdateLogoErrorMessage(makeLogo(), {}); + expect(result).toBe('Failed to update logo'); + }); + + it('returns "Failed to create logo" when logo is null', () => { + const result = getUpdateLogoErrorMessage(null, {}); + expect(result).toBe('Failed to create logo'); + }); + + it('returns "Failed to create logo" when logo is undefined', () => { + const result = getUpdateLogoErrorMessage(undefined, {}); + expect(result).toBe('Failed to create logo'); + }); + + it('prioritizes NETWORK_ERROR over response.data.error', () => { + const result = getUpdateLogoErrorMessage(makeLogo(), { + code: 'NETWORK_ERROR', + response: { data: { error: 'Custom' } }, + }); + expect(result).toBe('Request timed out. Please try again.'); + }); + }); + + // ── validateFileSize ─────────────────────────────────────────────────────── + + describe('validateFileSize', () => { + const makeFile = (sizeBytes) => + ({ size: sizeBytes }); + + it('returns true for a file exactly at the 5MB limit', () => { + const file = makeFile(5 * 1024 * 1024); + expect(validateFileSize(file)).toBe(true); + }); + + it('returns true for a file smaller than 5MB', () => { + const file = makeFile(1024 * 1024); + expect(validateFileSize(file)).toBe(true); + }); + + it('returns false for a file larger than 5MB', () => { + const file = makeFile(5 * 1024 * 1024 + 1); + expect(validateFileSize(file)).toBe(false); + }); + + it('returns true for a zero-byte file', () => { + const file = makeFile(0); + expect(validateFileSize(file)).toBe(true); + }); + }); + + // ── releaseUrl ───────────────────────────────────────────────────────────── + + describe('releaseUrl', () => { + beforeEach(() => { + URL.revokeObjectURL = vi.fn(); + }); + + afterEach(() => { + delete URL.revokeObjectURL; + }); + + it('calls URL.revokeObjectURL for a blob URL', () => { + releaseUrl('blob:https://example.com/1234'); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:https://example.com/1234'); + }); + + it('does not call URL.revokeObjectURL for a non-blob URL', () => { + releaseUrl('https://example.com/logo.png'); + expect(URL.revokeObjectURL).not.toHaveBeenCalled(); + }); + + it('does not throw when url is null', () => { + expect(() => releaseUrl(null)).not.toThrow(); + }); + + it('does not throw when url is undefined', () => { + expect(() => releaseUrl(undefined)).not.toThrow(); + }); + + it('does not throw when url is an empty string', () => { + expect(() => releaseUrl('')).not.toThrow(); + }); + }); + + // ── getFilenameWithoutExtension ──────────────────────────────────────────── + + describe('getFilenameWithoutExtension', () => { + it('removes a single extension from a filename', () => { + expect(getFilenameWithoutExtension('logo.png')).toBe('logo'); + }); + + it('removes only the last extension from a filename with multiple dots', () => { + expect(getFilenameWithoutExtension('my.logo.png')).toBe('my.logo'); + }); + + it('handles filenames with no extension', () => { + expect(getFilenameWithoutExtension('logo')).toBe('logo'); + }); + + it('handles filenames with a .jpg extension', () => { + expect(getFilenameWithoutExtension('channel-logo.jpg')).toBe('channel-logo'); + }); + + it('handles filenames with a .svg extension', () => { + expect(getFilenameWithoutExtension('icon.svg')).toBe('icon'); + }); + + it('handles an empty string', () => { + expect(getFilenameWithoutExtension('')).toBe(''); + }); + + it('handles a filename that is just an extension', () => { + expect(getFilenameWithoutExtension('.png')).toBe(''); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/utils/forms/__tests__/M3uFilterUtils.test.js b/frontend/src/utils/forms/__tests__/M3uFilterUtils.test.js new file mode 100644 index 00000000..cb9aa962 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/M3uFilterUtils.test.js @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + addM3UFilter, + updateM3UFilter, + deleteM3UFilter, +} from '../M3uFilterUtils.js'; + +// ── API mock ─────────────────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + addM3UFilter: vi.fn(), + updateM3UFilter: vi.fn(), + deleteM3UFilter: vi.fn(), + }, +})); + +import API from '../../../api.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeM3U = (overrides = {}) => ({ id: 'm3u-1', name: 'My Playlist', ...overrides }); +const makeFilter = (overrides = {}) => ({ id: 'filter-1', name: 'Filter A', ...overrides }); +const makeValues = (overrides = {}) => ({ keyword: 'sports', type: 'include', ...overrides }); + +describe('M3uFilterUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── addM3UFilter ─────────────────────────────────────────────────────────── + + describe('addM3UFilter', () => { + it('calls API.addM3UFilter with the m3u id and values', async () => { + vi.mocked(API.addM3UFilter).mockResolvedValue(undefined); + + await addM3UFilter(makeM3U(), makeValues()); + + expect(API.addM3UFilter).toHaveBeenCalledWith('m3u-1', makeValues()); + }); + + it('calls API.addM3UFilter exactly once', async () => { + vi.mocked(API.addM3UFilter).mockResolvedValue(undefined); + + await addM3UFilter(makeM3U(), makeValues()); + + expect(API.addM3UFilter).toHaveBeenCalledTimes(1); + }); + + it('uses only the id from the m3u object', async () => { + vi.mocked(API.addM3UFilter).mockResolvedValue(undefined); + const m3u = makeM3U({ id: 'm3u-99', name: 'Irrelevant' }); + + await addM3UFilter(m3u, makeValues()); + + expect(API.addM3UFilter).toHaveBeenCalledWith('m3u-99', expect.anything()); + }); + + it('passes values through unmodified', async () => { + vi.mocked(API.addM3UFilter).mockResolvedValue(undefined); + const values = { keyword: 'news', type: 'exclude', extra: 'data' }; + + await addM3UFilter(makeM3U(), values); + + expect(API.addM3UFilter).toHaveBeenCalledWith(expect.anything(), values); + }); + + it('propagates rejection from API.addM3UFilter', async () => { + vi.mocked(API.addM3UFilter).mockRejectedValue(new Error('Add failed')); + + await expect(addM3UFilter(makeM3U(), makeValues())).rejects.toThrow('Add failed'); + }); + + it('resolves without returning a value', async () => { + vi.mocked(API.addM3UFilter).mockResolvedValue({ id: 'filter-2' }); + + const result = await addM3UFilter(makeM3U(), makeValues()); + + expect(result).toBeUndefined(); + }); + }); + + // ── updateM3UFilter ──────────────────────────────────────────────────────── + + describe('updateM3UFilter', () => { + it('calls API.updateM3UFilter with the m3u id, filter id, and values', () => { + vi.mocked(API.updateM3UFilter).mockReturnValue({ id: 'filter-1' }); + + updateM3UFilter(makeM3U(), makeFilter(), makeValues()); + + expect(API.updateM3UFilter).toHaveBeenCalledWith('m3u-1', 'filter-1', makeValues()); + }); + + it('returns the result of API.updateM3UFilter', () => { + const mockResult = { id: 'filter-1', keyword: 'sports' }; + vi.mocked(API.updateM3UFilter).mockReturnValue(mockResult); + + const result = updateM3UFilter(makeM3U(), makeFilter(), makeValues()); + + expect(result).toBe(mockResult); + }); + + it('calls API.updateM3UFilter exactly once', () => { + vi.mocked(API.updateM3UFilter).mockReturnValue({}); + + updateM3UFilter(makeM3U(), makeFilter(), makeValues()); + + expect(API.updateM3UFilter).toHaveBeenCalledTimes(1); + }); + + it('uses only the id from the m3u object', () => { + vi.mocked(API.updateM3UFilter).mockReturnValue({}); + const m3u = makeM3U({ id: 'm3u-42', name: 'Irrelevant' }); + + updateM3UFilter(m3u, makeFilter(), makeValues()); + + expect(API.updateM3UFilter).toHaveBeenCalledWith('m3u-42', expect.anything(), expect.anything()); + }); + + it('uses only the id from the filter object', () => { + vi.mocked(API.updateM3UFilter).mockReturnValue({}); + const filter = makeFilter({ id: 'filter-99', name: 'Irrelevant' }); + + updateM3UFilter(makeM3U(), filter, makeValues()); + + expect(API.updateM3UFilter).toHaveBeenCalledWith(expect.anything(), 'filter-99', expect.anything()); + }); + + it('passes values through unmodified', () => { + vi.mocked(API.updateM3UFilter).mockReturnValue({}); + const values = { keyword: 'movies', type: 'exclude', extra: 'data' }; + + updateM3UFilter(makeM3U(), makeFilter(), values); + + expect(API.updateM3UFilter).toHaveBeenCalledWith(expect.anything(), expect.anything(), values); + }); + }); + + // ── deleteM3UFilter ──────────────────────────────────────────────────────── + + describe('deleteM3UFilter', () => { + it('calls API.deleteM3UFilter with the playlist id and filter id', async () => { + vi.mocked(API.deleteM3UFilter).mockResolvedValue(undefined); + + await deleteM3UFilter(makeM3U(), 'filter-1'); + + expect(API.deleteM3UFilter).toHaveBeenCalledWith('m3u-1', 'filter-1'); + }); + + it('calls API.deleteM3UFilter exactly once', async () => { + vi.mocked(API.deleteM3UFilter).mockResolvedValue(undefined); + + await deleteM3UFilter(makeM3U(), 'filter-1'); + + expect(API.deleteM3UFilter).toHaveBeenCalledTimes(1); + }); + + it('uses only the id from the playlist object', async () => { + vi.mocked(API.deleteM3UFilter).mockResolvedValue(undefined); + const playlist = makeM3U({ id: 'playlist-99', name: 'Irrelevant' }); + + await deleteM3UFilter(playlist, 'filter-1'); + + expect(API.deleteM3UFilter).toHaveBeenCalledWith('playlist-99', expect.anything()); + }); + + it('passes the filter id through unmodified', async () => { + vi.mocked(API.deleteM3UFilter).mockResolvedValue(undefined); + + await deleteM3UFilter(makeM3U(), 'filter-42'); + + expect(API.deleteM3UFilter).toHaveBeenCalledWith(expect.anything(), 'filter-42'); + }); + + it('propagates rejection from API.deleteM3UFilter', async () => { + vi.mocked(API.deleteM3UFilter).mockRejectedValue(new Error('Delete failed')); + + await expect(deleteM3UFilter(makeM3U(), 'filter-1')).rejects.toThrow('Delete failed'); + }); + + it('resolves without returning a value', async () => { + vi.mocked(API.deleteM3UFilter).mockResolvedValue({ success: true }); + + const result = await deleteM3UFilter(makeM3U(), 'filter-1'); + + expect(result).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/utils/forms/__tests__/M3uGroupFilterUtils.test.js b/frontend/src/utils/forms/__tests__/M3uGroupFilterUtils.test.js new file mode 100644 index 00000000..f2dfe038 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/M3uGroupFilterUtils.test.js @@ -0,0 +1,352 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + buildGroupStates, + saveAndRefreshPlaylist, +} from '../M3uGroupFilterUtils.js'; + +// ── API mock ─────────────────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + updateM3UGroupSettings: vi.fn(), + }, +})); + +// ── M3uUtils mock ────────────────────────────────────────────────────────────── +vi.mock('../M3uUtils.js', () => ({ + refreshPlaylist: vi.fn(), + updatePlaylist: vi.fn(), +})); + +import API from '../../../api.js'; +import { refreshPlaylist, updatePlaylist } from '../M3uUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makePlaylist = (overrides = {}) => ({ id: 'playlist-1', name: 'My Playlist', ...overrides }); + +const makeChannelGroups = () => ({ + 'group-1': { name: 'Sports' }, + 'group-2': { name: 'News' }, + 'group-3': { name: 'Movies' }, +}); + +const makePlaylistChannelGroup = (overrides = {}) => ({ + channel_group: 'group-1', + enabled: true, + original_enabled: false, + auto_channel_sync: false, + auto_sync_channel_start: 1.0, + custom_properties: null, + ...overrides, +}); + +const makeCategoryState = (overrides = {}) => ({ + id: 'cat-1', + enabled: true, + original_enabled: false, + custom_properties: null, + ...overrides, +}); + +const makeAutoEnableSettings = () => ({ auto_enable: true }); + +describe('M3uGroupFilterUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(updatePlaylist).mockResolvedValue(undefined); + vi.mocked(API.updateM3UGroupSettings).mockResolvedValue(undefined); + vi.mocked(refreshPlaylist).mockResolvedValue(undefined); + }); + + // ── buildGroupStates ─────────────────────────────────────────────────────── + + describe('buildGroupStates', () => { + it('maps playlistChannelGroups to group states with channel group names', () => { + const channelGroups = makeChannelGroups(); + const playlistChannelGroups = [makePlaylistChannelGroup()]; + + const result = buildGroupStates(channelGroups, playlistChannelGroups); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('Sports'); + }); + + it('filters out groups whose channel_group key is not in channelGroups', () => { + const channelGroups = makeChannelGroups(); + const playlistChannelGroups = [ + makePlaylistChannelGroup({ channel_group: 'group-1' }), + makePlaylistChannelGroup({ channel_group: 'group-unknown' }), + ]; + + const result = buildGroupStates(channelGroups, playlistChannelGroups); + + expect(result).toHaveLength(1); + expect(result[0].channel_group).toBe('group-1'); + }); + + it('returns an empty array when playlistChannelGroups is empty', () => { + const result = buildGroupStates(makeChannelGroups(), []); + expect(result).toEqual([]); + }); + + it('returns an empty array when no groups match channelGroups', () => { + const result = buildGroupStates(makeChannelGroups(), [ + makePlaylistChannelGroup({ channel_group: 'group-unknown' }), + ]); + expect(result).toEqual([]); + }); + + it('defaults auto_channel_sync to false when not set', () => { + const result = buildGroupStates(makeChannelGroups(), [ + makePlaylistChannelGroup({ auto_channel_sync: undefined }), + ]); + expect(result[0].auto_channel_sync).toBe(false); + }); + + it('preserves auto_channel_sync when set to true', () => { + const result = buildGroupStates(makeChannelGroups(), [ + makePlaylistChannelGroup({ auto_channel_sync: true }), + ]); + expect(result[0].auto_channel_sync).toBe(true); + }); + + it('defaults auto_sync_channel_start to 1.0 when not set', () => { + const result = buildGroupStates(makeChannelGroups(), [ + makePlaylistChannelGroup({ auto_sync_channel_start: undefined }), + ]); + expect(result[0].auto_sync_channel_start).toBe(1.0); + }); + + it('preserves auto_sync_channel_start when set', () => { + const result = buildGroupStates(makeChannelGroups(), [ + makePlaylistChannelGroup({ auto_sync_channel_start: 5.0 }), + ]); + expect(result[0].auto_sync_channel_start).toBe(5.0); + }); + + it('spreads all original group properties into the result', () => { + const group = makePlaylistChannelGroup({ enabled: true, extra_prop: 'value' }); + const result = buildGroupStates(makeChannelGroups(), [group]); + expect(result[0].enabled).toBe(true); + expect(result[0].extra_prop).toBe('value'); + }); + + it('maps multiple groups correctly', () => { + const channelGroups = makeChannelGroups(); + const playlistChannelGroups = [ + makePlaylistChannelGroup({ channel_group: 'group-1' }), + makePlaylistChannelGroup({ channel_group: 'group-2' }), + makePlaylistChannelGroup({ channel_group: 'group-3' }), + ]; + + const result = buildGroupStates(channelGroups, playlistChannelGroups); + + expect(result).toHaveLength(3); + expect(result.map((r) => r.name)).toEqual(['Sports', 'News', 'Movies']); + }); + + // ── parseCustomProperties (via buildGroupStates) ───────────────────────── + + describe('parseCustomProperties (via custom_properties field)', () => { + it('returns {} when custom_properties is null', () => { + const result = buildGroupStates(makeChannelGroups(), [ + makePlaylistChannelGroup({ custom_properties: null }), + ]); + expect(result[0].custom_properties).toEqual({}); + }); + + it('returns {} when custom_properties is undefined', () => { + const result = buildGroupStates(makeChannelGroups(), [ + makePlaylistChannelGroup({ custom_properties: undefined }), + ]); + expect(result[0].custom_properties).toEqual({}); + }); + + it('parses a valid JSON string into an object', () => { + const result = buildGroupStates(makeChannelGroups(), [ + makePlaylistChannelGroup({ custom_properties: '{"key":"value"}' }), + ]); + expect(result[0].custom_properties).toEqual({ key: 'value' }); + }); + + it('returns an already-parsed object as-is', () => { + const result = buildGroupStates(makeChannelGroups(), [ + makePlaylistChannelGroup({ custom_properties: { key: 'value' } }), + ]); + expect(result[0].custom_properties).toEqual({ key: 'value' }); + }); + + it('returns {} for invalid JSON string', () => { + const result = buildGroupStates(makeChannelGroups(), [ + makePlaylistChannelGroup({ custom_properties: '{invalid-json}' }), + ]); + expect(result[0].custom_properties).toEqual({}); + }); + }); + }); + + // ── saveAndRefreshPlaylist ───────────────────────────────────────────────── + + describe('saveAndRefreshPlaylist', () => { + it('calls updatePlaylist with playlist and autoEnableSettings', async () => { + const playlist = makePlaylist(); + const autoEnableSettings = makeAutoEnableSettings(); + + await saveAndRefreshPlaylist(playlist, [], [], [], autoEnableSettings); + + expect(updatePlaylist).toHaveBeenCalledWith(playlist, autoEnableSettings); + }); + + it('calls API.updateM3UGroupSettings with playlist id, groupSettings, and categorySettings', async () => { + const playlist = makePlaylist(); + + await saveAndRefreshPlaylist(playlist, [], [], [], makeAutoEnableSettings()); + + expect(API.updateM3UGroupSettings).toHaveBeenCalledWith( + 'playlist-1', + expect.anything(), + expect.anything() + ); + }); + + it('calls refreshPlaylist with playlist', async () => { + const playlist = makePlaylist(); + + await saveAndRefreshPlaylist(playlist, [], [], [], makeAutoEnableSettings()); + + expect(refreshPlaylist).toHaveBeenCalledWith(playlist); + }); + + it('calls updatePlaylist, updateM3UGroupSettings, and refreshPlaylist exactly once each', async () => { + await saveAndRefreshPlaylist(makePlaylist(), [], [], [], makeAutoEnableSettings()); + + expect(updatePlaylist).toHaveBeenCalledTimes(1); + expect(API.updateM3UGroupSettings).toHaveBeenCalledTimes(1); + expect(refreshPlaylist).toHaveBeenCalledTimes(1); + }); + + it('calls updatePlaylist before updateM3UGroupSettings', async () => { + const callOrder = []; + vi.mocked(updatePlaylist).mockImplementation(async () => { callOrder.push('updatePlaylist'); }); + vi.mocked(API.updateM3UGroupSettings).mockImplementation(async () => { callOrder.push('updateM3UGroupSettings'); }); + vi.mocked(refreshPlaylist).mockImplementation(async () => { callOrder.push('refreshPlaylist'); }); + + await saveAndRefreshPlaylist(makePlaylist(), [], [], [], makeAutoEnableSettings()); + + expect(callOrder.indexOf('updatePlaylist')).toBeLessThan( + callOrder.indexOf('updateM3UGroupSettings') + ); + }); + + it('calls updateM3UGroupSettings before refreshPlaylist', async () => { + const callOrder = []; + vi.mocked(updatePlaylist).mockImplementation(async () => { callOrder.push('updatePlaylist'); }); + vi.mocked(API.updateM3UGroupSettings).mockImplementation(async () => { callOrder.push('updateM3UGroupSettings'); }); + vi.mocked(refreshPlaylist).mockImplementation(async () => { callOrder.push('refreshPlaylist'); }); + + await saveAndRefreshPlaylist(makePlaylist(), [], [], [], makeAutoEnableSettings()); + + expect(callOrder.indexOf('updateM3UGroupSettings')).toBeLessThan( + callOrder.indexOf('refreshPlaylist') + ); + }); + + it('propagates rejection from updatePlaylist', async () => { + vi.mocked(updatePlaylist).mockRejectedValue(new Error('Update failed')); + + await expect( + saveAndRefreshPlaylist(makePlaylist(), [], [], [], makeAutoEnableSettings()) + ).rejects.toThrow('Update failed'); + }); + + it('propagates rejection from API.updateM3UGroupSettings', async () => { + vi.mocked(API.updateM3UGroupSettings).mockRejectedValue(new Error('Settings failed')); + + await expect( + saveAndRefreshPlaylist(makePlaylist(), [], [], [], makeAutoEnableSettings()) + ).rejects.toThrow('Settings failed'); + }); + + it('propagates rejection from refreshPlaylist', async () => { + vi.mocked(refreshPlaylist).mockRejectedValue(new Error('Refresh failed')); + + await expect( + saveAndRefreshPlaylist(makePlaylist(), [], [], [], makeAutoEnableSettings()) + ).rejects.toThrow('Refresh failed'); + }); + + it('does not call refreshPlaylist when updateM3UGroupSettings rejects', async () => { + vi.mocked(API.updateM3UGroupSettings).mockRejectedValue(new Error('fail')); + + await saveAndRefreshPlaylist(makePlaylist(), [], [], [], makeAutoEnableSettings()).catch(() => {}); + + expect(refreshPlaylist).not.toHaveBeenCalled(); + }); + + // ── prepareCategorySettings (via categorySettings arg) ─────────────────── + + describe('prepareCategorySettings (via API.updateM3UGroupSettings call)', () => { + it('includes category states where enabled differs from original_enabled', async () => { + const changedMovie = makeCategoryState({ enabled: true, original_enabled: false }); + const unchangedMovie = makeCategoryState({ id: 'cat-2', enabled: false, original_enabled: false }); + + await saveAndRefreshPlaylist( + makePlaylist(), [], [changedMovie], [unchangedMovie], makeAutoEnableSettings() + ); + + const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings).mock.calls[0]; + expect(categorySettings).toHaveLength(1); + expect(categorySettings[0].id).toBe('cat-1'); + }); + + it('excludes category states where enabled equals original_enabled', async () => { + const unchanged = makeCategoryState({ enabled: true, original_enabled: true }); + + await saveAndRefreshPlaylist( + makePlaylist(), [], [unchanged], [], makeAutoEnableSettings() + ); + + const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings).mock.calls[0]; + expect(categorySettings).toHaveLength(0); + }); + + it('merges movie and series category states together', async () => { + const movieCat = makeCategoryState({ id: 'movie-1', enabled: true, original_enabled: false }); + const seriesCat = makeCategoryState({ id: 'series-1', enabled: false, original_enabled: true }); + + await saveAndRefreshPlaylist( + makePlaylist(), [], [movieCat], [seriesCat], makeAutoEnableSettings() + ); + + const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings).mock.calls[0]; + expect(categorySettings).toHaveLength(2); + expect(categorySettings.map((c) => c.id)).toEqual(['movie-1', 'series-1']); + }); + + it('sets custom_properties to undefined when null', async () => { + const cat = makeCategoryState({ custom_properties: null, enabled: true, original_enabled: false }); + + await saveAndRefreshPlaylist( + makePlaylist(), [], [cat], [], makeAutoEnableSettings() + ); + + const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings).mock.calls[0]; + expect(categorySettings[0].custom_properties).toBeUndefined(); + }); + + it('preserves custom_properties when present', async () => { + const cat = makeCategoryState({ + custom_properties: { key: 'value' }, + enabled: true, + original_enabled: false, + }); + + await saveAndRefreshPlaylist( + makePlaylist(), [], [cat], [], makeAutoEnableSettings() + ); + + const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings).mock.calls[0]; + expect(categorySettings[0].custom_properties).toEqual({ key: 'value' }); + }); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/utils/forms/__tests__/M3uProfileUtils.test.js b/frontend/src/utils/forms/__tests__/M3uProfileUtils.test.js new file mode 100644 index 00000000..ecd5fad9 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/M3uProfileUtils.test.js @@ -0,0 +1,451 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as Yup from 'yup'; +import { + updateM3UProfile, + addM3UProfile, + deleteM3UProfile, + getDetectedMode, + applyRegex, + buildProfileSchema, + fetchFirstStreamUrl, + validateXcSimple, + prepareExpDate, + applyXcSimplePatterns, + buildSubmitValues, +} from '../M3uProfileUtils.js'; + +vi.mock('../../../api.js', () => ({ + default: { + updateM3UProfile: vi.fn(), + addM3UProfile: vi.fn(), + deleteM3UProfile: vi.fn(), + queryStreams: vi.fn(), + }, +})); + +import API from '../../../api.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeM3U = (overrides = {}) => ({ id: 'm3u-1', name: 'My Playlist', ...overrides }); +const makeSubmitValues = (overrides = {}) => ({ name: 'Profile A', xcMode: false, ...overrides }); + +describe('M3uProfileUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── updateM3UProfile ─────────────────────────────────────────────────────── + + describe('updateM3UProfile', () => { + it('calls API.updateM3UProfile with the m3u id and submitValues', async () => { + vi.mocked(API.updateM3UProfile).mockResolvedValue(undefined); + const m3u = makeM3U(); + const values = makeSubmitValues(); + + await updateM3UProfile(m3u, values); + + expect(API.updateM3UProfile).toHaveBeenCalledWith('m3u-1', values); + }); + + it('calls API.updateM3UProfile exactly once', async () => { + vi.mocked(API.updateM3UProfile).mockResolvedValue(undefined); + + await updateM3UProfile(makeM3U(), makeSubmitValues()); + + expect(API.updateM3UProfile).toHaveBeenCalledTimes(1); + }); + + it('uses only the id from the m3u object', async () => { + vi.mocked(API.updateM3UProfile).mockResolvedValue(undefined); + const m3u = makeM3U({ id: 'playlist-99', name: 'Irrelevant' }); + + await updateM3UProfile(m3u, makeSubmitValues()); + + expect(API.updateM3UProfile).toHaveBeenCalledWith('playlist-99', expect.anything()); + }); + + it('passes submitValues through unmodified', async () => { + vi.mocked(API.updateM3UProfile).mockResolvedValue(undefined); + const values = makeSubmitValues({ xcMode: true, extra: 'data' }); + + await updateM3UProfile(makeM3U(), values); + + expect(API.updateM3UProfile).toHaveBeenCalledWith(expect.anything(), values); + }); + + it('propagates rejection from API.updateM3UProfile', async () => { + vi.mocked(API.updateM3UProfile).mockRejectedValue(new Error('Update failed')); + + await expect(updateM3UProfile(makeM3U(), makeSubmitValues())).rejects.toThrow('Update failed'); + }); + + it('resolves without returning a value', async () => { + vi.mocked(API.updateM3UProfile).mockResolvedValue({ success: true }); + + const result = await updateM3UProfile(makeM3U(), makeSubmitValues()); + + expect(result).toBeUndefined(); + }); + }); + +// ── addM3UProfile ────────────────────────────────────────────────────────── + + describe('addM3UProfile', () => { + it('calls API.addM3UProfile with the m3u id and submitValues', async () => { + vi.mocked(API.addM3UProfile).mockResolvedValue(undefined); + const m3u = makeM3U(); + const values = makeSubmitValues(); + + await addM3UProfile(m3u, values); + + expect(API.addM3UProfile).toHaveBeenCalledWith('m3u-1', values); + }); + + it('calls API.addM3UProfile exactly once', async () => { + vi.mocked(API.addM3UProfile).mockResolvedValue(undefined); + + await addM3UProfile(makeM3U(), makeSubmitValues()); + + expect(API.addM3UProfile).toHaveBeenCalledTimes(1); + }); + + it('uses only the id from the m3u object', async () => { + vi.mocked(API.addM3UProfile).mockResolvedValue(undefined); + const m3u = makeM3U({ id: 'playlist-42', name: 'Irrelevant' }); + + await addM3UProfile(m3u, makeSubmitValues()); + + expect(API.addM3UProfile).toHaveBeenCalledWith('playlist-42', expect.anything()); + }); + + it('passes submitValues through unmodified', async () => { + vi.mocked(API.addM3UProfile).mockResolvedValue(undefined); + const values = makeSubmitValues({ xcMode: true, extra: 'data' }); + + await addM3UProfile(makeM3U(), values); + + expect(API.addM3UProfile).toHaveBeenCalledWith(expect.anything(), values); + }); + + it('propagates rejection from API.addM3UProfile', async () => { + vi.mocked(API.addM3UProfile).mockRejectedValue(new Error('Add failed')); + + await expect(addM3UProfile(makeM3U(), makeSubmitValues())).rejects.toThrow('Add failed'); + }); + + it('resolves without returning a value', async () => { + vi.mocked(API.addM3UProfile).mockResolvedValue({ id: 'new-profile' }); + + const result = await addM3UProfile(makeM3U(), makeSubmitValues()); + + expect(result).toBeUndefined(); + }); + }); + +// ── deleteM3UProfile ─────────────────────────────────────────────────────── + + describe('deleteM3UProfile', () => { + it('calls API.deleteM3UProfile with the playlist id and profile id', async () => { + vi.mocked(API.deleteM3UProfile).mockResolvedValue(undefined); + + await deleteM3UProfile(makeM3U(), 'profile-1'); + + expect(API.deleteM3UProfile).toHaveBeenCalledWith('m3u-1', 'profile-1'); + }); + + it('calls API.deleteM3UProfile exactly once', async () => { + vi.mocked(API.deleteM3UProfile).mockResolvedValue(undefined); + + await deleteM3UProfile(makeM3U(), 'profile-1'); + + expect(API.deleteM3UProfile).toHaveBeenCalledTimes(1); + }); + + it('uses only the id from the playlist object', async () => { + vi.mocked(API.deleteM3UProfile).mockResolvedValue(undefined); + const playlist = makeM3U({ id: 'playlist-99', name: 'Irrelevant' }); + + await deleteM3UProfile(playlist, 'profile-1'); + + expect(API.deleteM3UProfile).toHaveBeenCalledWith('playlist-99', expect.anything()); + }); + + it('passes the profile id through unmodified', async () => { + vi.mocked(API.deleteM3UProfile).mockResolvedValue(undefined); + + await deleteM3UProfile(makeM3U(), 'profile-42'); + + expect(API.deleteM3UProfile).toHaveBeenCalledWith(expect.anything(), 'profile-42'); + }); + + it('propagates rejection from API.deleteM3UProfile', async () => { + vi.mocked(API.deleteM3UProfile).mockRejectedValue(new Error('Delete failed')); + + await expect(deleteM3UProfile(makeM3U(), 'profile-1')).rejects.toThrow('Delete failed'); + }); + + it('resolves without returning a value', async () => { + vi.mocked(API.deleteM3UProfile).mockResolvedValue({ success: true }); + + const result = await deleteM3UProfile(makeM3U(), 'profile-1'); + + expect(result).toBeUndefined(); + }); + }); + + // ── getDetectedMode ─────────────────────────────────────────────────────── + + describe('getDetectedMode', () => { + it('returns storedMode when provided', () => { + expect(getDetectedMode('advanced', null, null)).toBe('advanced'); + }); + + it('returns "simple" when search_pattern matches username/password', () => { + const m3u = { username: 'user', password: 'pass' }; + const profile = { search_pattern: 'user/pass' }; + expect(getDetectedMode(null, profile, m3u)).toBe('simple'); + }); + + it('returns "advanced" when search_pattern exists but does not match username/password', () => { + const m3u = { username: 'user', password: 'pass' }; + const profile = { search_pattern: 'other/pattern' }; + expect(getDetectedMode(null, profile, m3u)).toBe('advanced'); + }); + + it('returns "simple" when no storedMode and no profile', () => { + expect(getDetectedMode(null, null, null)).toBe('simple'); + }); + + it('returns "simple" when profile has no search_pattern', () => { + const profile = {}; + expect(getDetectedMode(null, profile, { username: 'u', password: 'p' })).toBe('simple'); + }); + }); + + // ── applyRegex ─────────────────────────────────────────────────────────── + + describe('applyRegex', () => { + it('returns input when pattern is empty', () => { + expect(applyRegex('hello world', '', 'X')).toBe('hello world'); + }); + + it('returns input when input is empty/null', () => { + expect(applyRegex(null, 'pattern', 'X')).toBeNull(); + expect(applyRegex('', 'pattern', 'X')).toBe(''); + }); + + it('applies regex replacement globally', () => { + expect(applyRegex('aabaa', 'a', 'X')).toBe('XXbXX'); + }); + + it('returns input when regex is invalid', () => { + expect(applyRegex('hello', '[invalid', 'X')).toBe('hello'); + }); + + it('replaces matched groups', () => { + expect(applyRegex('foo/bar', '(foo)', 'baz')).toBe('baz/bar'); + }); + }); + + // ── buildProfileSchema ───────────────────────────────────────────────── + + describe('buildProfileSchema', () => { + it('requires search_pattern and replace_pattern when not default and not XC', async () => { + const schema = buildProfileSchema(false, false); + await expect(schema.validate({ name: 'Test' }, { abortEarly: false })).rejects.toThrow(); + const error = await schema.validate({ name: 'Test' }, { abortEarly: false }).catch((e) => e); + expect(error.errors).toContain('Search pattern is required'); + expect(error.errors).toContain('Replace pattern is required'); + }); + + it('does not require search_pattern and replace_pattern for default profile', async () => { + const schema = buildProfileSchema(true, false); + await expect(schema.validate({ name: 'Test' })).resolves.toBeTruthy(); + }); + + it('does not require search_pattern and replace_pattern when isXC is true', async () => { + const schema = buildProfileSchema(false, true); + await expect(schema.validate({ name: 'Test' })).resolves.toBeTruthy(); + }); + + it('requires name in all cases', async () => { + const schema = buildProfileSchema(true, true); + const error = await schema.validate({}, { abortEarly: false }).catch((e) => e); + expect(error.errors).toContain('Name is required'); + }); + + it('validates successfully with all required fields', async () => { + const schema = buildProfileSchema(false, false); + await expect( + schema.validate({ name: 'Test', search_pattern: 'foo', replace_pattern: 'bar' }) + ).resolves.toBeTruthy(); + }); + }); + + // ── fetchFirstStreamUrl ───────────────────────────────────────────────── + + describe('fetchFirstStreamUrl', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns url from first result', async () => { + API.queryStreams.mockResolvedValue({ results: [{ url: 'http://stream.url' }] }); + const url = await fetchFirstStreamUrl(5); + expect(url).toBe('http://stream.url'); + const params = API.queryStreams.mock.calls[0][0]; + expect(params.get('page')).toBe('1'); + expect(params.get('page_size')).toBe('1'); + expect(params.get('m3u_account')).toBe('5'); + }); + + it('returns null when results are empty', async () => { + API.queryStreams.mockResolvedValue({ results: [] }); + const url = await fetchFirstStreamUrl(5); + expect(url).toBeNull(); + }); + + it('returns null when response is null', async () => { + API.queryStreams.mockResolvedValue(null); + const url = await fetchFirstStreamUrl(5); + expect(url).toBeNull(); + }); + }); + + // ── validateXcSimple ───────────────────────────────────────────────── + + describe('validateXcSimple', () => { + it('returns errors when both fields are empty', () => { + const errs = validateXcSimple('', ''); + expect(errs.newUsername).toBe('New username is required'); + expect(errs.newPassword).toBe('New password is required'); + }); + + it('returns error only for empty username', () => { + const errs = validateXcSimple('', 'pass'); + expect(errs.newUsername).toBe('New username is required'); + expect(errs.newPassword).toBeUndefined(); + }); + + it('returns error only for empty password', () => { + const errs = validateXcSimple('user', ''); + expect(errs.newUsername).toBeUndefined(); + expect(errs.newPassword).toBe('New password is required'); + }); + + it('returns no errors when both fields are valid', () => { + const errs = validateXcSimple('user', 'pass'); + expect(errs).toEqual({}); + }); + + it('treats whitespace-only values as empty', () => { + const errs = validateXcSimple(' ', ' '); + expect(errs.newUsername).toBe('New username is required'); + expect(errs.newPassword).toBe('New password is required'); + }); + }); + + // ── prepareExpDate ───────────────────────────────────────────────── + + describe('prepareExpDate', () => { + it('returns undefined when isXC is true', () => { + expect(prepareExpDate('2025-01-01', true)).toBeUndefined(); + }); + + it('returns ISO string when value is a Date', () => { + const date = new Date('2025-06-15T00:00:00.000Z'); + expect(prepareExpDate(date, false)).toBe(date.toISOString()); + }); + + it('returns the value as-is when it is a string', () => { + expect(prepareExpDate('2025-06-15', false)).toBe('2025-06-15'); + }); + + it('returns null when value is falsy and not XC', () => { + expect(prepareExpDate(null, false)).toBeNull(); + expect(prepareExpDate('', false)).toBeNull(); + expect(prepareExpDate(undefined, false)).toBeNull(); + }); + }); + + // ── applyXcSimplePatterns ─────────────────────────────────────────────── + + describe('applyXcSimplePatterns', () => { + it('sets search_pattern and replace_pattern from m3u credentials and new credentials', () => { + const m3u = { username: 'oldUser', password: 'oldPass' }; + const values = { name: 'Test', notes: 'some note' }; + const result = applyXcSimplePatterns(values, m3u, 'newUser', 'newPass'); + expect(result.search_pattern).toBe('oldUser/oldPass'); + expect(result.replace_pattern).toBe('newUser/newPass'); + expect(result.name).toBe('Test'); + }); + + it('handles missing m3u username and password gracefully', () => { + const result = applyXcSimplePatterns({}, null, 'newUser', 'newPass'); + expect(result.search_pattern).toBe('/'); + expect(result.replace_pattern).toBe('newUser/newPass'); + }); + + it('trims whitespace from newUsername and newPassword', () => { + const m3u = { username: 'u', password: 'p' }; + const result = applyXcSimplePatterns({}, m3u, ' newUser ', ' newPass '); + expect(result.replace_pattern).toBe('newUser/newPass'); + }); + }); + + // ── buildSubmitValues ───────────────────────────────────────────────── + + describe('buildSubmitValues', () => { + const baseValues = { + name: 'Profile Name', + max_streams: 5, + search_pattern: 'foo', + replace_pattern: 'bar', + notes: 'some notes', + }; + + it('returns only name and custom_properties for default profile', () => { + const profile = { custom_properties: { existing: 'prop' } }; + const result = buildSubmitValues(baseValues, profile, true, false, null); + expect(result).toEqual({ + name: 'Profile Name', + custom_properties: { existing: 'prop', notes: 'some notes' }, + }); + expect(result.max_streams).toBeUndefined(); + expect(result.search_pattern).toBeUndefined(); + }); + + it('returns full values for non-default, non-XC profile', () => { + const profile = { custom_properties: {} }; + const result = buildSubmitValues(baseValues, profile, false, false, null); + expect(result).toEqual({ + name: 'Profile Name', + max_streams: 5, + search_pattern: 'foo', + replace_pattern: 'bar', + custom_properties: { notes: 'some notes' }, + }); + }); + + it('includes xcMode in custom_properties when isXC is true', () => { + const profile = { custom_properties: {} }; + const result = buildSubmitValues(baseValues, profile, false, true, 'simple'); + expect(result.custom_properties.xcMode).toBe('simple'); + }); + + it('does not include xcMode when isXC is false', () => { + const profile = { custom_properties: {} }; + const result = buildSubmitValues(baseValues, profile, false, false, 'simple'); + expect(result.custom_properties.xcMode).toBeUndefined(); + }); + + it('handles null or undefined profile custom_properties', () => { + const result = buildSubmitValues(baseValues, null, true, false, null); + expect(result.custom_properties).toEqual({ notes: 'some notes' }); + }); + + it('uses empty string for notes when notes is not provided', () => { + const values = { ...baseValues, notes: undefined }; + const result = buildSubmitValues(values, null, true, false, null); + expect(result.custom_properties.notes).toBe(''); + }); + }); +}); diff --git a/frontend/src/utils/forms/__tests__/M3uProfilesUtils.test.js b/frontend/src/utils/forms/__tests__/M3uProfilesUtils.test.js new file mode 100644 index 00000000..02687060 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/M3uProfilesUtils.test.js @@ -0,0 +1,282 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + parseExpirationDate, + isAccountExpired, + getExpirationInfo, + profileSortComparator, +} from '../M3uProfilesUtils.js'; + +// ── Fixed "now" for deterministic tests ─────────────────────────────────────── +const NOW = new Date('2024-06-01T12:00:00Z'); + +// Unix timestamp helpers +const unixSecondsFromNow = (offsetMs) => + String(Math.floor((NOW.getTime() + offsetMs) / 1000)); + +const MS = { + hour: 1000 * 60 * 60, + day: 1000 * 60 * 60 * 24, +}; + +describe('M3uProfilesUtils', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ── parseExpirationDate ──────────────────────────────────────────────────── + + describe('parseExpirationDate', () => { + const makeProfile = (expDate) => ({ + custom_properties: { user_info: { exp_date: expDate } }, + }); + + it('returns a Date object for a valid unix timestamp string', () => { + const expUnix = unixSecondsFromNow(MS.day); + const result = parseExpirationDate(makeProfile(expUnix)); + expect(result).toBeInstanceOf(Date); + }); + + it('returns the correct date for a known unix timestamp', () => { + // 2024-06-02T12:00:00Z = NOW + 1 day + const expUnix = unixSecondsFromNow(MS.day); + const result = parseExpirationDate(makeProfile(expUnix)); + expect(result.getTime()).toBeCloseTo(NOW.getTime() + MS.day, -3); + }); + + it('returns null when profile is null', () => { + expect(parseExpirationDate(null)).toBeNull(); + }); + + it('returns null when profile is undefined', () => { + expect(parseExpirationDate(undefined)).toBeNull(); + }); + + it('returns null when custom_properties is missing', () => { + expect(parseExpirationDate({})).toBeNull(); + }); + + it('returns null when user_info is missing', () => { + expect(parseExpirationDate({ custom_properties: {} })).toBeNull(); + }); + + it('returns null when exp_date is missing', () => { + expect(parseExpirationDate({ custom_properties: { user_info: {} } })).toBeNull(); + }); + + it('returns null when exp_date is null', () => { + expect(parseExpirationDate(makeProfile(null))).toBeNull(); + }); + + it('returns null when exp_date is an empty string', () => { + expect(parseExpirationDate(makeProfile(''))).toBeNull(); + }); + + it('returns epoch start when exp_date is zero', () => { + expect(parseExpirationDate(makeProfile('0'))).toEqual(new Date(0)); + }); + }); + + // ── isAccountExpired ─────────────────────────────────────────────────────── + + describe('isAccountExpired', () => { + const makeProfile = (expDate) => ({ + custom_properties: { user_info: { exp_date: expDate } }, + }); + + it('returns false when expiration date is in the future', () => { + const expUnix = unixSecondsFromNow(MS.day); + expect(isAccountExpired(makeProfile(expUnix))).toBe(false); + }); + + it('returns true when expiration date is in the past', () => { + const expUnix = unixSecondsFromNow(-MS.day); + expect(isAccountExpired(makeProfile(expUnix))).toBe(true); + }); + + it('returns true when expiration date is exactly now (expired at boundary)', () => { + // The exp date equals NOW exactly; NOW < NOW is false, so not expired + // Using -1ms to ensure it's strictly in the past + const pastUnix = unixSecondsFromNow(-1000); + expect(isAccountExpired(makeProfile(pastUnix))).toBe(true); + }); + + it('returns false when no expiration date is present', () => { + expect(isAccountExpired({})).toBe(false); + }); + + it('returns false when profile is null', () => { + expect(isAccountExpired(null)).toBe(false); + }); + + it('returns false when profile is undefined', () => { + expect(isAccountExpired(undefined)).toBe(false); + }); + + it('returns false when exp_date is empty string', () => { + expect(isAccountExpired(makeProfile(''))).toBe(false); + }); + }); + + // ── getExpirationInfo ────────────────────────────────────────────────────── + + describe('getExpirationInfo', () => { + const makeProfile = (expDate) => ({ + custom_properties: { user_info: { exp_date: expDate } }, + }); + + it('returns null when no expiration date is present', () => { + expect(getExpirationInfo({})).toBeNull(); + }); + + it('returns null when profile is null', () => { + expect(getExpirationInfo(null)).toBeNull(); + }); + + it('returns { text: "Expired", color: "red" } when date is in the past', () => { + const expUnix = unixSecondsFromNow(-MS.day); + expect(getExpirationInfo(makeProfile(expUnix))).toEqual({ + text: 'Expired', + color: 'red', + }); + }); + + it('returns green color when more than 30 days remain', () => { + const expUnix = unixSecondsFromNow(31 * MS.day); + const result = getExpirationInfo(makeProfile(expUnix)); + expect(result.color).toBe('green'); + expect(result.text).toMatch(/days/); + }); + + it('returns correct day count for 60 days remaining', () => { + const expUnix = unixSecondsFromNow(60 * MS.day); + const result = getExpirationInfo(makeProfile(expUnix)); + expect(result.text).toBe('60 days'); + expect(result.color).toBe('green'); + }); + + it('returns yellow color when 8–30 days remain', () => { + const expUnix = unixSecondsFromNow(15 * MS.day); + const result = getExpirationInfo(makeProfile(expUnix)); + expect(result.color).toBe('yellow'); + expect(result.text).toBe('15 days'); + }); + + it('returns yellow for exactly 8 days remaining', () => { + const expUnix = unixSecondsFromNow(8 * MS.day); + const result = getExpirationInfo(makeProfile(expUnix)); + expect(result.color).toBe('yellow'); + }); + + it('returns orange color when 1–7 days remain', () => { + const expUnix = unixSecondsFromNow(3 * MS.day); + const result = getExpirationInfo(makeProfile(expUnix)); + expect(result.color).toBe('orange'); + expect(result.text).toBe('3 days'); + }); + + it('returns orange for exactly 1 day remaining', () => { + const expUnix = unixSecondsFromNow(1 * MS.day); + const result = getExpirationInfo(makeProfile(expUnix)); + expect(result.color).toBe('orange'); + expect(result.text).toBe('1 days'); + }); + + it('returns red color with hours when less than 1 day remains', () => { + const expUnix = unixSecondsFromNow(5 * MS.hour); + const result = getExpirationInfo(makeProfile(expUnix)); + expect(result.color).toBe('red'); + expect(result.text).toBe('5h'); + }); + + it('returns "0h" when expiration is imminent (minutes away)', () => { + const expUnix = unixSecondsFromNow(30 * 60 * 1000); // 30 minutes + const result = getExpirationInfo(makeProfile(expUnix)); + expect(result.color).toBe('red'); + expect(result.text).toBe('0h'); + }); + + it('returns correct hours text for 23 hours remaining', () => { + const expUnix = unixSecondsFromNow(23 * MS.hour); + const result = getExpirationInfo(makeProfile(expUnix)); + expect(result.text).toBe('23h'); + expect(result.color).toBe('red'); + }); + }); + + // ── profileSortComparator ────────────────────────────────────────────────── + + describe('profileSortComparator', () => { + const makeProfile = (name, isDefault = false) => ({ name, is_default: isDefault }); + + it('sorts default profile before non-default', () => { + const defaultProfile = makeProfile('Zebra', true); + const normal = makeProfile('Alpha'); + expect(profileSortComparator(defaultProfile, normal)).toBe(-1); + }); + + it('sorts non-default after default profile', () => { + const normal = makeProfile('Alpha'); + const defaultProfile = makeProfile('Zebra', true); + expect(profileSortComparator(normal, defaultProfile)).toBe(1); + }); + + it('sorts two non-default profiles alphabetically ascending', () => { + const a = makeProfile('Alpha'); + const b = makeProfile('Beta'); + expect(profileSortComparator(a, b)).toBeLessThan(0); + }); + + it('sorts two non-default profiles alphabetically descending', () => { + const a = makeProfile('Zebra'); + const b = makeProfile('Alpha'); + expect(profileSortComparator(a, b)).toBeGreaterThan(0); + }); + + it('returns 0 for two non-default profiles with the same name', () => { + const a = makeProfile('Same'); + const b = makeProfile('Same'); + expect(profileSortComparator(a, b)).toBe(0); + }); + + it('places default profile first when sorting an array', () => { + const profiles = [ + makeProfile('Charlie'), + makeProfile('Alpha'), + makeProfile('Default Profile', true), + makeProfile('Beta'), + ]; + const sorted = [...profiles].sort(profileSortComparator); + expect(sorted[0].is_default).toBe(true); + }); + + it('sorts remaining profiles alphabetically after default', () => { + const profiles = [ + makeProfile('Charlie'), + makeProfile('Alpha'), + makeProfile('Default Profile', true), + makeProfile('Beta'), + ]; + const sorted = [...profiles].sort(profileSortComparator); + expect(sorted.slice(1).map((p) => p.name)).toEqual([ + 'Alpha', + 'Beta', + 'Charlie', + ]); + }); + + it('is stable when no profile is default (pure alphabetical)', () => { + const profiles = [ + makeProfile('Zeta'), + makeProfile('Alpha'), + makeProfile('Mu'), + ]; + const sorted = [...profiles].sort(profileSortComparator); + expect(sorted.map((p) => p.name)).toEqual(['Alpha', 'Mu', 'Zeta']); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/utils/forms/__tests__/M3uUtils.test.js b/frontend/src/utils/forms/__tests__/M3uUtils.test.js new file mode 100644 index 00000000..f9fc53a3 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/M3uUtils.test.js @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + updatePlaylist, + addPlaylist, + getPlaylist, + refreshPlaylist, + prepareSubmitValues, +} from '../M3uUtils.js'; + +vi.mock('../../../api.js', () => ({ + default: { + updatePlaylist: vi.fn(), + addPlaylist: vi.fn(), + getPlaylist: vi.fn(), + refreshPlaylist: vi.fn(), + }, +})); + +import API from '../../../api.js'; + +describe('M3uUtils', () => { + beforeEach(() => vi.clearAllMocks()); + +// ── updatePlaylist ───────────────────────────────────────────────────────────── + + describe('updatePlaylist', () => { + it('calls API.updatePlaylist with merged id, values, and file', () => { + const playlist = { id: 1 }; + const values = { name: 'Test' }; + const file = new File([''], 'test.m3u'); + updatePlaylist(playlist, values, file); + expect(API.updatePlaylist).toHaveBeenCalledWith({ id: 1, name: 'Test', file }); + }); + + it('returns the result of API.updatePlaylist', async () => { + API.updatePlaylist.mockResolvedValue({ id: 1, name: 'Updated' }); + const result = await updatePlaylist({ id: 1 }, { name: 'Updated' }, null); + expect(result).toEqual({ id: 1, name: 'Updated' }); + }); + + it('passes null file when no file provided', () => { + updatePlaylist({ id: 2 }, { name: 'No File' }, null); + expect(API.updatePlaylist).toHaveBeenCalledWith({ id: 2, name: 'No File', file: null }); + }); + }); + +// ── addPlaylist ──────────────────────────────────────────────────────────────── + + describe('addPlaylist', () => { + it('calls API.addPlaylist with merged values and file', async () => { + const file = new File([''], 'playlist.m3u'); + await addPlaylist({ name: 'New' }, file); + expect(API.addPlaylist).toHaveBeenCalledWith({ name: 'New', file }); + }); + + it('returns the result of API.addPlaylist', async () => { + API.addPlaylist.mockResolvedValue({ id: 5, name: 'New' }); + const result = await addPlaylist({ name: 'New' }, null); + expect(result).toEqual({ id: 5, name: 'New' }); + }); + + it('passes null file when no file provided', async () => { + await addPlaylist({ name: 'No File' }, null); + expect(API.addPlaylist).toHaveBeenCalledWith({ name: 'No File', file: null }); + }); + }); + +// ── getPlaylist ──────────────────────────────────────────────────────────────── + + describe('getPlaylist', () => { + it('calls API.getPlaylist with the playlist id', async () => { + API.getPlaylist.mockResolvedValue({ id: 3 }); + await getPlaylist({ id: 3 }); + expect(API.getPlaylist).toHaveBeenCalledWith(3); + }); + + it('returns the result of API.getPlaylist', async () => { + API.getPlaylist.mockResolvedValue({ id: 3, name: 'Fetched' }); + const result = await getPlaylist({ id: 3 }); + expect(result).toEqual({ id: 3, name: 'Fetched' }); + }); + }); + +// ── refreshPlaylist ──────────────────────────────────────────────────────────── + + describe('refreshPlaylist', () => { + it('calls API.refreshPlaylist with the playlist id', async () => { + API.refreshPlaylist.mockResolvedValue(undefined); + await refreshPlaylist({ id: 7 }); + expect(API.refreshPlaylist).toHaveBeenCalledWith(7); + }); + + it('returns the result of API.refreshPlaylist', async () => { + API.refreshPlaylist.mockResolvedValue(undefined); + const result = await refreshPlaylist({ id: 7 }); + expect(result).toEqual(undefined); + }); + }); + +// ── prepareSubmitValues ──────────────────────────────────────────────────────── + + describe('prepareSubmitValues', () => { + describe('exp_date handling', () => { + it('deletes exp_date when account_type is XC', () => { + const values = { account_type: 'XC', exp_date: '2025-01-01' }; + const result = prepareSubmitValues(values, null); + expect(result.exp_date).toBeUndefined(); + }); + + it('converts Date to ISO string when expDate is a Date instance', () => { + const date = new Date('2025-06-15T00:00:00.000Z'); + const values = { account_type: 'M3U', exp_date: null }; + const result = prepareSubmitValues(values, date); + expect(result.exp_date).toBe(date.toISOString()); + }); + + it('sets exp_date to null when expDate is not a Date and not XC', () => { + const values = { account_type: 'M3U', exp_date: '2025-01-01' }; + const result = prepareSubmitValues(values, null); + expect(result.exp_date).toBeNull(); + }); + + it('sets exp_date to null when expDate is a string', () => { + const values = { account_type: 'M3U' }; + const result = prepareSubmitValues(values, '2025-01-01'); + expect(result.exp_date).toBeNull(); + }); + }); + + describe('cron_expression / refresh_interval handling', () => { + it('sets refresh_interval to 0 when cron_expression is non-empty', () => { + const values = { + account_type: 'M3U', + cron_expression: '0 * * * *', + refresh_interval: 30, + }; + const result = prepareSubmitValues(values, null); + expect(result.refresh_interval).toBe(0); + expect(result.cron_expression).toBe('0 * * * *'); + }); + + it('sets cron_expression to empty string when it is blank', () => { + const values = { + account_type: 'M3U', + cron_expression: ' ', + refresh_interval: 30, + }; + const result = prepareSubmitValues(values, null); + expect(result.cron_expression).toBe(''); + expect(result.refresh_interval).toBe(30); + }); + + it('sets cron_expression to empty string when it is empty', () => { + const values = { + account_type: 'M3U', + cron_expression: '', + refresh_interval: 60, + }; + const result = prepareSubmitValues(values, null); + expect(result.cron_expression).toBe(''); + expect(result.refresh_interval).toBe(60); + }); + + it('handles undefined cron_expression', () => { + const values = { account_type: 'M3U', refresh_interval: 30 }; + const result = prepareSubmitValues(values, null); + expect(result.cron_expression).toBe(''); + expect(result.refresh_interval).toBe(30); + }); + }); + + describe('password handling for XC', () => { + it('deletes password when account_type is XC and password is empty string', () => { + const values = { account_type: 'XC', password: '' }; + const result = prepareSubmitValues(values, null); + expect(result.password).toBeUndefined(); + }); + + it('keeps password when account_type is XC and password is non-empty', () => { + const values = { account_type: 'XC', password: 'secret' }; + const result = prepareSubmitValues(values, null); + expect(result.password).toBe('secret'); + }); + + it('keeps password when account_type is not XC and password is empty', () => { + const values = { account_type: 'M3U', password: '' }; + const result = prepareSubmitValues(values, null); + expect(result.password).toBe(''); + }); + }); + + describe('user_agent handling', () => { + it('sets user_agent to null when value is "0"', () => { + const values = { account_type: 'M3U', user_agent: '0' }; + const result = prepareSubmitValues(values, null); + expect(result.user_agent).toBeNull(); + }); + + it('keeps user_agent when it is not "0"', () => { + const values = { account_type: 'M3U', user_agent: 'Mozilla/5.0' }; + const result = prepareSubmitValues(values, null); + expect(result.user_agent).toBe('Mozilla/5.0'); + }); + + it('keeps user_agent when it is undefined', () => { + const values = { account_type: 'M3U' }; + const result = prepareSubmitValues(values, null); + expect(result.user_agent).toBeUndefined(); + }); + }); + + describe('does not mutate original values', () => { + it('returns a new object and does not mutate the input', () => { + const values = { + account_type: 'M3U', + cron_expression: '', + refresh_interval: 30, + user_agent: '0', + }; + const original = { ...values }; + prepareSubmitValues(values, null); + expect(values).toEqual(original); + }); + }); + }); +}); From 0ec60b04d26d4c91a9ffa2b8818c834510292851 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 2 May 2026 01:44:30 -0700 Subject: [PATCH 02/15] Added util tests --- frontend/src/utils/forms/ChannelGroupUtils.js | 17 +++ .../src/utils/forms/LiveGroupFilterUtils.js | 144 ++++++++++++++++++ frontend/src/utils/forms/LogoUtils.js | 78 ++++++++++ frontend/src/utils/forms/M3uFilterUtils.js | 11 ++ .../src/utils/forms/M3uGroupFilterUtils.js | 69 +++++++++ frontend/src/utils/forms/M3uProfileUtils.js | 123 +++++++++++++++ frontend/src/utils/forms/M3uProfilesUtils.js | 37 +++++ frontend/src/utils/forms/M3uUtils.js | 53 +++++++ 8 files changed, 532 insertions(+) create mode 100644 frontend/src/utils/forms/ChannelGroupUtils.js create mode 100644 frontend/src/utils/forms/LiveGroupFilterUtils.js create mode 100644 frontend/src/utils/forms/LogoUtils.js create mode 100644 frontend/src/utils/forms/M3uFilterUtils.js create mode 100644 frontend/src/utils/forms/M3uGroupFilterUtils.js create mode 100644 frontend/src/utils/forms/M3uProfileUtils.js create mode 100644 frontend/src/utils/forms/M3uProfilesUtils.js create mode 100644 frontend/src/utils/forms/M3uUtils.js diff --git a/frontend/src/utils/forms/ChannelGroupUtils.js b/frontend/src/utils/forms/ChannelGroupUtils.js new file mode 100644 index 00000000..54b8346c --- /dev/null +++ b/frontend/src/utils/forms/ChannelGroupUtils.js @@ -0,0 +1,17 @@ +import API from '../../api.js'; + +export const updateChannelGroup = (channelGroup, values) => { + return API.updateChannelGroup({ + id: channelGroup.id, + ...values, + }); +}; +export const addChannelGroup = (values) => { + return API.addChannelGroup(values); +}; +export const deleteChannelGroup = (group) => { + return API.deleteChannelGroup(group.id); +}; +export const cleanupUnusedChannelGroups = () => { + return API.cleanupUnusedChannelGroups(); +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/LiveGroupFilterUtils.js b/frontend/src/utils/forms/LiveGroupFilterUtils.js new file mode 100644 index 00000000..b02abc63 --- /dev/null +++ b/frontend/src/utils/forms/LiveGroupFilterUtils.js @@ -0,0 +1,144 @@ +import API from '../../api.js'; + +export const getEPGs = () => { + return API.getEPGs(); +}; + +export const ADVANCED_OPTIONS_CONFIG = [ + { + value: 'force_epg', + label: 'Force EPG Source', + description: + 'Force a specific EPG source for all auto-synced channels, or disable EPG assignment entirely', + isActive: (p) => + p.custom_epg_id !== undefined || + p.force_dummy_epg || + p.force_epg_selected, + defaults: { force_dummy_epg: true }, + removeKeys: ['force_dummy_epg', 'custom_epg_id', 'force_epg_selected'], + }, + { + value: 'group_override', + label: 'Override Channel Group', + description: 'Override the group assignment for all channels in this group', + isActive: (p) => p.group_override !== undefined, + defaults: { group_override: null }, + removeKeys: ['group_override'], + }, + { + value: 'name_regex', + label: 'Channel Name Find & Replace (Regex)', + description: + 'Find and replace part of the channel name using a regex pattern', + isActive: (p) => + p.name_regex_pattern !== undefined || + p.name_replace_pattern !== undefined, + defaults: { name_regex_pattern: '', name_replace_pattern: '' }, + removeKeys: ['name_regex_pattern', 'name_replace_pattern'], + }, + { + value: 'name_match_regex', + label: 'Channel Name Filter (Regex)', + description: 'Only sync channels whose name matches this regex.', + isActive: (p) => p.name_match_regex !== undefined, + defaults: { name_match_regex: '' }, + removeKeys: ['name_match_regex'], + }, + { + value: 'profile_assignment', + label: 'Channel Profile Assignment', + description: + 'Specify which channel profiles the auto-synced channels should be added to', + isActive: (p) => p.channel_profile_ids !== undefined, + defaults: { channel_profile_ids: [] }, + removeKeys: ['channel_profile_ids'], + }, + { + value: 'channel_sort_order', + label: 'Channel Sort Order', + description: + 'Specify the order in which channels are created (name, tvg_id, updated_at)', + isActive: (p) => p.channel_sort_order !== undefined, + defaults: { channel_sort_order: '', channel_sort_reverse: false }, + removeKeys: ['channel_sort_order', 'channel_sort_reverse'], + }, + { + value: 'stream_profile_assignment', + label: 'Stream Profile Assignment', + description: + 'Assign a specific stream profile to all channels in this group during auto sync', + isActive: (p) => p.stream_profile_id !== undefined, + defaults: { stream_profile_id: null }, + removeKeys: ['stream_profile_id'], + }, + { + value: 'custom_logo', + label: 'Custom Logo', + description: + 'Assign a custom logo to all auto-synced channels in this group', + isActive: (p) => p.custom_logo_id !== undefined, + defaults: { custom_logo_id: null }, + removeKeys: ['custom_logo_id'], + }, +]; + +export const getSelectedAdvancedOptions = (customProps) => + ADVANCED_OPTIONS_CONFIG.filter((opt) => opt.isActive(customProps ?? {})).map( + (opt) => opt.value + ); + +export const applyAdvancedOptionsChange = (prevCustomProps, newValues) => { + const next = { ...prevCustomProps }; + + // Add defaults for newly selected options + for (const opt of ADVANCED_OPTIONS_CONFIG) { + if (newValues.includes(opt.value) && !opt.isActive(next)) { + Object.assign(next, opt.defaults); + } + } + + // Remove keys for deselected options + for (const opt of ADVANCED_OPTIONS_CONFIG) { + if (!newValues.includes(opt.value) && opt.isActive(next)) { + for (const key of opt.removeKeys) delete next[key]; + } + } + + return next; +}; + +export const getEpgSourceValue = (group) => { + // Show custom EPG if set + if ( + group.custom_properties?.custom_epg_id !== undefined && + group.custom_properties?.custom_epg_id !== null + ) { + return group.custom_properties.custom_epg_id.toString(); + } + // Show "No EPG" if force_dummy_epg is set + if (group.custom_properties?.force_dummy_epg) { + return '0'; + } + // Otherwise show empty/placeholder + return null; +}; + +export const getEpgSourceData = (epgSources) => { + return [ + { value: '0', label: 'No EPG (Disabled)' }, + ...[...epgSources] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((source) => ({ + value: source.id.toString(), + label: `${source.name} (${ + source.source_type === 'dummy' + ? 'Dummy' + : source.source_type === 'xmltv' + ? 'XMLTV' + : source.source_type === 'schedules_direct' + ? 'Schedules Direct' + : source.source_type + })`, + })), + ]; +}; diff --git a/frontend/src/utils/forms/LogoUtils.js b/frontend/src/utils/forms/LogoUtils.js new file mode 100644 index 00000000..f0682a85 --- /dev/null +++ b/frontend/src/utils/forms/LogoUtils.js @@ -0,0 +1,78 @@ +import API from '../../api.js'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as Yup from 'yup'; + +const schema = Yup.object({ + name: Yup.string().required('Name is required'), + url: Yup.string() + .required('URL is required') + .test( + 'valid-url-or-path', + 'Must be a valid URL or local file path', + (value) => { + if (!value) return false; + // Allow local file paths starting with /data/logos/ + if (value.startsWith('/data/logos/')) return true; + // Allow valid URLs + try { + new URL(value); + return true; + } catch { + return false; + } + } + ), +}); + +export const uploadLogo = async (selectedFile, values) => { + return await API.uploadLogo(selectedFile, values.name); +}; + +export const createLogo = async (values) => { + return await API.createLogo(values); +}; + +export const updateLogo = async (logo, values) => { + return await API.updateLogo(logo.id, values); +}; + +export const getResolver = () => { + return yupResolver(schema); +}; + +export const getUploadErrorMessage = (uploadError) => { + if ( + uploadError.code === 'NETWORK_ERROR' || + uploadError.message?.includes('timeout') + ) { + return 'Upload timed out. Please try again.'; + } else if (uploadError.status === 413) { + return 'File too large. Please choose a smaller file.'; + } else if (uploadError.body?.error) { + return uploadError.body.error; + } + return 'Failed to upload logo file'; +}; + +export const getUpdateLogoErrorMessage = (logo, error) => { + if (error.code === 'NETWORK_ERROR' || error.message?.includes('timeout')) { + return 'Request timed out. Please try again.'; + } else if (error.response?.data?.error) { + return error.response.data.error; + } + return logo ? 'Failed to update logo' : 'Failed to create logo'; +}; + +export const validateFileSize = (file) => { + return file.size <= 5 * 1024 * 1024; +}; + +export const releaseUrl = (url) => { + if (url && url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } +}; + +export const getFilenameWithoutExtension = (filename) => { + return filename.replace(/\.[^/.]+$/, ''); +}; diff --git a/frontend/src/utils/forms/M3uFilterUtils.js b/frontend/src/utils/forms/M3uFilterUtils.js new file mode 100644 index 00000000..bee18076 --- /dev/null +++ b/frontend/src/utils/forms/M3uFilterUtils.js @@ -0,0 +1,11 @@ +import API from '../../api.js'; + +export const addM3UFilter = async (m3u, values) => { + await API.addM3UFilter(m3u.id, values); +}; +export const updateM3UFilter = (m3u, filter, values) => { + return API.updateM3UFilter(m3u.id, filter.id, values); +}; +export const deleteM3UFilter = async (playlist, id) => { + await API.deleteM3UFilter(playlist.id, id); +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/M3uGroupFilterUtils.js b/frontend/src/utils/forms/M3uGroupFilterUtils.js new file mode 100644 index 00000000..a51c55c6 --- /dev/null +++ b/frontend/src/utils/forms/M3uGroupFilterUtils.js @@ -0,0 +1,69 @@ +import API from '../../api.js'; +import { refreshPlaylist, updatePlaylist } from './M3uUtils.js'; + +const updateM3UGroupSettings = async ( + playlist, + groupSettings, + categorySettings +) => { + await API.updateM3UGroupSettings( + playlist.id, + groupSettings, + categorySettings + ); +}; + +export const buildGroupStates = (channelGroups, playlistChannelGroups) => { + return playlistChannelGroups + .filter((group) => channelGroups[group.channel_group]) + .map((group) => ({ + ...group, + name: channelGroups[group.channel_group].name, + auto_channel_sync: group.auto_channel_sync || false, + auto_sync_channel_start: group.auto_sync_channel_start || 1.0, + custom_properties: parseCustomProperties(group.custom_properties), + })); +}; + +const parseCustomProperties = (raw) => { + if (!raw) return {}; + try { + return typeof raw === 'string' ? JSON.parse(raw) : raw; + } catch { + return {}; + } +}; + +export const saveAndRefreshPlaylist = async ( + playlist, + groupStates, + movieCategoryStates, + seriesCategoryStates, + autoEnableSettings +) => { + const groupSettings = prepareGroupSettings(groupStates); + const categorySettings = prepareCategorySettings( + movieCategoryStates, + seriesCategoryStates + ); + + await updatePlaylist(playlist, autoEnableSettings); + await updateM3UGroupSettings(playlist, groupSettings, categorySettings); + await refreshPlaylist(playlist); +}; + +const prepareGroupSettings = (groupStates) => { + return groupStates.map((state) => ({ + ...state, + custom_properties: state.custom_properties || undefined, + })); +}; + +const prepareCategorySettings = (movieCategoryStates, seriesCategoryStates) => { + return [...movieCategoryStates, ...seriesCategoryStates] + .map((state) => ({ + ...state, + custom_properties: state.custom_properties || undefined, + })) + .filter((state) => state.enabled !== state.original_enabled); +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/M3uProfileUtils.js b/frontend/src/utils/forms/M3uProfileUtils.js new file mode 100644 index 00000000..6ab4bf76 --- /dev/null +++ b/frontend/src/utils/forms/M3uProfileUtils.js @@ -0,0 +1,123 @@ +import API from '../../api.js'; +import * as Yup from 'yup'; + +export const updateM3UProfile = async (m3u, submitValues) => { + await API.updateM3UProfile(m3u.id, submitValues); +}; + +export const addM3UProfile = async (m3u, submitValues) => { + await API.addM3UProfile(m3u.id, submitValues); +}; + +export const deleteM3UProfile = async (playlist, id) => { + await API.deleteM3UProfile(playlist.id, id); +}; + +const queryStreams = async (params) => { + return await API.queryStreams(params); +}; + +export const getDetectedMode = (storedMode, profile, m3u) => { + if (storedMode) { + return storedMode; + } else if ( + profile?.search_pattern && + profile.search_pattern === `${m3u?.username}/${m3u?.password}` + ) { + return 'simple'; + } else if (profile?.search_pattern) { + return 'advanced'; + } + return 'simple'; +}; + +export const applyRegex = (input, pattern, replacer) => { + if (!pattern || !input) return input; + try { + const regex = new RegExp(pattern, 'g'); + return input.replace(regex, replacer); + } catch { + return input; + } +}; + +export const buildProfileSchema = (isDefaultProfile, isXC) => { + return Yup.object({ + name: Yup.string().required('Name is required'), + search_pattern: Yup.string().when([], { + is: () => !isDefaultProfile && !isXC, + then: (schema) => schema.required('Search pattern is required'), + otherwise: (schema) => schema.notRequired(), + }), + replace_pattern: Yup.string().when([], { + is: () => !isDefaultProfile && !isXC, + then: (schema) => schema.required('Replace pattern is required'), + otherwise: (schema) => schema.notRequired(), + }), + notes: Yup.string(), + }); +}; + +export const fetchFirstStreamUrl = async (m3uId) => { + const params = new URLSearchParams(); + params.append('page', 1); + params.append('page_size', 1); + params.append('m3u_account', m3uId); + const response = await queryStreams(params); + return response?.results?.[0]?.url ?? null; +}; + +export const validateXcSimple = (newUsername, newPassword) => { + const errs = {}; + if (!newUsername.trim()) errs.newUsername = 'New username is required'; + if (!newPassword.trim()) errs.newPassword = 'New password is required'; + return errs; +}; + +export const prepareExpDate = (expDateValue, isXC) => { + if (isXC) return undefined; + if (expDateValue instanceof Date) return expDateValue.toISOString(); + return expDateValue || null; +}; + +export const applyXcSimplePatterns = ( + values, + m3u, + newUsername, + newPassword +) => { + return { + ...values, + search_pattern: `${m3u?.username || ''}/${m3u?.password || ''}`, + replace_pattern: `${newUsername.trim()}/${newPassword.trim()}`, + }; +}; + +export const buildSubmitValues = ( + values, + profile, + isDefaultProfile, + isXC, + xcMode +) => { + if (isDefaultProfile) { + return { + name: values.name, + custom_properties: { + ...(profile?.custom_properties || {}), + notes: values.notes || '', + }, + }; + } + return { + name: values.name, + max_streams: values.max_streams, + search_pattern: values.search_pattern, + replace_pattern: values.replace_pattern, + custom_properties: { + ...(profile?.custom_properties || {}), + notes: values.notes || '', + ...(isXC ? { xcMode } : {}), + }, + }; +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/M3uProfilesUtils.js b/frontend/src/utils/forms/M3uProfilesUtils.js new file mode 100644 index 00000000..17228b1f --- /dev/null +++ b/frontend/src/utils/forms/M3uProfilesUtils.js @@ -0,0 +1,37 @@ +export const parseExpirationDate = (profile) => { + const expDate = profile?.custom_properties?.user_info?.exp_date; + if (!expDate) return null; + try { + return new Date(parseInt(expDate) * 1000); + } catch { + return null; + } +}; + +export const isAccountExpired = (profile) => { + const expDate = parseExpirationDate(profile); + if (!expDate) return false; + return expDate < new Date(); +}; + +export const getExpirationInfo = (profile) => { + const expDate = parseExpirationDate(profile); + if (!expDate) return null; + + const diffMs = expDate - new Date(); + if (diffMs <= 0) return { text: 'Expired', color: 'red' }; + + const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (days > 30) return { text: `${days} days`, color: 'green' }; + if (days > 7) return { text: `${days} days`, color: 'yellow' }; + if (days > 0) return { text: `${days} days`, color: 'orange' }; + + const hours = Math.floor(diffMs / (1000 * 60 * 60)); + return { text: `${hours}h`, color: 'red' }; +}; + +export const profileSortComparator = (a, b) => { + if (a.is_default) return -1; + if (b.is_default) return 1; + return a.name.localeCompare(b.name); +}; diff --git a/frontend/src/utils/forms/M3uUtils.js b/frontend/src/utils/forms/M3uUtils.js new file mode 100644 index 00000000..18ccb8f9 --- /dev/null +++ b/frontend/src/utils/forms/M3uUtils.js @@ -0,0 +1,53 @@ +import API from '../../api.js'; + +export const updatePlaylist = (playlist, values, file) => { + return API.updatePlaylist({ + id: playlist.id, + ...values, + file, + }); +}; + +export const addPlaylist = async (values, file) => { + return await API.addPlaylist({ + ...values, + file, + }); +}; + +export const getPlaylist = async (newPlaylist) => { + return await API.getPlaylist(newPlaylist.id); +}; + +export const refreshPlaylist = async (playlist) => { + return await API.refreshPlaylist(playlist.id); +}; + +export const prepareSubmitValues = (values, expDate) => { + const prepared = { ...values }; + + if (prepared.account_type === 'XC') { + delete prepared.exp_date; + } else if (expDate instanceof Date) { + prepared.exp_date = expDate.toISOString(); + } else { + prepared.exp_date = null; + } + + const hasCron = prepared.cron_expression && prepared.cron_expression.trim() !== ''; + if (hasCron) { + prepared.refresh_interval = 0; + } else { + prepared.cron_expression = ''; + } + + if (prepared.account_type == 'XC' && prepared.password == '') { + delete prepared.password; + } + + if (prepared.user_agent == '0') { + prepared.user_agent = null; + } + + return prepared; +}; \ No newline at end of file From e8f13b78c3eca224963e61415854a619d489db80 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Sat, 2 May 2026 01:44:47 -0700 Subject: [PATCH 03/15] Added component tests --- .../forms/__tests__/GroupManager.test.jsx | 687 +++++++++++++ .../forms/__tests__/LiveGroupFilter.test.jsx | 950 ++++++++++++++++++ .../forms/__tests__/LoginForm.test.jsx | 454 +++++++++ .../components/forms/__tests__/Logo.test.jsx | 445 ++++++++ .../components/forms/__tests__/M3U.test.jsx | 650 ++++++++++++ .../forms/__tests__/M3UFilter.test.jsx | 374 +++++++ .../forms/__tests__/M3UFilters.test.jsx | 595 +++++++++++ .../forms/__tests__/M3UGroupFilter.test.jsx | 420 ++++++++ .../forms/__tests__/M3UProfile.test.jsx | 574 +++++++++++ .../forms/__tests__/M3UProfiles.test.jsx | 556 ++++++++++ 10 files changed, 5705 insertions(+) create mode 100644 frontend/src/components/forms/__tests__/GroupManager.test.jsx create mode 100644 frontend/src/components/forms/__tests__/LiveGroupFilter.test.jsx create mode 100644 frontend/src/components/forms/__tests__/LoginForm.test.jsx create mode 100644 frontend/src/components/forms/__tests__/Logo.test.jsx create mode 100644 frontend/src/components/forms/__tests__/M3U.test.jsx create mode 100644 frontend/src/components/forms/__tests__/M3UFilter.test.jsx create mode 100644 frontend/src/components/forms/__tests__/M3UFilters.test.jsx create mode 100644 frontend/src/components/forms/__tests__/M3UGroupFilter.test.jsx create mode 100644 frontend/src/components/forms/__tests__/M3UProfile.test.jsx create mode 100644 frontend/src/components/forms/__tests__/M3UProfiles.test.jsx diff --git a/frontend/src/components/forms/__tests__/GroupManager.test.jsx b/frontend/src/components/forms/__tests__/GroupManager.test.jsx new file mode 100644 index 00000000..fe945127 --- /dev/null +++ b/frontend/src/components/forms/__tests__/GroupManager.test.jsx @@ -0,0 +1,687 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import GroupManager from '../GroupManager'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/channels', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/forms/ChannelGroupUtils.js', () => ({ + addChannelGroup: vi.fn(), + cleanupUnusedChannelGroups: vi.fn(), + deleteChannelGroup: vi.fn(), + updateChannelGroup: vi.fn(), +})); + +// ── ConfirmationDialog mock ──────────────────────────────────────────────────── +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ opened, onClose, onConfirm, title, confirmLabel, cancelLabel, loading }) => + opened ? ( +
+
{title}
+ + +
+ ) : null, +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + AlertCircle: () => , + Check: () => , + Database: () => , + Filter: () => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , + Trash: () => , + Tv: () => , + X: () => , +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', async () => ({ + ActionIcon: ({ children, onClick, disabled }) => ( + + ), + Alert: ({ children, title }) => ( +
+ {title &&
{title}
} + {children} +
+ ), + Badge: ({ children, color }) => ( + + {children} + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, disabled, loading, leftSection }) => ( + + ), + Chip: ({ children, checked, onChange }) => ( + + ), + Divider: () =>
, + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + ScrollArea: ({ children }) =>
{children}
, + Stack: ({ children }) =>
{children}
, + Text: ({ children }) => {children}, + TextInput: ({ value, onChange, placeholder, label, onKeyDown }) => ( + + ), + useMantineTheme: () => ({ tailwind: { yellow: ['#fefcbf'], red: ['#f56565'] } }), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useChannelsStore from '../../../store/channels'; +import useWarningsStore from '../../../store/warnings'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import * as ChannelGroupUtils from '../../../utils/forms/ChannelGroupUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeGroup = (overrides = {}) => ({ + id: 1, + name: 'Group A', + hasChannels: true, + hasM3UAccounts: false, + canEdit: true, + canDelete: true, + ...overrides, +}); + +const setupMocks = ({ groups = {}, isWarningSuppressed = false } = {}) => { + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ + channelGroups: groups, + canEditChannelGroup: vi.fn().mockReturnValue(true), + canDeleteChannelGroup: vi.fn().mockReturnValue(true), + }) + ); + + const mockSuppressWarning = vi.fn(); + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ + isWarningSuppressed: () => isWarningSuppressed, + suppressWarning: mockSuppressWarning, + }) + ); + + return { mockSuppressWarning }; +}; + +const renderGroupManager = (props = {}) => + render(); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('GroupManager', () => { + beforeEach(() => vi.clearAllMocks()); + + // ── Visibility ───────────────────────────────────────────────────────────── + + describe('visibility', () => { + it('renders nothing when isOpen is false', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('renders the modal when isOpen is true', () => { + setupMocks(); + renderGroupManager(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('calls onClose when modal close button is clicked', () => { + setupMocks(); + const onClose = vi.fn(); + renderGroupManager({ onClose }); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── Group rendering ──────────────────────────────────────────────────────── + + describe('group list rendering', () => { + it('renders a group name', () => { + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + expect(screen.getByText('Group A')).toBeInTheDocument(); + }); + + it('renders multiple groups sorted alphabetically', () => { + setupMocks({ + groups: { + 2: makeGroup({ id: 2, name: 'Zebra' }), + 1: makeGroup({ id: 1, name: 'Alpha' }), + }, + }); + renderGroupManager(); + const names = screen.getAllByText(/Alpha|Zebra/).map((el) => el.textContent); + expect(names.indexOf('Alpha')).toBeLessThan(names.indexOf('Zebra')); + }); + + it('renders channel badge for groups with channels', () => { + setupMocks({ groups: { 1: makeGroup({ hasChannels: true }) } }); + renderGroupManager(); + expect(screen.getByTestId('icon-tv')).toBeInTheDocument(); + }); + + it('renders M3U badge for groups with M3U accounts', () => { + setupMocks({ + groups: { 1: makeGroup({ hasChannels: false, hasM3UAccounts: true }) }, + }); + renderGroupManager(); + expect(screen.getByTestId('icon-database')).toBeInTheDocument(); + }); + + it('shows empty state when no groups match filter', () => { + setupMocks({ groups: {} }); + renderGroupManager(); + // No groups rendered in scroll area + expect(screen.queryByText('Group A')).not.toBeInTheDocument(); + }); + }); + + // ── Search filtering ─────────────────────────────────────────────────────── + + describe('search filtering', () => { + it('filters groups by search term', () => { + setupMocks({ + groups: { + 1: makeGroup({ id: 1, name: 'Sports' }), + 2: makeGroup({ id: 2, name: 'Movies' }), + }, + }); + renderGroupManager(); + + const searchInput = screen.getByPlaceholderText(/search/i); + fireEvent.change(searchInput, { target: { value: 'Sports' } }); + + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.queryByText('Movies')).not.toBeInTheDocument(); + }); + + it('shows all groups when search is cleared', () => { + setupMocks({ + groups: { + 1: makeGroup({ id: 1, name: 'Sports' }), + 2: makeGroup({ id: 2, name: 'Movies' }), + }, + }); + renderGroupManager(); + + const searchInput = screen.getByPlaceholderText(/search/i); + fireEvent.change(searchInput, { target: { value: 'Sports' } }); + fireEvent.change(searchInput, { target: { value: '' } }); + + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.getByText('Movies')).toBeInTheDocument(); + }); + + it('is case-insensitive', () => { + setupMocks({ groups: { 1: makeGroup({ name: 'Sports' }) } }); + renderGroupManager(); + + const searchInput = screen.getByPlaceholderText(/search/i); + fireEvent.change(searchInput, { target: { value: 'sports' } }); + + expect(screen.getByText('Sports')).toBeInTheDocument(); + }); + }); + + // ── Edit group ───────────────────────────────────────────────────────────── + + describe('edit group', () => { + it('shows edit input when edit button is clicked', () => { + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + expect(screen.getByDisplayValue('Group A')).toBeInTheDocument(); + }); + + it('calls updateChannelGroup with trimmed name on save', async () => { + vi.mocked(ChannelGroupUtils.updateChannelGroup).mockResolvedValue(undefined); + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + const input = screen.getByDisplayValue('Group A'); + fireEvent.change(input, { target: { value: ' Updated Name ' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(ChannelGroupUtils.updateChannelGroup).toHaveBeenCalledWith( + makeGroup(), + { name: 'Updated Name' } + ); + }); + }); + + it('shows success notification after save', async () => { + vi.mocked(ChannelGroupUtils.updateChannelGroup).mockResolvedValue(undefined); + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'green', title: 'Success' }) + ); + }); + }); + + it('shows error notification when name is empty on save', async () => { + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + const input = screen.getByDisplayValue('Group A'); + fireEvent.change(input, { target: { value: '' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red' }) + ); + }); + expect(ChannelGroupUtils.updateChannelGroup).not.toHaveBeenCalled(); + }); + + it('shows error notification when updateChannelGroup throws', async () => { + vi.mocked(ChannelGroupUtils.updateChannelGroup).mockRejectedValue(new Error('fail')); + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red', title: 'Error' }) + ); + }); + }); + + it('cancels edit and restores group name', () => { + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + const input = screen.getByDisplayValue('Group A'); + fireEvent.change(input, { target: { value: 'Changed' } }); + + fireEvent.click(screen.getByTestId('icon-x').closest('button')); + + expect(screen.queryByDisplayValue('Changed')).not.toBeInTheDocument(); + expect(screen.getByText('Group A')).toBeInTheDocument(); + }); + }); + + // ── Create group ─────────────────────────────────────────────────────────── + + describe('create group', () => { + it('calls addChannelGroup with trimmed name', async () => { + vi.mocked(ChannelGroupUtils.addChannelGroup).mockResolvedValue(undefined); + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + const input = screen.getByPlaceholderText(/Enter group name/i); + fireEvent.change(input, { target: { value: ' New Group ' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(ChannelGroupUtils.addChannelGroup).toHaveBeenCalledWith({ name: 'New Group' }); + }); + }); + + it('shows success notification after create', async () => { + vi.mocked(ChannelGroupUtils.addChannelGroup).mockResolvedValue(undefined); + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + const input = screen.getByPlaceholderText(/Enter group name/i); + fireEvent.change(input, { target: { value: 'New Group' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'green', title: 'Success' }) + ); + }); + }); + + it('shows error notification when name is empty', async () => { + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red' }) + ); + }); + expect(ChannelGroupUtils.addChannelGroup).not.toHaveBeenCalled(); + }); + + it('shows error notification when addChannelGroup throws', async () => { + vi.mocked(ChannelGroupUtils.addChannelGroup).mockRejectedValue(new Error('fail')); + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + const input = screen.getByPlaceholderText(/Enter group name/i); + fireEvent.change(input, { target: { value: 'New Group' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red', title: 'Error' }) + ); + }); + }); + + it('clears new group name and hides form after successful create', async () => { + vi.mocked(ChannelGroupUtils.addChannelGroup).mockResolvedValue(undefined); + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + const input = screen.getByPlaceholderText(/Enter group name/i); + fireEvent.change(input, { target: { value: 'New Group' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(screen.queryByPlaceholderText(/Enter group name/i)).not.toBeInTheDocument(); + }); + }); + + it('cancels creation form with X button', () => { + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + expect(screen.getByPlaceholderText(/Enter group name/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('icon-x').closest('button')); + expect(screen.queryByPlaceholderText(/Enter group name/i)).not.toBeInTheDocument(); + }); + }); + + // ── Delete group ─────────────────────────────────────────────────────────── + + describe('delete group', () => { + it('opens confirmation dialog when delete is clicked', async () => { + setupMocks({ groups: { 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false } } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-minus').closest('button')); + await waitFor(() => { + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent('Confirm Group Deletion'); + }); + }); + + it('calls deleteChannelGroup when confirmed', async () => { + vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue(undefined); + const groupToDelete = { ...makeGroup(), hasChannels: false, hasM3UAccounts: false }; + setupMocks({ groups: { 1: groupToDelete } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-btn')); + + await waitFor(() => { + expect(ChannelGroupUtils.deleteChannelGroup).toHaveBeenCalledWith(groupToDelete); + }); + }); + + it('shows success notification after delete', async () => { + vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue(undefined); + setupMocks({ groups: { 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false } } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-btn')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'green', title: 'Success' }) + ); + }); + }); + + it('shows error notification when deleteChannelGroup throws', async () => { + vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockRejectedValue(new Error('fail')); + setupMocks({ groups: { 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false } } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-btn')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red', title: 'Error' }) + ); + }); + }); + + it('closes confirmation dialog on cancel', () => { + setupMocks({ groups: { 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false } } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('cancel-btn')); + + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + + it('closes confirmation dialog after successful delete', async () => { + vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue(undefined); + setupMocks({ groups: { 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false } } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-minus').closest('button')); + fireEvent.click(screen.getByTestId('confirm-btn')); + + await waitFor(() => { + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + }); + }); + + // ── Cleanup ──────────────────────────────────────────────────────────────── + + describe('cleanup unused groups', () => { + const successResponse = { deleted_count: 1 }; + + it('opens cleanup confirmation dialog when warning not suppressed', () => { + setupMocks({ isWarningSuppressed: false }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent('Cleanup'); + }); + + it('calls cleanupUnusedChannelGroups directly when warning is suppressed', async () => { + vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue(successResponse); + setupMocks({ isWarningSuppressed: true }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + + await waitFor(() => { + expect(ChannelGroupUtils.cleanupUnusedChannelGroups).toHaveBeenCalled(); + }); + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + + it('calls cleanupUnusedChannelGroups when cleanup confirmed', async () => { + vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue(successResponse); + setupMocks({ isWarningSuppressed: false }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + fireEvent.click(screen.getByTestId('confirm-btn')); + + await waitFor(() => { + expect(ChannelGroupUtils.cleanupUnusedChannelGroups).toHaveBeenCalled(); + }); + }); + + it('shows success notification after cleanup', async () => { + vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue(successResponse); + setupMocks({ isWarningSuppressed: true }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'green' }) + ); + }); + }); + + it('shows error notification when cleanup throws', async () => { + vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockRejectedValue( + new Error('fail') + ); + setupMocks({ isWarningSuppressed: true }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red' }) + ); + }); + }); + + it('closes cleanup dialog on cancel', () => { + setupMocks({ isWarningSuppressed: false }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + fireEvent.click(screen.getByTestId('cancel-btn')); + + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + }); + + // ── Filter chips ─────────────────────────────────────────────────────────── + + describe('filter chips', () => { + const twoGroups = { + 1: makeGroup({ id: 1, name: 'Channel Group', hasChannels: true, hasM3UAccounts: false }), + 2: makeGroup({ id: 2, name: 'M3U Group', hasChannels: false, hasM3UAccounts: true }), + }; + + it('hides channel groups when Channels chip is unchecked', () => { + setupMocks({ groups: twoGroups }); + renderGroupManager(); + + const channelChip = screen.getByLabelText(/channel groups/i); + fireEvent.click(channelChip); + + expect(screen.queryByText('Channel Group')).not.toBeInTheDocument(); + expect(screen.getByText('M3U Group')).toBeInTheDocument(); + }); + + it('hides M3U groups when M3U chip is unchecked', () => { + setupMocks({ groups: twoGroups }); + renderGroupManager(); + + const m3uChip = screen.getByLabelText(/m3u groups/i); + fireEvent.click(m3uChip); + + expect(screen.getByText('Channel Group')).toBeInTheDocument(); + expect(screen.queryByText('M3U Group')).not.toBeInTheDocument(); + }); + + it('hides unused groups when Unused chip is unchecked', () => { + setupMocks({ + groups: { + 1: makeGroup({ id: 1, name: 'Unused Group', hasChannels: false, hasM3UAccounts: false }), + 2: makeGroup({ id: 2, name: 'Used Group', hasChannels: true, hasM3UAccounts: false }), + }, + }); + renderGroupManager(); + + const unusedChip = screen.getByLabelText(/unused/i); + fireEvent.click(unusedChip); + + expect(screen.queryByText('Unused Group')).not.toBeInTheDocument(); + expect(screen.getByText('Used Group')).toBeInTheDocument(); + }); + }); + + // ── fetchGroupUsage ──────────────────────────────────────────────────────── + + describe('fetchGroupUsage on open', () => { + it('populates group usage from channelGroups flags when modal opens', () => { + setupMocks({ + groups: { + 1: makeGroup({ hasChannels: true, hasM3UAccounts: false }), + }, + }); + renderGroupManager(); + // Group is visible (usage was populated), badge is shown + expect(screen.getByTestId('icon-tv')).toBeInTheDocument(); + }); + + it('does not fetch when isOpen is false', () => { + setupMocks({ + groups: { 1: makeGroup() }, + }); + render(); + // Nothing should render + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/__tests__/LiveGroupFilter.test.jsx b/frontend/src/components/forms/__tests__/LiveGroupFilter.test.jsx new file mode 100644 index 00000000..2c9abf47 --- /dev/null +++ b/frontend/src/components/forms/__tests__/LiveGroupFilter.test.jsx @@ -0,0 +1,950 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import LiveGroupFilter from '../LiveGroupFilter'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/channels', () => ({ default: vi.fn() })); +vi.mock('../../../store/streamProfiles', () => ({ default: vi.fn() })); + +// ── Hook mocks ───────────────────────────────────────────────────────────────── +vi.mock('../../../hooks/useSmartLogos', () => ({ + useChannelLogoSelection: vi.fn(), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/LiveGroupFilterUtils', () => ({ + ADVANCED_OPTIONS_CONFIG: [ + { + value: 'stream_profile', + label: 'Stream Profile', + description: 'Assign a specific stream profile to all channels in this group during auto sync', + isActive: (p) => p.stream_profile_id !== undefined, + defaults: { stream_profile_id: null }, + removeKeys: ['stream_profile_id'], + }, + ], + getEPGs: vi.fn(), + getEpgSourceData: vi.fn(), + getEpgSourceValue: vi.fn(), + getSelectedAdvancedOptions: vi.fn(), + applyAdvancedOptionsChange: vi.fn(), +})); + +// ── Component mocks ──────────────────────────────────────────────────────────── +vi.mock('../Logo', () => ({ + default: ({ isOpen, onClose, onSuccess }) => + isOpen ? ( +
+ + +
+ ) : null, +})); + +vi.mock('../../LazyLogo', () => ({ + default: ({ logoId, alt }) => {alt}, +})); + +vi.mock('react-window', () => ({ + FixedSizeList: ({ children, itemCount }) => ( +
+ {Array.from({ length: itemCount }, (_, index) => + children({ index, style: {} }) + )} +
+ ), +})); + +vi.mock('../../../images/logo.png', () => ({ default: 'logo.png' })); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', async () => ({ + TextInput: ({ label, placeholder, value, onChange, onClick, readOnly, size }) => ( + + ), + Button: ({ children, onClick, disabled, variant, size, leftSection }) => ( + + ), + Checkbox: ({ label, checked, onChange, disabled, size, description }) => ( + + ), + Flex: ({ children, gap }) =>
{children}
, + Select: ({ label, placeholder, value, onChange, data, size }) => ( + + ), + Stack: ({ children, gap }) =>
{children}
, + Group: ({ children, justify }) =>
{children}
, + SimpleGrid: ({ children }) =>
{children}
, + Text: ({ children, size, c }) => {children}, + NumberInput: ({ label, value, onChange, min, step, size }) => ( + onChange(Number(e.target.value))} + min={min} + step={step} + data-size={size} + /> + ), + Divider: ({ label }) =>
, + Alert: ({ children, icon }) => ( +
+ {icon} + {children} +
+ ), + Box: ({ children, style }) =>
{children}
, + MultiSelect: ({ label, placeholder, value, onChange, data, size }) => ( + + ), + Tooltip: ({ children, label, disabled }) => ( +
{children}
+ ), + Popover: ({ children, opened }) => ( +
{children}
+ ), + ScrollArea: ({ children, style }) =>
{children}
, + Center: ({ children }) =>
{children}
, + SegmentedControl: ({ value, onChange, data }) => ( +
+ {data?.map((d) => ( + + ))} +
+ ), + PopoverTarget: ({ children }) =>
{children}
, + PopoverDropdown: ({ children, onMouseDown }) => ( +
{children}
+ ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Info: () => , + CircleCheck: () => , + CircleX: () => , +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useChannelsStore from '../../../store/channels'; +import useStreamProfilesStore from '../../../store/streamProfiles'; +import { useChannelLogoSelection } from '../../../hooks/useSmartLogos'; +import { + ADVANCED_OPTIONS_CONFIG, + applyAdvancedOptionsChange, + getEPGs, + getEpgSourceData, + getEpgSourceValue, + getSelectedAdvancedOptions, +} from '../../../utils/forms/LiveGroupFilterUtils.js'; + +// ── Fixtures ─────────────────────────────────────────────────────────────────── +const makeChannelGroup = (id, name) => ({ id, name }); + +const makeGroupState = (overrides = {}) => ({ + channel_group: 1, + name: 'Sports', + enabled: true, + auto_channel_sync: false, + auto_sync_channel_start: 1, + original_enabled: true, + custom_properties: {}, + ...overrides, +}); + +const makePlaylist = (groups = []) => ({ + channel_groups: groups, +}); + +const defaultChannelGroups = { + 1: makeChannelGroup(1, 'Sports'), + 2: makeChannelGroup(2, 'News'), + 3: makeChannelGroup(3, 'Movies'), +}; + +const defaultProfiles = { + 1: { id: 1, name: 'Profile A' }, + 2: { id: 2, name: 'Profile B' }, +}; + +const defaultStreamProfiles = [ + { id: 10, name: 'Stream HD' }, + { id: 11, name: 'Stream SD' }, +]; + +const setupStoreMocks = ({ + channelGroups = defaultChannelGroups, + profiles = defaultProfiles, + streamProfiles = defaultStreamProfiles, + fetchStreamProfiles = vi.fn(), + } = {}) => { + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ channelGroups, profiles }) + ); + vi.mocked(useStreamProfilesStore).mockImplementation((sel) => + sel({ profiles: streamProfiles, fetchProfiles: fetchStreamProfiles }) + ); +}; + +const setupLogoMock = ({ + logos = { 5: { id: 5, name: 'My Logo', url: '/logos/5.png' } }, + ensureLogosLoaded = vi.fn(), + isLoading = false, + } = {}) => { + vi.mocked(useChannelLogoSelection).mockReturnValue({ + logos, + ensureLogosLoaded, + isLoading, + }); + return { ensureLogosLoaded }; +}; + +const defaultProps = ({ + groupStates = [], + setGroupStates = vi.fn(), + autoEnableNewGroupsLive = true, + setAutoEnableNewGroupsLive = vi.fn(), + playlist = makePlaylist([]), + } = {}) => ({ + playlist, + groupStates, + setGroupStates, + autoEnableNewGroupsLive, + setAutoEnableNewGroupsLive, +}); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('LiveGroupFilter', () => { + beforeEach(() => { + vi.clearAllMocks(); + setupStoreMocks(); + setupLogoMock(); + vi.mocked(getEPGs).mockResolvedValue([]); + }); + + // ── Rendering ──────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the info alert', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByText(/Auto Channel Sync/)).toBeInTheDocument(); + }); + }); + + it('renders auto-enable checkbox with correct checked state', async () => { + render(); + const checkbox = screen.getByRole('checkbox', { + name: /Automatically enable new groups/i, + }); + await waitFor(() => { + expect(checkbox).toBeChecked(); + }); + }); + + it('renders auto-enable checkbox unchecked when false', () => { + render(); + const checkbox = screen.getByRole('checkbox', { + name: /Automatically enable new groups/i, + }); + expect(checkbox).not.toBeChecked(); + }); + + it('renders filter input', async () => { + render(); + await waitFor(() => { + expect(screen.getByPlaceholderText('Filter groups...')).toBeInTheDocument(); + }); + }); + + it('renders All/Enabled/Disabled segmented control', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('All')).toBeInTheDocument(); + expect(screen.getByText('Enabled')).toBeInTheDocument(); + expect(screen.getByText('Disabled')).toBeInTheDocument(); + }); + }); + + it('renders Select Visible and Deselect Visible buttons', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Select Visible')).toBeInTheDocument(); + expect(screen.getByText('Deselect Visible')).toBeInTheDocument(); + }); + }); + + it('renders group cards', async () => { + const groupStates = [makeGroupState()]; + render(); + await waitFor(() => { + expect(screen.getByText('Sports')).toBeInTheDocument(); + }); + }); + + it('renders no group cards when groupStates is empty', () => { + render(); + expect(screen.queryByTestId('icon-circle-check')).not.toBeInTheDocument(); + }); + + it('renders enabled group with CircleCheck icon', async () => { + const groupStates = [makeGroupState({ enabled: true })]; + render(); + await waitFor(() => { + expect(screen.getByTestId('icon-circle-check')).toBeInTheDocument(); + }); + }); + + it('renders disabled group with CircleX icon', async () => { + const groupStates = [makeGroupState({ enabled: false })]; + render(); + await waitFor(() => { + expect(screen.getByTestId('icon-circle-x')).toBeInTheDocument(); + }); + }); + }); + + // ── Initialization ─────────────────────────────────────────────────────── + + describe('initialization', () => { + it('calls ensureLogosLoaded on mount', () => { + const { ensureLogosLoaded } = setupLogoMock(); + render(); + expect(ensureLogosLoaded).toHaveBeenCalled(); + }); + + it('calls fetchStreamProfiles when streamProfiles is empty', () => { + const fetchStreamProfiles = vi.fn(); + setupStoreMocks({ streamProfiles: [], fetchStreamProfiles }); + render(); + expect(fetchStreamProfiles).toHaveBeenCalled(); + }); + + it('does not call fetchStreamProfiles when streamProfiles already loaded', () => { + const fetchStreamProfiles = vi.fn(); + setupStoreMocks({ fetchStreamProfiles }); + render(); + expect(fetchStreamProfiles).not.toHaveBeenCalled(); + }); + + it('calls getEPGs on mount', async () => { + render(); + await waitFor(() => expect(getEPGs).toHaveBeenCalled()); + }); + + it('handles getEPGs failure gracefully', async () => { + vi.mocked(getEPGs).mockRejectedValue(new Error('Network error')); + expect(() => render()).not.toThrow(); + }); + }); + + // ── setGroupStates on playlist/channelGroups change ────────────────────── + + describe('setGroupStates from playlist', () => { + it('calls setGroupStates when channelGroups and playlist are set', () => { + const setGroupStates = vi.fn(); + const playlist = makePlaylist([ + { channel_group: 1, enabled: true, auto_channel_sync: false, auto_sync_channel_start: 1 }, + ]); + render( + + ); + expect(setGroupStates).toHaveBeenCalled(); + }); + + it('skips groups that are not in channelGroups', () => { + const setGroupStates = vi.fn(); + const playlist = makePlaylist([ + { channel_group: 999, enabled: true }, + ]); + render(); + expect(setGroupStates).toHaveBeenCalledWith([]); + }); + + it('parses custom_properties string in playlist groups', () => { + const setGroupStates = vi.fn(); + const playlist = makePlaylist([ + { + channel_group: 1, + enabled: true, + auto_channel_sync: false, + auto_sync_channel_start: 1, + custom_properties: JSON.stringify({ foo: 'bar' }), + }, + ]); + render(); + const [mappedGroups] = setGroupStates.mock.calls[setGroupStates.mock.calls.length - 1]; + expect(mappedGroups[0].custom_properties).toEqual({ foo: 'bar' }); + }); + + it('handles invalid custom_properties JSON gracefully', () => { + const setGroupStates = vi.fn(); + const playlist = makePlaylist([ + { channel_group: 1, enabled: true, custom_properties: '{invalid}' }, + ]); + expect(() => + render() + ).not.toThrow(); + }); + + it('does not call setGroupStates when channelGroups is empty', () => { + setupStoreMocks({ channelGroups: {} }); + const setGroupStates = vi.fn(); + render(); + expect(setGroupStates).not.toHaveBeenCalled(); + }); + }); + + // ── autoEnableNewGroupsLive checkbox ───────────────────────────────────── + + describe('autoEnableNewGroupsLive toggle', () => { + it('calls setAutoEnableNewGroupsLive when checkbox is changed', () => { + const setAutoEnableNewGroupsLive = vi.fn(); + render( + + ); + fireEvent.click( + screen.getByRole('checkbox', { name: /Automatically enable new groups/i }) + ); + expect(setAutoEnableNewGroupsLive).toHaveBeenCalled(); + }); + }); + + // ── toggleGroupEnabled ─────────────────────────────────────────────────── + + describe('toggleGroupEnabled', () => { + it('calls setGroupStates toggling enabled for matching group', async () => { + const groupStates = [makeGroupState({ enabled: true })]; + let lastUpdater; + const setGroupStates = vi.fn((arg) => { + if (typeof arg === 'function') lastUpdater = arg; + }); + render(); + + fireEvent.click(screen.getByText('Sports')); + await waitFor(() => { + expect(setGroupStates).toHaveBeenCalled(); + const result = lastUpdater(groupStates); + expect(result).toEqual(expect.arrayContaining([ + expect.objectContaining({ channel_group: 1, enabled: false }), + ])); + }); + }); + + it('does not toggle unrelated group', async () => { + const groupStates = [ + makeGroupState({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroupState({ channel_group: 2, name: 'News', enabled: true }), + ]; + let lastUpdater; + const setGroupStates = vi.fn((arg) => { + if (typeof arg === 'function') lastUpdater = arg; + }); + render(); + const buttons = screen.getAllByRole('button', { name: /Sports/ }); + fireEvent.click(buttons[0]); // click Sports + await waitFor(() => { + expect(setGroupStates).toHaveBeenCalled(); + const result = lastUpdater(groupStates); + expect(result[0].enabled).toBe(false); + expect(result[1].enabled).toBe(true); + }); + }); + }); + + // ── Filter ─────────────────────────────────────────────────────────────── + + describe('group text filter', () => { + it('filters groups by name text', () => { + const groupStates = [ + makeGroupState({ channel_group: 1, name: 'Sports' }), + makeGroupState({ channel_group: 2, name: 'News' }), + ]; + render(); + fireEvent.change(screen.getByPlaceholderText('Filter groups...'), { + target: { value: 'sport' }, + }); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.queryByText('News')).not.toBeInTheDocument(); + }); + + it('shows all groups when filter is cleared', () => { + const groupStates = [ + makeGroupState({ channel_group: 1, name: 'Sports' }), + makeGroupState({ channel_group: 2, name: 'News' }), + ]; + render(); + const input = screen.getByPlaceholderText('Filter groups...'); + fireEvent.change(input, { target: { value: 'sport' } }); + fireEvent.change(input, { target: { value: '' } }); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); + }); + + // ── Status filter ───────────────────────────────────────────────────────── + + describe('status filter', () => { + it('filters to only enabled groups', () => { + const groupStates = [ + makeGroupState({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroupState({ channel_group: 2, name: 'News', enabled: false }), + ]; + render(); + fireEvent.click(screen.getByText('Enabled')); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.queryByText('News')).not.toBeInTheDocument(); + }); + + it('filters to only disabled groups', () => { + const groupStates = [ + makeGroupState({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroupState({ channel_group: 2, name: 'News', enabled: false }), + ]; + render(); + fireEvent.click(screen.getByText('Disabled')); + expect(screen.queryByText('Sports')).not.toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); + + it('shows all groups when All is selected', () => { + const groupStates = [ + makeGroupState({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroupState({ channel_group: 2, name: 'News', enabled: false }), + ]; + render(); + fireEvent.click(screen.getByText('Disabled')); + fireEvent.click(screen.getByText('All')); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); + }); + + // ── selectAll / deselectAll ─────────────────────────────────────────────── + + describe('Select / Deselect Visible', () => { + it('selectAll enables all visible groups', async () => { + let lastUpdater; + const setGroupStates = vi.fn((arg) => { + if (typeof arg === 'function') lastUpdater = arg; + }); + const groupStates = [ + makeGroupState({ channel_group: 1, name: 'Sports', enabled: false }), + makeGroupState({ channel_group: 2, name: 'News', enabled: false }), + ]; + render(); + fireEvent.click(screen.getByText('Select Visible')); + await waitFor(() => { + expect(setGroupStates).toHaveBeenCalled(); + const result = lastUpdater(groupStates); + expect(result[0].enabled).toBe(true); + expect(result[1].enabled).toBe(true); + }); + }); + + it('deselectAll disables all visible groups', async () => { + let lastUpdater; + const setGroupStates = vi.fn((arg) => { + if (typeof arg === 'function') lastUpdater = arg; + }); + const groupStates = [ + makeGroupState({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroupState({ channel_group: 2, name: 'News', enabled: true }), + ]; + render(); + fireEvent.click(screen.getByText('Deselect Visible')); + await waitFor(() => { + expect(setGroupStates).toHaveBeenCalled(); + const result = lastUpdater(groupStates); + expect(result[0].enabled).toBe(false); + expect(result[1].enabled).toBe(false); + }); + }); + + it('selectAll only enables visible (filtered) groups', async () => { + let lastUpdater; + const setGroupStates = vi.fn((arg) => { + if (typeof arg === 'function') lastUpdater = arg; + }); + const groupStates = [ + makeGroupState({ channel_group: 1, name: 'Sports', enabled: false }), + makeGroupState({ channel_group: 2, name: 'News', enabled: false }), + ]; + render(); + fireEvent.change(screen.getByPlaceholderText('Filter groups...'), { + target: { value: 'Sports' }, + }); + fireEvent.click(screen.getByText('Select Visible')); + await waitFor(() => { + expect(setGroupStates).toHaveBeenCalled(); + const result = lastUpdater(groupStates); + expect(result[0].enabled).toBe(true); // Sports - visible + expect(result[1].enabled).toBe(false); // News - hidden + }); + }); + }); + + // ── Auto Channel Sync checkbox ──────────────────────────────────────────── + + describe('Auto Channel Sync', () => { + it('renders Auto Channel Sync checkbox when group is enabled', () => { + const groupStates = [makeGroupState({ enabled: true })]; + render(); + expect(screen.getByRole('checkbox', { name: /Auto Channel Sync/i })).toBeInTheDocument(); + }); + + it('Auto Channel Sync checkbox is disabled when group is disabled', () => { + const groupStates = [makeGroupState({ enabled: false })]; + render(); + expect(screen.getByRole('checkbox', { name: /Auto Channel Sync/i })).toBeDisabled(); + }); + + it('toggleAutoSync calls setGroupStates toggling auto_channel_sync', async () => { + let lastUpdater; + const setGroupStates = vi.fn((arg) => { + if (typeof arg === 'function') lastUpdater = arg; + }); + const groupStates = [makeGroupState({ enabled: true, auto_channel_sync: false })]; + render(); + fireEvent.click(screen.getByRole('checkbox', { name: /Auto Channel Sync/i })); + await waitFor(() => { + expect(setGroupStates).toHaveBeenCalled(); + const result = lastUpdater(groupStates); + expect(result[0].auto_channel_sync).toBe(true); + }); + }); + + it('shows Start Channel # input when auto_channel_sync is enabled', () => { + const groupStates = [makeGroupState({ enabled: true, auto_channel_sync: true })]; + render(); + expect(screen.getByRole('spinbutton', { name: /Start Channel #/i })).toBeInTheDocument(); + }); + + it('hides Start Channel # when auto_channel_sync is false', () => { + const groupStates = [makeGroupState({ enabled: true, auto_channel_sync: false })]; + render(); + expect(screen.queryByRole('spinbutton', { name: /Start Channel #/i })).not.toBeInTheDocument(); + }); + + it('updateChannelStart calls setGroupStates with new value', async () => { + let lastUpdater; + const setGroupStates = vi.fn((arg) => { + if (typeof arg === 'function') lastUpdater = arg; + }); + const groupStates = [makeGroupState({ enabled: true, auto_channel_sync: true })]; + render(); + fireEvent.change(screen.getByRole('spinbutton', { name: /Start Channel #/i }), { + target: { value: '100' }, + }); + await waitFor(() => { + expect(setGroupStates).toHaveBeenCalled(); + const result = lastUpdater(groupStates); + expect(result[0].auto_sync_channel_start).toBe(100); + }); + }); + }); + + // ── Channel Numbering Mode ──────────────────────────────────────────────── + + describe('Channel Numbering Mode', () => { + it('shows Channel Numbering Mode select when auto_channel_sync enabled', () => { + const groupStates = [makeGroupState({ enabled: true, auto_channel_sync: true })]; + render(); + expect(screen.getByRole('combobox', { name: /Channel Numbering Mode/i })).toBeInTheDocument(); + }); + + it('shows Fallback Channel # when numbering mode is provider', () => { + const groupStates = [ + makeGroupState({ + enabled: true, + auto_channel_sync: true, + custom_properties: { channel_numbering_mode: 'provider' }, + }), + ]; + render(); + expect( + screen.getByRole('spinbutton', { name: /Fallback Channel #/i }) + ).toBeInTheDocument(); + }); + + it('hides Start Channel # when mode is not fixed', () => { + const groupStates = [ + makeGroupState({ + enabled: true, + auto_channel_sync: true, + custom_properties: { channel_numbering_mode: 'next_available' }, + }), + ]; + render(); + expect(screen.queryByRole('spinbutton', { name: /Start Channel #/i })).not.toBeInTheDocument(); + }); + + it('updating Channel Numbering Mode calls setGroupStates', () => { + const setGroupStates = vi.fn(); + const groupStates = [makeGroupState({ enabled: true, auto_channel_sync: true })]; + render(); + fireEvent.change(screen.getByRole('combobox', { name: /Channel Numbering Mode/i }), { + target: { value: 'provider' }, + }); + expect(setGroupStates).toHaveBeenCalled(); + }); + }); + + // ── Advanced Options MultiSelect ────────────────────────────────────────── + + describe('Advanced Options MultiSelect', () => { + const makeAutoSyncGroup = (customProps = {}) => + makeGroupState({ + enabled: true, + auto_channel_sync: true, + custom_properties: customProps, + }); + + it('renders Advanced Options multiselect when auto_channel_sync is enabled', () => { + render( + + ); + expect(screen.getByRole('listbox', { name: /Advanced Options/i })).toBeInTheDocument(); + }); + + it('shows Channel Name Find input when name_regex_pattern is set', () => { + const groupStates = [ + makeAutoSyncGroup({ name_regex_pattern: '.*', name_replace_pattern: '$1' }), + ]; + render(); + expect(screen.getByRole('textbox', { name: /Channel Name Find/i })).toBeInTheDocument(); + }); + + it('shows Channel Name Replace input when name_replace_pattern is set', () => { + const groupStates = [ + makeAutoSyncGroup({ name_regex_pattern: '.*', name_replace_pattern: '$1' }), + ]; + render(); + expect(screen.getByRole('textbox', { name: /Channel Name Replace/i })).toBeInTheDocument(); + }); + + it('shows Channel Name Filter input when name_match_regex is set', () => { + const groupStates = [makeAutoSyncGroup({ name_match_regex: '^Sports' })]; + render(); + expect( + screen.getByRole('textbox', { name: /Channel Name Filter/i }) + ).toBeInTheDocument(); + }); + + it('shows Channel Profiles multiselect when channel_profile_ids is set', () => { + const groupStates = [makeAutoSyncGroup({ channel_profile_ids: [] })]; + render(); + expect(screen.getByRole('listbox', { name: /Channel Profiles/i })).toBeInTheDocument(); + }); + + it('shows Override Channel Group select when group_override is set', () => { + const groupStates = [makeAutoSyncGroup({ group_override: null })]; + render(); + expect( + screen.getByRole('combobox', { name: /Override Channel Group/i }) + ).toBeInTheDocument(); + }); + + it('shows Stream Profile select when stream_profile_id is set', () => { + const groupStates = [makeAutoSyncGroup({ stream_profile_id: null })]; + render(); + expect(screen.getByRole('combobox', { name: /Stream Profile/i })).toBeInTheDocument(); + }); + + it('shows Channel Sort Order select when channel_sort_order is set', () => { + const groupStates = [makeAutoSyncGroup({ channel_sort_order: '' })]; + render(); + expect( + screen.getByRole('combobox', { name: /Channel Sort Order/i }) + ).toBeInTheDocument(); + }); + + it('shows Reverse Sort Order checkbox when channel_sort_order is set', () => { + const groupStates = [ + makeAutoSyncGroup({ channel_sort_order: '', channel_sort_reverse: false }), + ]; + render(); + expect(screen.getByRole('checkbox', { name: /Reverse Sort Order/i })).toBeInTheDocument(); + }); + + it('shows EPG Source select when force_dummy_epg is set', () => { + const groupStates = [makeAutoSyncGroup({ force_dummy_epg: true })]; + render(); + expect(screen.getByRole('combobox', { name: /EPG Source/i })).toBeInTheDocument(); + }); + + it('shows EPG Source select when custom_epg_id is set', () => { + const groupStates = [makeAutoSyncGroup({ custom_epg_id: 42 })]; + render(); + expect(screen.getByRole('combobox', { name: /EPG Source/i })).toBeInTheDocument(); + }); + }); + + // ── Logo handling ───────────────────────────────────────────────────────── + + describe('custom logo', () => { + const makeLogoGroup = () => + makeGroupState({ + enabled: true, + auto_channel_sync: true, + custom_properties: { custom_logo_id: null }, + }); + + it('shows Upload or Create Logo button when custom_logo_id is set', () => { + const groupStates = [makeLogoGroup()]; + render(); + expect(screen.getByText('Upload or Create Logo')).toBeInTheDocument(); + }); + + it('opens LogoForm when Upload or Create Logo is clicked', () => { + const groupStates = [makeLogoGroup()]; + render(); + fireEvent.click(screen.getByText('Upload or Create Logo')); + expect(screen.getByTestId('logo-form')).toBeInTheDocument(); + }); + + it('closes LogoForm when close is clicked', () => { + const groupStates = [makeLogoGroup()]; + render(); + fireEvent.click(screen.getByText('Upload or Create Logo')); + fireEvent.click(screen.getByTestId('logo-form-close')); + expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument(); + }); + + it('calls setGroupStates with new logo id on success', async () => { + let lastUpdater; + const setGroupStates = vi.fn((arg) => { + if (typeof arg === 'function') lastUpdater = arg; + }); + const groupStates = [makeLogoGroup()]; + render(); + fireEvent.click(screen.getByText('Upload or Create Logo')); + fireEvent.click(screen.getByTestId('logo-form-success')); + expect(setGroupStates).toHaveBeenCalled(); + await waitFor(() => { + expect(setGroupStates).toHaveBeenCalled(); + const result = lastUpdater(groupStates); + expect(result[0].custom_properties.custom_logo_id).toBe(99); + }); + }); + + it('closes LogoForm after successful logo upload', () => { + const groupStates = [makeLogoGroup()]; + render(); + fireEvent.click(screen.getByText('Upload or Create Logo')); + fireEvent.click(screen.getByTestId('logo-form-success')); + expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument(); + }); + }); + + // ── EPG sources loaded from API ─────────────────────────────────────────── + + describe('EPG sources', () => { + it('populates EPG Source select with sources from API', async () => { + vi.mocked(getEpgSourceData).mockReturnValue([ + { id: 1, value: 'epg_one', label: 'EPG One' }, + { id: 2, value: 'epg_two', label: 'EPG Two' }, + ]); + const groupStates = [ + makeGroupState({ + enabled: true, + auto_channel_sync: true, + custom_properties: { force_dummy_epg: true }, + }), + ]; + render(); + await waitFor(() => { + expect(screen.getByRole('option', { name: /EPG One/i })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /EPG Two/i })).toBeInTheDocument(); + }); + }); + }); + + // ── Group sorting ───────────────────────────────────────────────────────── + + describe('group sorting', () => { + it('renders groups sorted alphabetically', () => { + const groupStates = [ + makeGroupState({ channel_group: 2, name: 'Zebra' }), + makeGroupState({ channel_group: 1, name: 'Alpha' }), + ]; + render(); + const buttons = screen.getAllByRole('button', { name: /Alpha|Zebra/ }); + expect(buttons[0]).toHaveTextContent('Alpha'); + expect(buttons[1]).toHaveTextContent('Zebra'); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/__tests__/LoginForm.test.jsx b/frontend/src/components/forms/__tests__/LoginForm.test.jsx new file mode 100644 index 00000000..585c3f72 --- /dev/null +++ b/frontend/src/components/forms/__tests__/LoginForm.test.jsx @@ -0,0 +1,454 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import LoginForm from '../LoginForm'; + +// ── Router mock ──────────────────────────────────────────────────────────────── +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigate, +})); + +// 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; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/auth', () => ({ default: vi.fn() })); +vi.mock('../../../store/settings', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +// ── Asset mock ───────────────────────────────────────────────────────────────── +vi.mock('../../../assets/logo.png', () => ({ default: 'logo.png' })); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', async () => ({ + Paper: ({ children }) =>
{children}
, + Title: ({ children }) =>

{children}

, + TextInput: ({ label, name, value, onChange, placeholder, type, disabled }) => ( + + ), + Button: ({ children, onClick, loading, disabled, type, variant }) => ( + + ), + Center: ({ children }) =>
{children}
, + Stack: ({ children }) =>
{children}
, + Text: ({ children, c, size }) => ( + + {children} + + ), + Image: ({ src, alt }) => {alt}, + Group: ({ children }) =>
{children}
, + Divider: () =>
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Anchor: ({ children, onClick }) => ( + + {children} + + ), + Code: ({ children }) => {children}, + Checkbox: ({ label, checked, onChange }) => ( + + ), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useAuthStore from '../../../store/auth'; +import useSettingsStore from '../../../store/settings'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const setupMocks = ({ + isAuthenticated = false, + loginResult = Promise.resolve(true), + version = '1.0.0', + } = {}) => { + const mockLogin = vi.fn().mockReturnValue(loginResult); + const mockLogout = vi.fn(); + const mockInitData = vi.fn().mockResolvedValue(undefined); + const mockFetchVersion = vi.fn().mockResolvedValue(undefined); + + vi.mocked(useAuthStore).mockImplementation((sel) => + sel({ + login: mockLogin, + logout: mockLogout, + isAuthenticated, + initData: mockInitData, + }) + ); + + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ + fetchVersion: mockFetchVersion, + version: { version }, + }) + ); + + return { mockLogin, mockLogout, mockInitData, mockFetchVersion }; +}; + +const renderLoginForm = () => render(); + +const getUsername = () => screen.getByTestId('input-Username'); +const getPassword = () => screen.getByTestId('input-Password'); +const getLoginButton = () => screen.getByRole('button', { type: 'submit' }); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('LoginForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the login form', () => { + setupMocks(); + renderLoginForm(); + expect(screen.getByTestId('paper')).toBeInTheDocument(); + }); + + it('renders the logo', () => { + setupMocks(); + renderLoginForm(); + expect(screen.getByRole('img')).toHaveAttribute('src', 'logo.png'); + }); + + it('renders username and password inputs', () => { + setupMocks(); + renderLoginForm(); + expect(getUsername()).toBeInTheDocument(); + expect(getPassword()).toBeInTheDocument(); + }); + + it('renders the login button', () => { + setupMocks(); + renderLoginForm(); + expect(getLoginButton()).toBeInTheDocument(); + }); + + it('renders "Remember Me" checkbox', () => { + setupMocks(); + renderLoginForm(); + expect(screen.getByLabelText(/remember me/i)).toBeInTheDocument(); + }); + + it('renders "Save Password" checkbox', async () => { + setupMocks(); + renderLoginForm(); + await waitFor(() => { + fireEvent.click(screen.getByLabelText(/remember me/i)); + expect(screen.getByLabelText(/save password/i)).toBeInTheDocument(); + }); + }); + + it('renders version info when version is available', () => { + setupMocks({ version: '2.3.4' }); + renderLoginForm(); + expect(screen.getByText(/2.3.4/i)).toBeInTheDocument(); + }); + + it('renders forgot password anchor', () => { + setupMocks(); + renderLoginForm(); + expect(screen.getByText(/forgot password/i)).toBeInTheDocument(); + }); + }); + + // ── fetchVersion on mount ────────────────────────────────────────────────── + + describe('on mount', () => { + it('calls fetchVersion on mount', () => { + const { mockFetchVersion } = setupMocks(); + renderLoginForm(); + expect(mockFetchVersion).toHaveBeenCalled(); + }); + }); + + // ── Form input ───────────────────────────────────────────────────────────── + + describe('form input', () => { + it('updates username field on change', () => { + setupMocks(); + renderLoginForm(); + fireEvent.change(getUsername(), { target: { value: 'testuser' } }); + expect(getUsername()).toHaveValue('testuser'); + }); + + it('updates password field on change', () => { + setupMocks(); + renderLoginForm(); + fireEvent.change(getPassword(), { target: { value: 'secret' } }); + expect(getPassword()).toHaveValue('secret'); + }); + + it('password input has type="password"', () => { + setupMocks(); + renderLoginForm(); + expect(getPassword()).toHaveAttribute('type', 'password'); + }); + }); + + // ── Successful login ─────────────────────────────────────────────────────── + + describe('successful login', () => { + it('calls login with username and password', async () => { + const { mockLogin } = setupMocks({ loginResult: Promise.resolve(true) }); + renderLoginForm(); + + fireEvent.change(getUsername(), { target: { value: 'admin' } }); + fireEvent.change(getPassword(), { target: { value: 'pass123' } }); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith({ username: 'admin', password: 'pass123' }); + }); + }); + + it('calls initData after successful login', async () => { + const { mockInitData } = setupMocks({ loginResult: Promise.resolve(true) }); + renderLoginForm(); + + fireEvent.change(getUsername(), { target: { value: 'admin' } }); + fireEvent.change(getPassword(), { target: { value: 'pass123' } }); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(mockInitData).toHaveBeenCalled(); + }); + }); + + it('navigates to "/channels" when authenticated', async () => { + setupMocks({ isAuthenticated: true }); + renderLoginForm(); + + fireEvent.change(getUsername(), { target: { value: 'admin' } }); + fireEvent.change(getPassword(), { target: { value: 'pass123' } }); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/channels'); + }); + }); + }); + + // ── Failed login ─────────────────────────────────────────────────────────── + + describe('failed login', () => { + it('does not navigate when login fails', async () => { + setupMocks({ loginResult: Promise.resolve(false) }); + renderLoginForm(); + + fireEvent.change(getUsername(), { target: { value: 'admin' } }); + fireEvent.change(getPassword(), { target: { value: 'wrong' } }); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + + it('does not navigate when login throws', async () => { + setupMocks({ loginResult: Promise.reject(new Error('fail')) }); + renderLoginForm(); + + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + }); + + // ── Loading state ────────────────────────────────────────────────────────── + + describe('loading state', () => { + it('disables the login button while loading', async () => { + let resolveLogin; + const loginResult = new Promise((res) => { resolveLogin = res; }); + setupMocks({ loginResult }); + renderLoginForm(); + + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(getLoginButton()).toBeDisabled(); + }); + + resolveLogin(true); + }); + + it('re-enables the login button after login completes', async () => { + setupMocks({ loginResult: Promise.resolve(false) }); + renderLoginForm(); + + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(getLoginButton()).not.toBeDisabled(); + }); + }); + }); + + // ── Remember Me ─────────────────────────────────────────────────────────── + + describe('remember me', () => { + it('saves username to localStorage when Remember Me is checked', async () => { + setupMocks({ loginResult: Promise.resolve(true) }); + renderLoginForm(); + + fireEvent.click(screen.getByLabelText(/remember me/i)); + fireEvent.change(getUsername(), { target: { value: 'savedUser' } }); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(localStorage.getItem('dispatcharr_remembered_username')).toBe('savedUser'); + }); + }); + + it('removes username from localStorage when Remember Me is unchecked', async () => { + localStorage.setItem('dispatcharr_remembered_username', 'oldUser'); + setupMocks({ loginResult: Promise.resolve(true) }); + renderLoginForm(); + + fireEvent.click(screen.getByLabelText(/remember me/i)); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(localStorage.getItem('dispatcharr_remembered_username')).toBeNull(); + }); + }); + + it('pre-fills username from localStorage on mount', () => { + localStorage.setItem('dispatcharr_remembered_username', 'storedUser'); + setupMocks(); + renderLoginForm(); + expect(getUsername()).toHaveValue('storedUser'); + }); + }); + + // ── Save Password ───────────────────────────────────────────────────────── + + describe('save password', () => { + it('saves encoded password to localStorage when Save Password is checked', async () => { + setupMocks({ loginResult: Promise.resolve(true) }); + renderLoginForm(); + + await waitFor(() => { + fireEvent.click(screen.getByLabelText(/remember me/i)); + fireEvent.click(screen.getByLabelText(/save password/i)); + fireEvent.change(getPassword(), { target: { value: 'mySecret' } }); + fireEvent.click(getLoginButton()); + }); + + await waitFor(() => { + expect(localStorage.getItem('dispatcharr_saved_password')).not.toBeNull(); + }); + }); + + it('removes password from localStorage when Save Password is unchecked', async () => { + localStorage.setItem('dispatcharr_saved_password', btoa('oldPass')); + setupMocks({ loginResult: Promise.resolve(true) }); + renderLoginForm(); + + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(localStorage.getItem('dispatcharr_saved_password')).toBeNull(); + }); + }); + + it('pre-fills password from localStorage on mount', () => { + localStorage.setItem('dispatcharr_remembered_username', 'storedUser'); + localStorage.setItem('dispatcharr_saved_password', btoa('storedPass')); + setupMocks(); + renderLoginForm(); + expect(getPassword()).toHaveValue('storedPass'); + }); + }); + + // ── Forgot Password modal ────────────────────────────────────────────────── + + describe('forgot password modal', () => { + it('opens the forgot password modal when the anchor is clicked', () => { + setupMocks(); + renderLoginForm(); + + fireEvent.click(screen.getByText(/forgot password/i)); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('shows the modal title', () => { + setupMocks(); + renderLoginForm(); + + fireEvent.click(screen.getByText(/forgot password/i)); + expect(screen.getByTestId('modal-title')).toBeInTheDocument(); + }); + + it('closes the modal when the close button is clicked', () => { + setupMocks(); + renderLoginForm(); + + fireEvent.click(screen.getByText(/forgot password/i)); + fireEvent.click(screen.getByTestId('modal-close')); + + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/__tests__/Logo.test.jsx b/frontend/src/components/forms/__tests__/Logo.test.jsx new file mode 100644 index 00000000..dd3a33ca --- /dev/null +++ b/frontend/src/components/forms/__tests__/Logo.test.jsx @@ -0,0 +1,445 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import LogoForm from '../Logo'; + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/LogoUtils.js', () => ({ + createLogo: vi.fn(), + updateLogo: vi.fn(), + uploadLogo: vi.fn(), + getFilenameWithoutExtension: vi.fn((name) => name.replace(/\.[^.]+$/, '')), + getResolver: vi.fn(() => undefined), + getUpdateLogoErrorMessage: vi.fn((logo, err) => err.message), + getUploadErrorMessage: vi.fn((err) => err.message), + releaseUrl: vi.fn(), + validateFileSize: vi.fn(() => true), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +// ── Mantine dropzone ─────────────────────────────────────────────────────────── +vi.mock('@mantine/dropzone', () => ({ + Dropzone: vi.fn(({ children, onDrop }) => ( +
onDrop([])}> + {children} +
+ )), + DropzoneAccept: ({ children }) =>
{children}
, + DropzoneReject: ({ children }) =>
{children}
, + DropzoneIdle: ({ children }) =>
{children}
, +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Box: ({ children }) =>
{children}
, + Button: ({ children, onClick, type, loading, variant }) => ( + + ), + Center: ({ children }) =>
{children}
, + Divider: ({ label }) =>
, + Group: ({ children }) =>
{children}
, + Image: ({ src, alt, fallbackSrc }) => ( + {alt} + ), + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Stack: ({ children }) =>
{children}
, + Text: ({ children, size, color }) => ( + {children} + ), + TextInput: ({ label, placeholder, onChange, onBlur, error, disabled, ...rest }) => ( +
+ + {error && {error}} +
+ ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + FileImage: () => , + Upload: () => , + X: () => , +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import * as LogoUtils from '../../../utils/forms/LogoUtils.js'; +import { showNotification } from '../../../utils/notificationUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeLogo = (overrides = {}) => ({ + id: 1, + name: 'My Logo', + url: 'https://example.com/logo.png', + cache_url: 'https://cdn.example.com/logo.png', + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + logo: null, + isOpen: true, + onClose: vi.fn(), + onSuccess: vi.fn(), + ...overrides, +}); + +const makeFile = (name = 'test-logo.png', size = 1024) => + new File(['content'], name, { type: 'image/png', size }); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('LogoForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(LogoUtils.validateFileSize).mockReturnValue(true); + vi.mocked(LogoUtils.createLogo).mockResolvedValue({ id: 2, name: 'New Logo', url: 'https://example.com/new.png' }); + vi.mocked(LogoUtils.updateLogo).mockResolvedValue({ id: 1, name: 'Updated Logo', url: 'https://example.com/updated.png' }); + vi.mocked(LogoUtils.uploadLogo).mockResolvedValue({ id: 3, name: 'uploaded-logo', url: 'https://cdn.example.com/uploaded.png' }); + vi.mocked(LogoUtils.getResolver).mockReturnValue(undefined); + + URL.createObjectURL = vi.fn(); + }); + + afterEach(() => { + delete URL.createObjectURL; + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the modal when isOpen is true', () => { + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('does not render the modal when isOpen is false', () => { + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('shows "Add Logo" title for new logo', () => { + render(); + expect(screen.getByTestId('modal-title')).toHaveTextContent('Add Logo'); + }); + + it('shows "Edit Logo" title when editing existing logo', () => { + render(); + expect(screen.getByTestId('modal-title')).toHaveTextContent('Edit Logo'); + }); + + it('shows "Create" button for new logo', () => { + render(); + expect(screen.getByText('Create')).toBeInTheDocument(); + }); + + it('shows "Update" button when editing existing logo', () => { + render(); + expect(screen.getByText('Update')).toBeInTheDocument(); + }); + + it('pre-fills name input when editing existing logo', () => { + render(); + expect(screen.getByDisplayValue('My Logo')).toBeInTheDocument(); + }); + + it('pre-fills URL input when editing existing logo', () => { + render(); + expect(screen.getByDisplayValue('https://example.com/logo.png')).toBeInTheDocument(); + }); + + it('shows logo preview when logo has cache_url', () => { + render(); + const img = screen.getByAltText('Logo preview'); + expect(img).toHaveAttribute('src', 'https://cdn.example.com/logo.png'); + }); + + it('does not show preview when no logo provided', () => { + render(); + expect(screen.queryByAltText('Logo preview')).not.toBeInTheDocument(); + }); + + it('renders the dropzone', () => { + render(); + expect(screen.getByTestId('dropzone')).toBeInTheDocument(); + }); + + it('calls onClose when Cancel is clicked', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByText('Cancel')); + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onClose when modal close button is clicked', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── Create logo ──────────────────────────────────────────────────────────── + + describe('creating a logo via URL', () => { + it('calls createLogo with entered values on submit', async () => { + render(); + + fireEvent.change(screen.getByPlaceholderText('https://example.com/logo.png'), { + target: { value: 'https://example.com/new.png' }, + }); + fireEvent.change(screen.getByPlaceholderText('Enter logo name'), { + target: { value: 'New Logo' }, + }); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(LogoUtils.createLogo).toHaveBeenCalled(); + }); + }); + + it('shows success notification after creating logo', async () => { + render(); + + fireEvent.change(screen.getByPlaceholderText('https://example.com/logo.png'), { + target: { value: 'https://example.com/new.png' }, + }); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Success', color: 'green' }) + ); + }); + }); + + it('calls onSuccess with type "create" after creating logo', async () => { + const onSuccess = vi.fn(); + render(); + + fireEvent.change(screen.getByPlaceholderText('https://example.com/logo.png'), { + target: { value: 'https://example.com/new.png' }, + }); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ type: 'create' }) + ); + }); + }); + + it('calls onClose after creating logo', async () => { + const onClose = vi.fn(); + render(); + + fireEvent.change(screen.getByPlaceholderText('https://example.com/logo.png'), { + target: { value: 'https://example.com/new.png' }, + }); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('shows error notification when createLogo throws', async () => { + vi.mocked(LogoUtils.createLogo).mockRejectedValue(new Error('API error')); + render(); + + fireEvent.change(screen.getByPlaceholderText('https://example.com/logo.png'), { + target: { value: 'https://example.com/new.png' }, + }); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ); + }); + }); + }); + + // ── Update logo ──────────────────────────────────────────────────────────── + + describe('updating an existing logo', () => { + it('calls updateLogo on submit', async () => { + render(); + fireEvent.click(screen.getByText('Update')); + + await waitFor(() => { + expect(LogoUtils.updateLogo).toHaveBeenCalledWith( + makeLogo(), + expect.objectContaining({ name: 'My Logo' }) + ); + }); + }); + + it('calls onSuccess with type "update" after updating logo', async () => { + const onSuccess = vi.fn(); + render(); + fireEvent.click(screen.getByText('Update')); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ type: 'update' }) + ); + }); + }); + + it('shows success notification after updating logo', async () => { + render(); + fireEvent.click(screen.getByText('Update')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Success', color: 'green' }) + ); + }); + }); + + it('shows error notification when updateLogo throws', async () => { + vi.mocked(LogoUtils.updateLogo).mockRejectedValue(new Error('Server error')); + render(); + fireEvent.click(screen.getByText('Update')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ); + }); + }); + }); + + // ── File upload ──────────────────────────────────────────────────────────── + + describe('file upload via dropzone', () => { + it('calls uploadLogo with the selected file on submit', async () => { + const file = makeFile('my-logo.png'); + + // Override dropzone to pass our test file + const { Dropzone } = await import('@mantine/dropzone'); + vi.mocked(Dropzone).mockImplementation(({ children, onDrop }) => ( +
onDrop([file])}> + {children} +
+ )); + + render(); + + fireEvent.click(screen.getByTestId('dropzone')); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(LogoUtils.uploadLogo).toHaveBeenCalledWith( + file, + expect.any(Object) + ); + }); + }); + + + it('shows error notification when file is too large', async () => { + vi.mocked(LogoUtils.validateFileSize).mockReturnValue(false); + + // Override dropzone to pass a file + const file = makeFile('big.png', 10 * 1024 * 1024); + const { Dropzone } = await import('@mantine/dropzone'); + vi.mocked(Dropzone).mockImplementationOnce(({ children, onDrop }) => ( +
onDrop([file])}> + {children} +
+ )); + + render(); + fireEvent.click(screen.getByTestId('dropzone')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ); + }); + }); + + it('shows upload error notification when uploadLogo throws', async () => { + vi.mocked(LogoUtils.uploadLogo).mockRejectedValue(new Error('Upload failed')); + const file = makeFile('logo.png'); + const { Dropzone } = await import('@mantine/dropzone'); + vi.mocked(Dropzone).mockImplementationOnce(({ children, onDrop }) => ( +
onDrop([file])}> + {children} +
+ )); + + render(); + fireEvent.click(screen.getByTestId('dropzone')); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Upload Error', color: 'red' }) + ); + }); + }); + }); + + // ── URL input behaviour ──────────────────────────────────────────────────── + + describe('URL input behaviour', () => { + it('updates preview when a valid http URL is entered', () => { + render(); + fireEvent.change(screen.getByPlaceholderText('https://example.com/logo.png'), { + target: { value: 'https://example.com/img.png' }, + }); + expect(screen.getByAltText('Logo preview')).toHaveAttribute('src', 'https://example.com/img.png'); + }); + + it('removes preview when URL is cleared', () => { + render(); + fireEvent.change(screen.getByPlaceholderText('https://example.com/logo.png'), { + target: { value: '' }, + }); + expect(screen.queryByAltText('Logo preview')).not.toBeInTheDocument(); + }); + + it('auto-fills name from URL on blur', () => { + render(); + fireEvent.change(screen.getByPlaceholderText('https://example.com/logo.png'), { + target: { value: 'https://example.com/my-channel-logo.png' }, + }); + fireEvent.blur(screen.getByPlaceholderText('https://example.com/logo.png'), { + target: { value: 'https://example.com/my-channel-logo.png' }, + }); + expect(LogoUtils.getFilenameWithoutExtension).toHaveBeenCalled(); + }); + + it('does not throw on blur with invalid URL', () => { + render(); + expect(() => { + fireEvent.blur(screen.getByPlaceholderText('https://example.com/logo.png'), { + target: { value: 'not-a-url' }, + }); + }).not.toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/__tests__/M3U.test.jsx b/frontend/src/components/forms/__tests__/M3U.test.jsx new file mode 100644 index 00000000..122062b4 --- /dev/null +++ b/frontend/src/components/forms/__tests__/M3U.test.jsx @@ -0,0 +1,650 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import M3U from '../M3U'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/userAgents', () => ({ default: vi.fn() })); +vi.mock('../../../store/channels', () => ({ default: vi.fn() })); +vi.mock('../../../store/epgs', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/M3uUtils.js', () => ({ + addPlaylist: vi.fn(), + getPlaylist: vi.fn(), + prepareSubmitValues: vi.fn((values) => values), + updatePlaylist: vi.fn(), +})); + +vi.mock('../../../utils/forms/DummyEpgUtils.js', () => ({ + addEPG: vi.fn(), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +// ── Sub-component mocks ──────────────────────────────────────────────────────── +vi.mock('../M3UProfiles', () => ({ + default: ({ onChange }) => ( +
+ +
+ ), +})); + +vi.mock('../M3UGroupFilter', () => ({ + default: ({ onChange }) => ( +
+ +
+ ), +})); + +vi.mock('../M3UFilters', () => ({ + default: ({ onChange }) => ( +
+ +
+ ), +})); + +vi.mock('../ScheduleInput', () => ({ + default: ({ onChange, value }) => ( +
+ onChange?.(e.target.value)} + /> +
+ ), +})); + +// ── Mantine dates ────────────────────────────────────────────────────────────── +vi.mock('@mantine/dates', () => ({ + DateTimePicker: ({ label, value, onChange, placeholder }) => ( +
+ +
+ ), +})); + +// ── Mantine form ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/form', () => { + let _values = null; + + return { + isNotEmpty: vi.fn(() => (val) => (val ? null : 'Required')), + __resetFormState: () => { _values = null; }, + useForm: vi.fn(({ initialValues = {} } = {}) => { + if (_values === null) { + _values = { ...initialValues }; + } + + return { + key: vi.fn((field) => field), + getValues: () => ({ ..._values }), + setValues: (v) => { Object.assign(_values, v); }, + setFieldValue: (field, val) => { _values[field] = val; }, + reset: () => { _values = { ...initialValues }; }, + submitting: false, + onSubmit: vi.fn((handler) => (e) => { + e?.preventDefault?.(); + if (_values?.name) handler(); + }), + getInputProps: vi.fn((field) => ({ + value: _values?.[field] ?? '', + onChange: vi.fn((e) => { + const val = e?.target?.value ?? e; + if (_values) _values[field] = val; + }), + error: null, + })), + }; + }), + }; +}); + + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Box: ({ children }) =>
{children}
, + Button: ({ children, onClick, type, loading, disabled, variant, color }) => ( + + ), + Checkbox: ({ label, checked, onChange, disabled }) => ( + + ), + Divider: ({ label }) =>
, + FileInput: ({ label, placeholder, onChange, accept, disabled }) => ( +
+ +
+ ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + LoadingOverlay: ({ visible }) => + visible ?
: null, + Modal: ({ children, opened, onClose, title, size }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + NumberInput: ({ label, placeholder, value, onChange, min, max, disabled, error }) => ( +
+ + {error && {error}} +
+ ), + PasswordInput: ({ label, placeholder, value, onChange, onBlur, error, disabled }) => ( +
+ + {error && {error}} +
+ ), + Select: ({ label, placeholder, value, onChange, data, disabled, error }) => ( +
+ + {error && {error}} +
+ ), + Stack: ({ children }) =>
{children}
, + Switch: ({ label, checked, onChange, disabled }) => ( + + ), + TextInput: ({ id, label, placeholder, value, onChange, onBlur, error, disabled, ...rest }) => ( +
+ + {error && {error}} +
+ ), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useUserAgentsStore from '../../../store/userAgents'; +import useChannelsStore from '../../../store/channels'; +import useEPGsStore from '../../../store/epgs'; +import useVODStore from '../../../store/useVODStore'; +import * as M3uUtils from '../../../utils/forms/M3uUtils.js'; +import * as DummyEpgUtils from '../../../utils/forms/DummyEpgUtils.js'; +import * as mantineForm from '@mantine/form'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeM3uAccount = (overrides = {}) => ({ + id: 1, + name: 'Test M3U', + server_url: 'http://example.com/playlist.m3u', + username: 'user1', + password: 'pass1', + account_type: 'XC', + max_streams: 0, + refresh_interval: 24, + auto_refresh: false, + is_active: true, + custom_properties: {}, + ...overrides, +}); + +const makeUserAgent = (overrides = {}) => ({ + id: 1, + name: 'Default Agent', + user_agent: 'Mozilla/5.0', + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + m3uAccount: null, + isOpen: true, + onClose: vi.fn(), + ...overrides, +}); + +const setupStores = (overrides = {}) => { + const fetchUserAgents = vi.fn(); + const fetchChannelGroups = vi.fn(); + const fetchEPGs = vi.fn(); + const fetchCategories = vi.fn(); + + useUserAgentsStore.mockImplementation((selector) => { + const state = { + userAgents: overrides.userAgents || [], + fetchUserAgents, + }; + return selector(state); + }); + + useChannelsStore.mockImplementation((selector) => { + const state = { + fetchChannelGroups, + }; + return selector(state); + }); + + useEPGsStore.mockImplementation((selector) => { + const state = { + fetchEPGs, + }; + return selector(state); + }); + + useVODStore.mockImplementation((selector) => { + const state = { + fetchCategories, + }; + return selector(state); + }); + + return { + fetchUserAgents, + fetchChannelGroups, + fetchEPGs, + fetchCategories, + }; +}; + + +// ────────────────────────────────────────────────────────────────────────────── + +describe('M3U', () => { + beforeEach(() => { + vi.clearAllMocks(); + mantineForm.__resetFormState(); + vi.mocked(M3uUtils.addPlaylist).mockResolvedValue(makeM3uAccount({ id: 2 })); + vi.mocked(M3uUtils.updatePlaylist).mockResolvedValue(makeM3uAccount()); + vi.mocked(M3uUtils.getPlaylist).mockResolvedValue(makeM3uAccount()); + vi.mocked(M3uUtils.prepareSubmitValues).mockImplementation((v) => v); + vi.mocked(DummyEpgUtils.addEPG).mockResolvedValue({ id: 10, name: 'Dummy EPG' }); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the modal when isOpen is true', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('does not render the modal when isOpen is false', () => { + setupStores(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('renders Name input', () => { + setupStores(); + render(); + expect(screen.getByTestId('text-input-name')).toBeInTheDocument(); + }); + + it('renders URL input', () => { + setupStores(); + render(); + expect(screen.getByTestId('text-input-server_url')).toBeInTheDocument(); + }); + + it('renders submit button with "Add" label for new account', () => { + setupStores(); + render(); + expect( + screen.getByRole('button', { name: /add|create|save/i }) + ).toBeInTheDocument(); + }); + + it('renders submit button with "Update" or "Save" label for existing account', () => { + setupStores(); + render(); + expect( + screen.getByRole('button', { name: /update|save/i }) + ).toBeInTheDocument(); + }); + + it('pre-fills name when editing', () => { + setupStores(); + render(); + expect(screen.getByDisplayValue('Test M3U')).toBeInTheDocument(); + }); + + it('pre-fills URL when editing', () => { + setupStores(); + render(); + expect( + screen.getByDisplayValue('http://example.com/playlist.m3u') + ).toBeInTheDocument(); + }); + + it('renders M3UProfiles sub-component', () => { + setupStores(); + render(); + expect(screen.getByTestId('m3u-profiles')).toBeInTheDocument(); + }); + + it('renders M3UGroupFilter sub-component', () => { + setupStores(); + render(); + expect(screen.getByTestId('m3u-group-filter')).toBeInTheDocument(); + }); + + it('renders M3UFilters sub-component', () => { + setupStores(); + render(); + expect(screen.getByTestId('m3u-filters')).toBeInTheDocument(); + }); + + it('renders ScheduleInput sub-component', () => { + setupStores(); + render(); + expect(screen.getByTestId('schedule-input')).toBeInTheDocument(); + }); + + it('populates user agent select with agents from store', () => { + setupStores({ + userAgents: [ + makeUserAgent({ id: 1, name: 'Agent One' }), + makeUserAgent({ id: 2, name: 'Agent Two' }), + ], + }); + render(); + expect(screen.getByText('Agent One')).toBeInTheDocument(); + expect(screen.getByText('Agent Two')).toBeInTheDocument(); + }); + }); + + // ── Cancel behaviour ─────────────────────────────────────────────────────── + + describe('cancel behaviour', () => { + it('calls onClose when modal X is clicked', () => { + const onClose = vi.fn(); + setupStores(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── Adding a playlist ────────────────────────────────────────────────────── + + describe('adding a new playlist', () => { + const fillRequiredFields = () => { + const nameInput = screen.getByTestId('text-input-name'); + fireEvent.change(nameInput, { target: { value: 'New Playlist' } }); + + const urlInput = screen.getByTestId('text-input-server_url'); + if (urlInput) { + fireEvent.change(urlInput, { + target: { value: 'http://example.com/new.m3u' }, + }); + } + }; + + it('calls addPlaylist on valid submit', async () => { + setupStores(); + render(); + fillRequiredFields(); + fireEvent.click(screen.getByRole('button', { name: /add|create|save/i })); + await waitFor(() => { + expect(M3uUtils.addPlaylist).toHaveBeenCalled(); + }); + }); + + it('calls prepareSubmitValues before addPlaylist', async () => { + setupStores(); + render(); + fillRequiredFields(); + fireEvent.click(screen.getByRole('button', { name: /add|create|save/i })); + await waitFor(() => { + expect(M3uUtils.prepareSubmitValues).toHaveBeenCalled(); + }); + }); + + it('calls onClose after successful add', async () => { + const onClose = vi.fn(); + setupStores(); + render( + + ); + fillRequiredFields(); + fireEvent.click(screen.getByRole('button', { name: /add|create|save/i })); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + }); + + // ── Updating a playlist ──────────────────────────────────────────────────── + + describe('updating an existing playlist', () => { + it('calls updatePlaylist on submit', async () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /update|save/i })); + await waitFor(() => { + expect(M3uUtils.updatePlaylist).toHaveBeenCalled(); + }); + }); + + it('does not call addPlaylist when updating', async () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /update|save/i })); + await waitFor(() => { + expect(M3uUtils.updatePlaylist).toHaveBeenCalled(); + }); + expect(M3uUtils.addPlaylist).not.toHaveBeenCalled(); + }); + + it('calls onClose after successful update', async () => { + const onClose = vi.fn(); + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /update|save/i })); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + }); + + // ── Form validation ──────────────────────────────────────────────────────── + + describe('form validation', () => { + it('does not call addPlaylist when Name is empty', async () => { + setupStores(); + render(); + // Clear name if pre-filled, then submit + const nameInput = screen.getByTestId('text-input-name'); + fireEvent.change(nameInput, { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: /add|create|save/i })); + await new Promise((r) => setTimeout(r, 50)); + expect(M3uUtils.addPlaylist).not.toHaveBeenCalled(); + }); + }); + + // ── Refresh / schedule behaviour ─────────────────────────────────────────── + + describe('schedule and refresh', () => { + it('renders the ScheduleInput', () => { + setupStores(); + render(); + expect(screen.getByTestId('schedule-input')).toBeInTheDocument(); + }); + }); + + // ── Max streams ──────────────────────────────────────────────────────────── + + describe('max streams field', () => { + it('renders max streams number input', () => { + setupStores(); + render(); + expect( + screen.getByRole('spinbutton', { name: /max.?stream/i }) + ).toBeInTheDocument(); + }); + + it('pre-fills max_streams when editing', () => { + setupStores(); + render( + + ); + expect(screen.getByDisplayValue('5')).toBeInTheDocument(); + }); + }); + + // ── User agent select ────────────────────────────────────────────────────── + + describe('user agent select', () => { + it('renders user agent select', () => { + setupStores(); + render(); + expect( + screen.getByRole('combobox', { name: /user.?agent/i }) + ).toBeInTheDocument(); + }); + }); + + // ── Credential fields ────────────────────────────────────────────────────── + + describe('credential fields', () => { + it('renders username input', () => { + setupStores(); + render(); + expect(screen.getByTestId('text-input-username')).toBeInTheDocument(); + }); + + it('renders password input', () => { + setupStores(); + render(); + expect( + document.querySelector('input[type="password"]') + ).toBeInTheDocument(); + }); + + it('pre-fills username when editing', () => { + setupStores(); + render(); + expect(screen.getByDisplayValue('user1')).toBeInTheDocument(); + }); + }); + + // ── Dummy EPG creation ───────────────────────────────────────────────────── + + describe('dummy EPG auto-creation', () => { + it('calls addEPG after successfully adding a playlist', async () => { + setupStores(); + render(); + + const nameInput = screen.getByTestId('text-input-name'); + fireEvent.change(nameInput, { target: { value: 'My Playlist' } }); + + const urlInput = screen.getByTestId('text-input-server_url'); + if (urlInput) { + fireEvent.change(urlInput, { target: { value: 'http://example.com/p.m3u' } }); + } + + fireEvent.click(screen.getByRole('button', { name: /add|create|save/i })); + await waitFor(() => { + expect(M3uUtils.addPlaylist).toHaveBeenCalled(); + }); + // addEPG may be called conditionally — assert it was called or not based on a checkbox + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/__tests__/M3UFilter.test.jsx b/frontend/src/components/forms/__tests__/M3UFilter.test.jsx new file mode 100644 index 00000000..4b84b67e --- /dev/null +++ b/frontend/src/components/forms/__tests__/M3UFilter.test.jsx @@ -0,0 +1,374 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import M3UFilter from '../M3UFilter'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/playlists', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/M3uFilterUtils.js', () => ({ + addM3UFilter: vi.fn(), + updateM3UFilter: vi.fn(), +})); + +vi.mock('../../../utils', () => ({ + setCustomProperty: vi.fn((obj, key, value) => ({ ...obj, [key]: value })), +})); + +// ── Constants mock ───────────────────────────────────────────────────────────── +vi.mock('../../../constants', () => ({ + M3U_FILTER_TYPES: [ + { value: 'include', label: 'Include' }, + { value: 'exclude', label: 'Exclude' }, + ], +})); + +// ── Mantine form ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/form', () => { + let _values = null; + + return { + isNotEmpty: vi.fn(() => (val) => (val ? null : 'Required')), + __resetFormState: () => { _values = null; }, + useForm: vi.fn(({ initialValues = {} } = {}) => { + if (_values === null) { + _values = { ...initialValues }; + } + + return { + key: vi.fn((field) => field), + getValues: () => ({ ..._values }), + setValues: (v) => { Object.assign(_values, v); }, + setFieldValue: (field, val) => { _values[field] = val; }, + reset: () => { _values = { ...initialValues }; }, + submitting: false, + onSubmit: vi.fn((handler) => (e) => { + e?.preventDefault?.(); + handler(); + }), + getInputProps: vi.fn((field, options = {}) => { + const isCheckbox = options?.type === 'checkbox'; + return { + ...(isCheckbox + ? { checked: !!(_values?.[field]) } + : { value: _values?.[field] ?? '' }), + onChange: vi.fn((e) => { + const val = isCheckbox ? (e?.target?.checked ?? e) : (e?.target?.value ?? e); + if (_values) _values[field] = val; + }), + error: null, + }; + }), + }; + }), + }; +}); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Box: ({ children }) =>
{children}
, + Button: ({ children, onClick, type, loading, disabled, variant, color }) => ( + + ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Select: ({ label, placeholder, value, onChange, data, error, disabled }) => ( +
+ + {error && {error}} +
+ ), + Stack: ({ children }) =>
{children}
, + Switch: ({ id, label, checked, onChange, disabled }) => ( + + ), + TextInput: ({ label, placeholder, value, onChange, onBlur, error, disabled }) => ( +
+ + {error && {error}} +
+ ), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import usePlaylistsStore from '../../../store/playlists'; +import * as M3uFilterUtils from '../../../utils/forms/M3uFilterUtils.js'; +import * as mantineForm from '@mantine/form'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeM3U = (overrides = {}) => ({ + id: 1, + name: 'Test Playlist', + custom_properties: {}, + filters: [], + ...overrides, +}); + +const makeFilter = (overrides = {}) => ({ + id: 10, + filter_type: 'include', + regex_pattern: 'HBO.*', + exclude: false, + custom_properties: { case_sensitive: false }, + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + filter: null, + m3u: makeM3U(), + isOpen: true, + onClose: vi.fn(), + ...overrides, +}); + +const setupStores = ({ fetchPlaylist = vi.fn().mockResolvedValue(undefined) } = {}) => { + vi.mocked(usePlaylistsStore).mockImplementation((sel) => + sel({ fetchPlaylist }) + ); + return { fetchPlaylist }; +}; + +// ────────────────────────────────────────────────────────────────────────────── + +describe('M3UFilter', () => { + beforeEach(() => { + vi.clearAllMocks(); + mantineForm.__resetFormState(); + vi.mocked(M3uFilterUtils.addM3UFilter).mockResolvedValue({ id: 99 }); + vi.mocked(M3uFilterUtils.updateM3UFilter).mockResolvedValue({}); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the modal when isOpen is true', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('does not render when isOpen is false', () => { + setupStores(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('renders modal title as "Filter"', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal-title')).toHaveTextContent('Filter'); + }); + + it('renders filter type Select', () => { + setupStores(); + render(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('renders filter type options from M3U_FILTER_TYPES', () => { + setupStores(); + render(); + expect(screen.getByRole('option', { name: 'Include' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Exclude' })).toBeInTheDocument(); + }); + + it('renders a text input for the pattern', () => { + setupStores(); + render(); + expect(screen.getByLabelText('Regex Pattern')).toBeInTheDocument(); + }); + + it('renders the exclusion switch', () => { + setupStores(); + render(); + expect(screen.getByTestId('exclude')).toBeInTheDocument(); + }); + + it('renders the case sensitivity switch', () => { + setupStores(); + render(); + expect(screen.getByTestId('case_sensitive')).toBeInTheDocument(); + }); + + it('renders a submit button', () => { + setupStores(); + render(); + expect( + screen.getByRole('button', { name: /add|save|submit/i }) + ).toBeInTheDocument(); + }); + }); + + // ── Form reset on open/filter change ────────────────────────────────────── + + describe('form reset behaviour', () => { + it('shows empty pattern field for a new filter', () => { + setupStores(); + render(); + expect(screen.getByRole('textbox')).toHaveValue(''); + }); + }); + + // ── Cancel behaviour ─────────────────────────────────────────────────────── + + describe('cancel behaviour', () => { + it('calls onClose when modal X is clicked', () => { + const onClose = vi.fn(); + setupStores(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── Adding a filter ──────────────────────────────────────────────────────── + + describe('adding a new filter', () => { + const fillForm = () => { + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'include' }, + }); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'Sports.*' }, + }); + }; + + it('calls addM3UFilter on submit for a new filter', async () => { + setupStores(); + render(); + fillForm(); + fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i })); + await waitFor(() => { + expect(M3uFilterUtils.addM3UFilter).toHaveBeenCalled(); + }); + }); + + it('does not call updateM3UFilter when adding', async () => { + setupStores(); + render(); + fillForm(); + fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i })); + await waitFor(() => { + expect(M3uFilterUtils.addM3UFilter).toHaveBeenCalled(); + }); + expect(M3uFilterUtils.updateM3UFilter).not.toHaveBeenCalled(); + }); + + it('calls fetchPlaylist after successful add', async () => { + const { fetchPlaylist } = setupStores(); + render(); + fillForm(); + fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i })); + await waitFor(() => { + expect(fetchPlaylist).toHaveBeenCalledWith(makeM3U().id); + }); + }); + + it('calls onClose after successful add', async () => { + const onClose = vi.fn(); + setupStores(); + render(); + fillForm(); + fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i })); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + }); + + // ── Updating a filter ────────────────────────────────────────────────────── + + describe('updating an existing filter', () => { + it('calls updateM3UFilter on submit for an existing filter', async () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /add|save|update|submit/i })); + await waitFor(() => { + expect(M3uFilterUtils.updateM3UFilter).toHaveBeenCalled(); + }); + }); + + it('does not call addM3UFilter when updating', async () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /add|save|update|submit/i })); + await waitFor(() => { + expect(M3uFilterUtils.updateM3UFilter).toHaveBeenCalled(); + }); + expect(M3uFilterUtils.addM3UFilter).not.toHaveBeenCalled(); + }); + + it('calls fetchPlaylist after successful update', async () => { + const { fetchPlaylist } = setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /add|save|update|submit/i })); + await waitFor(() => { + expect(fetchPlaylist).toHaveBeenCalledWith(makeM3U().id); + }); + }); + + it('calls onClose after successful update', async () => { + const onClose = vi.fn(); + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /add|save|update|submit/i })); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/__tests__/M3UFilters.test.jsx b/frontend/src/components/forms/__tests__/M3UFilters.test.jsx new file mode 100644 index 00000000..e3b3b160 --- /dev/null +++ b/frontend/src/components/forms/__tests__/M3UFilters.test.jsx @@ -0,0 +1,595 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import M3UFilters from '../M3UFilters'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/playlists', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/M3uFilterUtils.js', () => ({ + deleteM3UFilter: vi.fn(), + updateM3UFilter: vi.fn(), +})); + +// ── Constants mock ───────────────────────────────────────────────────────────── +vi.mock('../../../constants', () => ({ + M3U_FILTER_TYPES: [ + { value: 'include', label: 'Include' }, + { value: 'exclude', label: 'Exclude' }, + ], +})); + +// ── Sub-component mocks ──────────────────────────────────────────────────────── +vi.mock('../M3UFilter', () => ({ + default: ({ isOpen, onClose, filter, m3u }) => + isOpen ? ( +
+
{filter?.id ?? 'new'}
+ + +
+ ) : null, +})); + +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ opened, onConfirm, onClose, title }) => + opened ? ( +
+
{title}
+ + +
+ ) : null, +})); + +// ── DnD kit mocks ────────────────────────────────────────────────────────────── +vi.mock('@dnd-kit/core', () => ({ + closestCenter: vi.fn(), + DndContext: vi.fn(({ children, onDragEnd }) => ( +
+ {children} +
+ )), + KeyboardSensor: vi.fn(), + MouseSensor: vi.fn(), + TouchSensor: vi.fn(), + useDraggable: () => ({ + attributes: {}, + listeners: {}, + setNodeRef: vi.fn(), + }), + useSensor: vi.fn((sensor) => sensor), + useSensors: vi.fn((...sensors) => sensors), +})); + +vi.mock('@dnd-kit/sortable', () => ({ + arrayMove: vi.fn((arr, from, to) => { + const result = [...arr]; + const [item] = result.splice(from, 1); + result.splice(to, 0, item); + return result; + }), + SortableContext: ({ children }) => ( +
{children}
+ ), + useSortable: () => ({ + transform: null, + transition: null, + setNodeRef: vi.fn(), + isDragging: false, + }), + verticalListSortingStrategy: vi.fn(), +})); + +vi.mock('@dnd-kit/utilities', () => ({ + CSS: { Transform: { toString: vi.fn(() => '') } }, +})); + +vi.mock('@dnd-kit/modifiers', () => ({ + restrictToVerticalAxis: vi.fn(), +})); + +// ── lucide-react mocks ───────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + GripHorizontal: () => , + Info: () => , + SquareMinus: () => , + SquarePen: () => , +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Alert: ({ children, title }) => ( +
+
{title}
+ {children} +
+ ), + Box: ({ children, ref }) =>
{children}
, + Button: ({ children, onClick, disabled, loading, variant, color }) => ( + + ), + Center: ({ children }) =>
{children}
, + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Text: ({ children, size, c, fw }) => ( + + {children} + + ), + useMantineTheme: () => ({ + tailwind: { + red: { 6: '#f56565' }, + green: { 5: '#48bb78'}, + yellow: { 3: '#ecc94b'}, + }, + }), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import usePlaylistsStore from '../../../store/playlists'; +import useWarningsStore from '../../../store/warnings'; +import * as M3uFilterUtils from '../../../utils/forms/M3uFilterUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeFilter = (overrides = {}) => ({ + id: 1, + filter_type: 'include', + regex_pattern: 'HBO.*', + is_active: true, + exclude: false, + order: 0, + ...overrides, +}); + +const makePlaylist = (overrides = {}) => ({ + id: 10, + name: 'Test Playlist', + filters: [ + makeFilter({ id: 1, regex_pattern: 'HBO.*', order: 0 }), + makeFilter({ id: 2, filter_type: 'exclude', regex_pattern: 'ESPN.*', order: 1 }), + ], + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + playlist: makePlaylist(), + isOpen: true, + onClose: vi.fn(), + ...overrides, +}); + +const setupStores = ({ + fetchPlaylist = vi.fn().mockResolvedValue(undefined), + isWarningSuppressed = vi.fn().mockReturnValue(false), + suppressWarning = vi.fn(), + } = {}) => { + vi.mocked(usePlaylistsStore).mockImplementation((sel) => + sel({ fetchPlaylist }) + ); + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ isWarningSuppressed, suppressWarning }) + ); + return { fetchPlaylist, isWarningSuppressed, suppressWarning }; +}; + +// ────────────────────────────────────────────────────────────────────────────── + +describe('M3UFilters', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(M3uFilterUtils.deleteM3UFilter).mockResolvedValue(undefined); + vi.mocked(M3uFilterUtils.updateM3UFilter).mockResolvedValue(undefined); + }); + + // ── Guard conditions ─────────────────────────────────────────────────────── + + describe('guard conditions', () => { + it('does not render modal when isOpen is false', () => { + setupStores(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('does not render modal when playlist is null', () => { + setupStores(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('does not render modal when playlist has no id', () => { + setupStores(); + render( + + ); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the modal when isOpen is true with a valid playlist', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('renders the modal title', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal-title')).toBeInTheDocument(); + }); + + it('renders an "Add Filter" button', () => { + setupStores(); + render(); + expect( + screen.getByRole('button', { name: /new/i }) + ).toBeInTheDocument(); + }); + + it('renders filter patterns from playlist', () => { + setupStores(); + render(); + expect(screen.getByText('HBO.*')).toBeInTheDocument(); + expect(screen.getByText('ESPN.*')).toBeInTheDocument(); + }); + + it('renders filter type labels for each filter', () => { + setupStores(); + render(); + expect(screen.getAllByText('Include').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Exclude').length).toBeGreaterThanOrEqual(1); + }); + + it('renders edit action icons for each filter', () => { + setupStores(); + render(); + const penIcons = screen.getAllByTestId('icon-square-pen'); + expect(penIcons).toHaveLength(2); + }); + + it('renders delete action icons for each filter', () => { + setupStores(); + render(); + const minusIcons = screen.getAllByTestId('icon-square-minus'); + expect(minusIcons).toHaveLength(2); + }); + + it('renders drag handle icons for each filter', () => { + setupStores(); + render(); + const gripIcons = screen.getAllByTestId('icon-grip'); + expect(gripIcons).toHaveLength(2); + }); + + it('renders empty state when playlist has no filters', () => { + setupStores(); + const playlist = makePlaylist({ + filters: [], + }); + render(); + expect(screen.queryByTestId('icon-square-pen')).not.toBeInTheDocument(); + }); + + it('wraps list in DndContext', () => { + setupStores(); + render(); + expect(screen.getByTestId('dnd-context')).toBeInTheDocument(); + }); + + it('wraps list in SortableContext', () => { + setupStores(); + render(); + expect(screen.getByTestId('sortable-context')).toBeInTheDocument(); + }); + }); + + // ── Close / cancel ───────────────────────────────────────────────────────── + + describe('close behaviour', () => { + it('calls onClose when modal X is clicked', () => { + const onClose = vi.fn(); + setupStores(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── Opening the filter editor ────────────────────────────────────────────── + + describe('opening the filter editor', () => { + it('opens M3UFilter editor when Add Filter is clicked', () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /new/i })); + expect(screen.getByTestId('m3u-filter-editor')).toBeInTheDocument(); + }); + + it('passes null filter to editor when adding a new filter', () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /new/i })); + expect(screen.getByTestId('editor-filter-id')).toHaveTextContent('new'); + }); + + it('opens editor with the correct filter when edit icon is clicked', () => { + setupStores(); + render(); + const editButtons = screen + .getAllByTestId('icon-square-pen') + .map((icon) => icon.closest('button')); + fireEvent.click(editButtons[0]); + expect(screen.getByTestId('m3u-filter-editor')).toBeInTheDocument(); + expect(screen.getByTestId('editor-filter-id')).toHaveTextContent('1'); + }); + + it('closes editor when editor fires onClose without updated playlist', () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /new/i })); + fireEvent.click(screen.getByTestId('editor-close')); + expect(screen.queryByTestId('m3u-filter-editor')).not.toBeInTheDocument(); + }); + + it('closes editor and refreshes filters when editor fires onClose with playlist', async () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /new/i })); + fireEvent.click(screen.getByTestId('editor-close-with-playlist')); + expect(screen.queryByTestId('m3u-filter-editor')).not.toBeInTheDocument(); + }); + }); + + // ── Delete filter ────────────────────────────────────────────────────────── + + describe('deleting a filter', () => { + const clickDeleteFirst = () => { + const deleteButtons = screen + .getAllByTestId('icon-square-minus') + .map((icon) => icon.closest('button')); + fireEvent.click(deleteButtons[0]); + }; + + it('opens confirmation dialog when delete icon is clicked (warning not suppressed)', () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) }); + render(); + clickDeleteFirst(); + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + }); + + it('calls deleteM3UFilter directly when warning is suppressed', async () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(true) }); + render(); + clickDeleteFirst(); + await waitFor(() => { + expect(M3uFilterUtils.deleteM3UFilter).toHaveBeenCalled(); + }); + }); + + it('does not open confirmation dialog when warning is suppressed', async () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(true) }); + render(); + clickDeleteFirst(); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + + it('calls deleteM3UFilter after confirming deletion', async () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-yes')); + await waitFor(() => { + expect(M3uFilterUtils.deleteM3UFilter).toHaveBeenCalled(); + }); + }); + + it('calls fetchPlaylist after successful deletion', async () => { + const { fetchPlaylist } = setupStores({ + isWarningSuppressed: vi.fn().mockReturnValue(false), + }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-yes')); + await waitFor(() => { + expect(fetchPlaylist).toHaveBeenCalledWith(10); + }); + }); + + it('closes confirmation dialog after confirming deletion', async () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-yes')); + await waitFor(() => { + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + }); + + it('closes confirmation dialog when No is clicked', () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-no')); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + + it('does not call deleteM3UFilter when No is clicked', () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-no')); + expect(M3uFilterUtils.deleteM3UFilter).not.toHaveBeenCalled(); + }); + + it('does not call fetchPlaylist when deleteM3UFilter throws', async () => { + vi.mocked(M3uFilterUtils.deleteM3UFilter).mockRejectedValue( + new Error('fail') + ); + const { fetchPlaylist } = setupStores({ + isWarningSuppressed: vi.fn().mockReturnValue(false), + }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-yes')); + await waitFor(() => { + expect(M3uFilterUtils.deleteM3UFilter).toHaveBeenCalled(); + }); + expect(fetchPlaylist).not.toHaveBeenCalled(); + }); + }); + + // ── Initialization from playlist prop ───────────────────────────────────── + + describe('filter initialization', () => { + it('loads filters from playlist.custom_properties.filters on mount', () => { + setupStores(); + render(); + expect(screen.getByText('HBO.*')).toBeInTheDocument(); + expect(screen.getByText('ESPN.*')).toBeInTheDocument(); + }); + + it('updates displayed filters when playlist prop changes', () => { + setupStores(); + const { rerender } = render(); + const updatedPlaylist = makePlaylist({ + filters: [makeFilter({ id: 3, regex_pattern: 'CNN.*', order: 0 })], + }); + rerender( + + ); + expect(screen.getByText('CNN.*')).toBeInTheDocument(); + expect(screen.queryByText('HBO.*')).not.toBeInTheDocument(); + }); + }); + + // ── Drag and drop reordering ─────────────────────────────────────────────── + + describe('drag and drop', () => { + it('calls updateM3UFilter for reordered filters after drag end', async () => { + setupStores(); + // We need to trigger handleDragEnd manually via the DndContext mock. + // Re-mock DndContext to capture and expose onDragEnd. + let capturedOnDragEnd; + const { DndContext } = await import('@dnd-kit/core'); + vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => { + capturedOnDragEnd = onDragEnd; + return
{children}
; + }); + + render(); + + // Simulate drag: move filter id=1 over filter id=2 + await waitFor(() => expect(capturedOnDragEnd).toBeDefined()); + capturedOnDragEnd({ active: { id: 1 }, over: { id: 2 } }); + + await waitFor(() => { + expect(M3uFilterUtils.updateM3UFilter).toHaveBeenCalled(); + }); + }); + + it('does not call updateM3UFilter when drag ends on same position', async () => { + setupStores(); + let capturedOnDragEnd; + const { DndContext } = await import('@dnd-kit/core'); + vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => { + capturedOnDragEnd = onDragEnd; + return
{children}
; + }); + + render(); + + await waitFor(() => expect(capturedOnDragEnd).toBeDefined()); + capturedOnDragEnd({ active: { id: 1 }, over: { id: 1 } }); + + await waitFor(() => { + expect(M3uFilterUtils.updateM3UFilter).not.toHaveBeenCalled(); + }); + }); + + it('does not call updateM3UFilter when there is no over target', async () => { + setupStores(); + let capturedOnDragEnd; + const { DndContext } = await import('@dnd-kit/core'); + vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => { + capturedOnDragEnd = onDragEnd; + return
{children}
; + }); + + render(); + + await waitFor(() => expect(capturedOnDragEnd).toBeDefined()); + capturedOnDragEnd({ active: { id: 1 }, over: null }); + + expect(M3uFilterUtils.updateM3UFilter).not.toHaveBeenCalled(); + }); + }); + + // ── Warning suppression ──────────────────────────────────────────────────── + + describe('warning suppression', () => { + it('calls suppressWarning when user confirms and opts to suppress', async () => { + // This depends on ConfirmationDialog exposing a "suppress" callback. + // Here we verify suppressWarning is available from the store. + const { suppressWarning } = setupStores({ + isWarningSuppressed: vi.fn().mockReturnValue(false), + }); + render(); + expect(suppressWarning).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/__tests__/M3UGroupFilter.test.jsx b/frontend/src/components/forms/__tests__/M3UGroupFilter.test.jsx new file mode 100644 index 00000000..c1a4b1f8 --- /dev/null +++ b/frontend/src/components/forms/__tests__/M3UGroupFilter.test.jsx @@ -0,0 +1,420 @@ +import { + render, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import M3UGroupFilter from '../M3UGroupFilter'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/channels', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/forms/M3uGroupFilterUtils.js', () => ({ + saveAndRefreshPlaylist: vi.fn(), + buildGroupStates: vi.fn(), +})); + +// ── Sub-component mocks ──────────────────────────────────────────────────────── +vi.mock('../LiveGroupFilter', () => ({ + default: ({ groupStates, setGroupStates, autoEnableNewGroupsLive, setAutoEnableNewGroupsLive }) => ( +
+ {groupStates?.length ?? 0} + + +
+ ), +})); + +vi.mock('../VODCategoryFilter', () => ({ + default: ({ + categoryStates, + setCategoryStates, + autoEnableNewGroups, + setAutoEnableNewGroups, + type, + }) => ( +
+ {categoryStates?.length ?? 0} + + +
+ ), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Button: ({ children, onClick, loading, disabled, variant, color }) => ( + + ), + Flex: ({ children }) =>
{children}
, + LoadingOverlay: ({ visible }) => + visible ?
: null, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Stack: ({ children }) =>
{children}
, + Tabs: ({ children, defaultValue, value }) => ( +
+ {children} +
+ ), + TabsList: ({ children }) =>
{children}
, + TabsPanel: ({ children, value }) => ( +
{children}
+ ), + TabsTab: ({ children, value, onClick }) => ( + + ), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useChannelsStore from '../../../store/channels'; +import useVODStore from '../../../store/useVODStore'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import * as M3uGroupFilterUtils from '../../../utils/forms/M3uGroupFilterUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makePlaylist = (overrides = {}) => ({ + id: 1, + name: 'Test Playlist', + account_type: 'XC', + enable_vod: true, + ...overrides, +}); + +const makeGroup = (overrides = {}) => ({ + id: 1, + name: 'Group A', + playlist_id: 1, + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + playlist: makePlaylist(), + isOpen: true, + onClose: vi.fn(), + ...overrides, +}); + +const setupStores = ({ + channelGroups = [makeGroup(), makeGroup({ id: 2, name: 'Group B' })], + fetchCategories = vi.fn().mockResolvedValue(undefined), + } = {}) => { + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ channelGroups }) + ); + vi.mocked(useVODStore).mockImplementation((sel) => + sel({ fetchCategories }) + ); + return { channelGroups, fetchCategories }; +}; + +// ────────────────────────────────────────────────────────────────────────────── + +describe('M3UGroupFilter', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockResolvedValue(undefined); + vi.mocked(M3uGroupFilterUtils.buildGroupStates).mockReturnValue([]); + }); + + // ── Guard conditions ─────────────────────────────────────────────────────── + + describe('guard conditions', () => { + it('does not render modal when isOpen is false', () => { + setupStores(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the modal when isOpen is true with a valid playlist', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('renders the modal title', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal-title')).toBeInTheDocument(); + }); + + it('renders tab list with Live and VOD tabs', () => { + setupStores(); + render(); + expect(screen.getByTestId('tabs-list')).toBeInTheDocument(); + expect(screen.getByTestId('tab-live')).toBeInTheDocument(); + expect(screen.getByTestId('tab-vod-movie')).toBeInTheDocument(); + expect(screen.getByTestId('tab-vod-series')).toBeInTheDocument(); + }); + + it('renders LiveGroupFilter panel', () => { + setupStores(); + render(); + expect(screen.getByTestId('live-group-filter')).toBeInTheDocument(); + }); + + it('renders VODCategoryFilter panels', () => { + setupStores(); + render(); + expect(screen.getByTestId('vod-category-filter-movie')).toBeInTheDocument(); + expect(screen.getByTestId('vod-category-filter-series')).toBeInTheDocument(); + }); + + it('renders a Save button', () => { + setupStores(); + render(); + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + + it('renders a Cancel button', () => { + setupStores(); + render(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + }); + + // ── Initialization ───────────────────────────────────────────────────────── + + describe('initialization', () => { + it('calls buildGroupStates with channelGroups and playlist on mount', async () => { + const { channelGroups } = setupStores(); + render(); + await waitFor(() => { + expect(M3uGroupFilterUtils.buildGroupStates).toHaveBeenCalledWith( + channelGroups, + undefined + ); + }); + }); + + it('calls fetchCategories on mount', async () => { + const { fetchCategories } = setupStores(); + render(); + await waitFor(() => { + expect(fetchCategories).toHaveBeenCalled(); + }); + }); + + it('re-initializes when playlist prop changes', async () => { + setupStores(); + const { rerender } = render(); + const updatedPlaylist = makePlaylist({ id: 2, name: 'Updated Playlist', channel_groups: [{ id: 3, name: 'Group C', playlist_id: 2 }] }); + rerender(); + await waitFor(() => { + expect(M3uGroupFilterUtils.buildGroupStates).toHaveBeenCalledWith( + expect.anything(), + updatedPlaylist.channel_groups + ); + }); + }); + }); + + // ── Close / cancel behaviour ─────────────────────────────────────────────── + + describe('close / cancel behaviour', () => { + it('calls onClose when modal X is clicked', () => { + const onClose = vi.fn(); + setupStores(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onClose when Cancel button is clicked', () => { + const onClose = vi.fn(); + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── LiveGroupFilter interaction ──────────────────────────────────────────── + + describe('LiveGroupFilter interaction', () => { + it('updates groupStates when LiveGroupFilter fires onGroupStatesChange', async () => { + setupStores(); + render(); + await waitFor(() => screen.getByTestId('live-group-filter')); + fireEvent.click(screen.getByTestId('live-change-groups')); + expect(screen.getByTestId('live-group-count')).toHaveTextContent('1'); + }); + + it('toggles autoEnableNewGroupsLive when LiveGroupFilter fires onAutoEnableChange', async () => { + setupStores(); + render(); + await waitFor(() => screen.getByTestId('live-group-filter')); + // Toggle twice to verify it actually flips + fireEvent.click(screen.getByTestId('live-toggle-auto')); + fireEvent.click(screen.getByTestId('live-toggle-auto')); + // No crash = state updated correctly + }); + }); + + // ── VODCategoryFilter interaction ────────────────────────────────────────── + + describe('VODCategoryFilter interaction', () => { + it('updates movieCategoryStates when movie VODCategoryFilter fires setCategoryStates', async () => { + setupStores(); + render(); + fireEvent.click(screen.getByTestId('vod-change-movie')); + expect(screen.getByTestId('movie-category-count')).toHaveTextContent('1'); + }); + + it('updates seriesCategoryStates when series VODCategoryFilter fires setCategoryStates', async () => { + setupStores(); + render(); + fireEvent.click(screen.getByTestId('vod-change-series')); + expect(screen.getByTestId('series-category-count')).toHaveTextContent('1'); + }); + + it('toggles autoEnableNewGroupsVod when movie VODCategoryFilter fires setAutoEnableNewGroups', () => { + setupStores(); + render(); + fireEvent.click(screen.getByTestId('vod-toggle-auto-movie')); + fireEvent.click(screen.getByTestId('vod-toggle-auto-movie')); + }); + + it('toggles autoEnableNewGroupsSeries when series VODCategoryFilter fires setAutoEnableNewGroups', () => { + setupStores(); + render(); + fireEvent.click(screen.getByTestId('vod-toggle-auto-series')); + fireEvent.click(screen.getByTestId('vod-toggle-auto-series')); + }); + }); + + // ── Save ─────────────────────────────────────────────────────────────────── + + describe('saving', () => { + it('calls saveAndRefreshPlaylist with playlist and current states on Save click', async () => { + setupStores(); + render(); + await waitFor(() => screen.getByRole('button', { name: /save/i })); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => { + expect(M3uGroupFilterUtils.saveAndRefreshPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ id: 1 }), + expect.any(Array), + expect.any(Array), + expect.any(Array), + expect.objectContaining({ + auto_enable_new_groups_live: true, + auto_enable_new_groups_vod: true, + auto_enable_new_groups_series: true, + }) + ); + }); + }); + + it('calls onClose after successful save', async () => { + const onClose = vi.fn(); + setupStores(); + render(); + await waitFor(() => screen.getByRole('button', { name: /save/i })); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('shows success notification after saving', async () => { + setupStores(); + render(); + await waitFor(() => screen.getByRole('button', { name: /save/i })); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: expect.stringMatching(/green|teal/) }) + ); + }); + }); + + it('does not call onClose when saveAndRefreshPlaylist throws', async () => { + vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockRejectedValue( + new Error('save failed') + ); + const onClose = vi.fn(); + setupStores(); + render(); + await waitFor(() => screen.getByRole('button', { name: /save/i })); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + // ── Loading state ────────────────────────────────────────────────────────── + + describe('loading state', () => { + it('disables Save button while submitting', async () => { + let resolveSave; + vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockImplementation( + () => new Promise((res) => { resolveSave = res; }) + ); + setupStores(); + render(); + await waitFor(() => screen.getByRole('button', { name: /save/i })); + + const saveBtn = screen.getByRole('button', { name: /save/i }); + fireEvent.click(saveBtn); + + await waitFor(() => { + expect( + saveBtn.disabled || saveBtn.dataset.loading === 'true' + ).toBe(true); + }); + + resolveSave(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/__tests__/M3UProfile.test.jsx b/frontend/src/components/forms/__tests__/M3UProfile.test.jsx new file mode 100644 index 00000000..b030d310 --- /dev/null +++ b/frontend/src/components/forms/__tests__/M3UProfile.test.jsx @@ -0,0 +1,574 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import M3UProfile from '../M3UProfile'; + +// ── WebSocket mock ───────────────────────────────────────────────────────────── +vi.mock('../../../WebSocket', () => ({ + useWebSocket: vi.fn(), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/M3uProfileUtils.js', () => ({ + addM3UProfile: vi.fn(), + applyRegex: vi.fn(), + applyXcSimplePatterns: vi.fn(), + buildProfileSchema: vi.fn(), + buildSubmitValues: vi.fn(), + fetchFirstStreamUrl: vi.fn(), + getDetectedMode: vi.fn(), + prepareExpDate: vi.fn(), + updateM3UProfile: vi.fn(), + validateXcSimple: vi.fn(), +})); + +// ── react-hook-form mock ─────────────────────────────────────────────────────── +vi.mock('react-hook-form', async () => { + const actual = await vi.importActual('react-hook-form'); + return { + ...actual, + useForm: vi.fn(), + }; +}); + +// ── @hookform/resolvers/yup mock ─────────────────────────────────────────────── +vi.mock('@hookform/resolvers/yup', () => ({ + yupResolver: vi.fn(() => vi.fn()), +})); + +// ── @mantine/dates mock ──────────────────────────────────────────────────────── +vi.mock('@mantine/dates', () => ({ + DateTimePicker: ({ label, value, onChange, disabled, placeholder }) => ( +
+ + onChange?.(e.target.value)} + disabled={disabled} + placeholder={placeholder} + /> +
+ ), +})); + +// ── @mantine/core mock ───────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Badge: ({ children, color }) => ( + + {children} + + ), + Button: ({ children, onClick, disabled, loading, variant, color, type }) => ( + + ), + Flex: ({ children }) =>
{children}
, + Grid: ({ children }) =>
{children}
, + GridCol: ({ children, span }) => ( +
+ {children} +
+ ), + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + NumberInput: ({ label, value, onChange, disabled, min, max, placeholder }) => ( +
+ + onChange?.(Number(e.target.value))} + disabled={disabled} + min={min} + max={max} + placeholder={placeholder} + /> +
+ ), + Paper: ({ children }) =>
{children}
, + SegmentedControl: ({ value, onChange, data, disabled }) => ( +
+ {data?.map((item) => ( + + ))} +
+ ), + Text: ({ children, size, c, fw }) => ( + + {children} + + ), + Textarea: ({ label, value, onChange, disabled, placeholder, error }) => ( +
+ +