diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js index 0299b240..040354f6 100644 --- a/frontend/src/utils/__tests__/dateTimeUtils.test.js +++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; @@ -10,8 +10,12 @@ import useLocalStorage from '../../hooks/useLocalStorage'; dayjs.extend(utc); dayjs.extend(timezone); -vi.mock('../../store/settings'); -vi.mock('../../hooks/useLocalStorage'); +vi.mock('../../store/settings', () => ({ + default: vi.fn(() => ({})), +})); +vi.mock('../../hooks/useLocalStorage', () => ({ + default: vi.fn(() => ['UTC', vi.fn()]), +})); describe('dateTimeUtils', () => { beforeEach(() => { @@ -484,4 +488,162 @@ describe('dateTimeUtils', () => { Intl.DateTimeFormat = originalDateTimeFormat; }); }); + + describe('setTz', () => { + it('should convert date to specified timezone', () => { + const date = '2024-01-15T15:00:00Z'; + const result = dateTimeUtils.setTz(date, 'America/New_York'); + expect(result.isValid()).toBe(true); + expect(result.utcOffset()).toBe(-300); // EST = UTC-5 + }); + }); + + describe('setMonth', () => { + it('should set the month on a date', () => { + const date = dayjs.utc('2024-01-15T10:00:00Z'); + const result = dateTimeUtils.setMonth(date, 5); + expect(result.month()).toBe(5); + }); + + it('should return a new dayjs object with correct month', () => { + const date = dayjs.utc('2024-01-15T10:00:00Z'); + const result = dateTimeUtils.setMonth(date, 11); + expect(result.month()).toBe(11); + }); + }); + + describe('setYear', () => { + it('should set the year on a date', () => { + const date = dayjs.utc('2024-01-15T10:00:00Z'); + const result = dateTimeUtils.setYear(date, 2030); + expect(result.year()).toBe(2030); + }); + }); + + describe('setDay', () => { + it('should set the day of the month', () => { + const date = dayjs.utc('2024-01-15T10:00:00Z'); + const result = dateTimeUtils.setDay(date, 20); + expect(result.date()).toBe(20); + }); + }); + + describe('setHour', () => { + it('should set the hour on a date', () => { + const date = dayjs.utc('2024-01-15T10:00:00Z'); + const result = dateTimeUtils.setHour(date, 18); + expect(result.hour()).toBe(18); + }); + }); + + describe('setMinute', () => { + it('should set the minute on a date', () => { + const date = dayjs.utc('2024-01-15T10:00:00Z'); + const result = dateTimeUtils.setMinute(date, 45); + expect(result.minute()).toBe(45); + }); + }); + + describe('setSecond', () => { + it('should set the second on a date', () => { + const date = dayjs.utc('2024-01-15T10:00:00Z'); + const result = dateTimeUtils.setSecond(date, 30); + expect(result.second()).toBe(30); + }); + }); + + describe('getMonth', () => { + it('should return the month (0-indexed) from a date', () => { + const date = dayjs.utc('2024-03-15T10:00:00Z'); + expect(dateTimeUtils.getMonth(date)).toBe(2); + }); + + it('should return 0 for January', () => { + const date = dayjs.utc('2024-01-01T12:00:00Z'); + expect(dateTimeUtils.getMonth(date)).toBe(0); + }); + }); + + describe('getYear', () => { + it('should return the year from a date', () => { + const date = dayjs.utc('2024-03-15T10:00:00Z'); + expect(dateTimeUtils.getYear(date)).toBe(2024); + }); + }); + + describe('getDay', () => { + it('should return the day of the month', () => { + const date = dayjs.utc('2024-01-20T12:00:00Z'); + expect(dateTimeUtils.getDay(date)).toBe(20); + }); + }); + + describe('getHour', () => { + it('should return the hour from a UTC date', () => { + const date = dayjs.utc('2024-01-15T14:00:00Z'); + expect(dateTimeUtils.getHour(date)).toBe(14); + }); + }); + + describe('getMinute', () => { + it('should return the minute from a date', () => { + const date = dayjs.utc('2024-01-15T14:35:00Z'); + expect(dateTimeUtils.getMinute(date)).toBe(35); + }); + }); + + describe('getSecond', () => { + it('should return the second from a date', () => { + const date = dayjs.utc('2024-01-15T14:00:45Z'); + expect(dateTimeUtils.getSecond(date)).toBe(45); + }); + }); + + describe('MONTH_NAMES', () => { + it('should have 12 month names', () => { + expect(dateTimeUtils.MONTH_NAMES).toHaveLength(12); + }); + + it('should start with january', () => { + expect(dateTimeUtils.MONTH_NAMES[0]).toBe('january'); + }); + + it('should end with december', () => { + expect(dateTimeUtils.MONTH_NAMES[11]).toBe('december'); + }); + + it('should contain all lowercase month names', () => { + expect(dateTimeUtils.MONTH_NAMES).toEqual([ + 'january', 'february', 'march', 'april', 'may', 'june', + 'july', 'august', 'september', 'october', 'november', 'december', + ]); + }); + }); + + describe('MONTH_ABBR', () => { + it('should have 12 abbreviated month names', () => { + expect(dateTimeUtils.MONTH_ABBR).toHaveLength(12); + }); + + it('should start with jan', () => { + expect(dateTimeUtils.MONTH_ABBR[0]).toBe('jan'); + }); + + it('should end with dec', () => { + expect(dateTimeUtils.MONTH_ABBR[11]).toBe('dec'); + }); + + it('should contain all lowercase abbreviated month names', () => { + expect(dateTimeUtils.MONTH_ABBR).toEqual([ + 'jan', 'feb', 'mar', 'apr', 'may', 'jun', + 'jul', 'aug', 'sep', 'oct', 'nov', 'dec', + ]); + }); + + it('should align with MONTH_NAMES by index', () => { + dateTimeUtils.MONTH_NAMES.forEach((name, i) => { + expect(name.startsWith(dateTimeUtils.MONTH_ABBR[i])).toBe(true); + }); + }); + }); }); diff --git a/frontend/src/utils/forms/__tests__/AccountInfoModalUtils.test.js b/frontend/src/utils/forms/__tests__/AccountInfoModalUtils.test.js new file mode 100644 index 00000000..770184cf --- /dev/null +++ b/frontend/src/utils/forms/__tests__/AccountInfoModalUtils.test.js @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ── API mock ─────────────────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ default: { refreshAccountInfo: vi.fn() } })); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import API from '../../../api.js'; +import { + formatTimestamp, + getTimeRemaining, + refreshAccountInfo, +} from '../AccountInfoModalUtils.js'; + +// ────────────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────────────── +describe('AccountInfoModalUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── formatTimestamp ──────────────────────────────────────────────────────── + + describe('formatTimestamp', () => { + it('returns "Unknown" for null', () => { + expect(formatTimestamp(null)).toBe('Unknown'); + }); + + it('returns "Unknown" for undefined', () => { + expect(formatTimestamp(undefined)).toBe('Unknown'); + }); + + it('returns "Unknown" for empty string', () => { + expect(formatTimestamp('')).toBe('Unknown'); + }); + + it('returns "Unknown" for 0', () => { + expect(formatTimestamp(0)).toBe('Unknown'); + }); + + it('formats a Unix timestamp string (no T) using parseInt * 1000', () => { + const result = formatTimestamp('1700000000'); + expect(result).toBeTypeOf('string'); + expect(result).not.toBe('Unknown'); + expect(result).not.toBe('Invalid date'); + }); + + it('formats an ISO string (contains T) directly', () => { + const result = formatTimestamp('2023-11-14T22:13:20.000Z'); + expect(result).toBeTypeOf('string'); + expect(result).not.toBe('Unknown'); + expect(result).not.toBe('Invalid date'); + }); + + it('returns a string containing the year for a valid Unix timestamp', () => { + // 1700000000 => Nov 14, 2023 + const result = formatTimestamp('1700000000'); + expect(result).toContain('2023'); + }); + + it('returns a string containing the year for a valid ISO timestamp', () => { + const result = formatTimestamp('2023-11-14T22:13:20.000Z'); + expect(result).toContain('2023'); + }); + + it('returns "Invalid date" when Date constructor throws', () => { + const spy = vi.spyOn(global, 'Date').mockImplementationOnce(() => { + throw new Error('bad date'); + }); + const result = formatTimestamp('1700000000'); + expect(result).toBe('Invalid date'); + spy.mockRestore(); + }); + + it('uses parseInt for numeric strings without T', () => { + const spy = vi.spyOn(global, 'Date'); + formatTimestamp('1700000000'); + // Called with 1700000000 * 1000 + expect(spy).toHaveBeenCalledWith(1700000000 * 1000); + spy.mockRestore(); + }); + + it('passes ISO string directly to Date constructor', () => { + const spy = vi.spyOn(global, 'Date'); + const iso = '2023-11-14T22:13:20.000Z'; + formatTimestamp(iso); + expect(spy).toHaveBeenCalledWith(iso); + spy.mockRestore(); + }); + }); + + // ── getTimeRemaining ─────────────────────────────────────────────────────── + + describe('getTimeRemaining', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns null for null', () => { + expect(getTimeRemaining(null)).toBeNull(); + }); + + it('returns null for undefined', () => { + expect(getTimeRemaining(undefined)).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(getTimeRemaining('')).toBeNull(); + }); + + it('returns null for 0', () => { + expect(getTimeRemaining(0)).toBeNull(); + }); + + it('returns "Expired" when expiry is in the past', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-10T00:00:00Z')); + // Jan 1, 2024 Unix timestamp + const past = String(Math.floor(new Date('2024-01-01T00:00:00Z').getTime() / 1000)); + expect(getTimeRemaining(past)).toBe('Expired'); + }); + + it('returns "Expired" when diffMs is exactly 0', () => { + vi.useFakeTimers(); + const now = new Date('2024-06-01T12:00:00Z'); + vi.setSystemTime(now); + const expTimestamp = String(Math.floor(now.getTime() / 1000)); + expect(getTimeRemaining(expTimestamp)).toBe('Expired'); + }); + + it('returns only hours when less than 1 day remains', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-01T00:00:00Z')); + // 5 hours from now + const exp = Math.floor(new Date('2024-06-01T05:00:00Z').getTime() / 1000); + expect(getTimeRemaining(String(exp))).toBe('5 hours'); + }); + + it('uses singular "hour" when exactly 1 hour remains', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-01T00:00:00Z')); + const exp = Math.floor(new Date('2024-06-01T01:00:00Z').getTime() / 1000); + expect(getTimeRemaining(String(exp))).toBe('1 hour'); + }); + + it('returns days and hours when more than 1 day remains', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-01T00:00:00Z')); + // 2 days + 3 hours + const exp = Math.floor(new Date('2024-06-03T03:00:00Z').getTime() / 1000); + expect(getTimeRemaining(String(exp))).toBe('2 days 3 hours'); + }); + + it('uses singular "day" when exactly 1 day and some hours remain', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-01T00:00:00Z')); + // 1 day + 4 hours + const exp = Math.floor(new Date('2024-06-02T04:00:00Z').getTime() / 1000); + expect(getTimeRemaining(String(exp))).toBe('1 day 4 hours'); + }); + + it('uses singular "day" and singular "hour" when 1 day 1 hour remains', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-01T00:00:00Z')); + const exp = Math.floor(new Date('2024-06-02T01:00:00Z').getTime() / 1000); + expect(getTimeRemaining(String(exp))).toBe('1 day 1 hour'); + }); + + it('shows 0 hours when exactly N full days remain', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-01T00:00:00Z')); + // Exactly 3 days, 0 hours + const exp = Math.floor(new Date('2024-06-04T00:00:00Z').getTime() / 1000); + expect(getTimeRemaining(String(exp))).toBe('3 days 0 hours'); + }); + + it('returns "Unknown" when an exception is thrown', () => { + const spy = vi.spyOn(global, 'Date').mockImplementationOnce(() => { + throw new Error('bad'); + }); + expect(getTimeRemaining('1700000000')).toBe('Unknown'); + spy.mockRestore(); + }); + + it('accepts a numeric (non-string) timestamp', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-01T00:00:00Z')); + const exp = Math.floor(new Date('2024-06-01T02:00:00Z').getTime() / 1000); + expect(getTimeRemaining(exp)).toBe('2 hours'); + }); + }); + + // ── refreshAccountInfo ───────────────────────────────────────────────────── + + describe('refreshAccountInfo', () => { + it('calls API.refreshAccountInfo with the profile id', async () => { + vi.mocked(API.refreshAccountInfo).mockResolvedValue({ success: true }); + const profile = { id: 'profile-123' }; + await refreshAccountInfo(profile); + expect(API.refreshAccountInfo).toHaveBeenCalledWith('profile-123'); + }); + + it('returns the result from API.refreshAccountInfo', async () => { + vi.mocked(API.refreshAccountInfo).mockResolvedValue({ success: true }); + const result = await refreshAccountInfo({ id: 'profile-123' }); + expect(result).toEqual({ success: true }); + }); + + it('propagates rejection from API.refreshAccountInfo', async () => { + const error = new Error('network error'); + vi.mocked(API.refreshAccountInfo).mockRejectedValue(error); + await expect(refreshAccountInfo({ id: 'profile-123' })).rejects.toThrow('network error'); + }); + + it('calls API.refreshAccountInfo exactly once', async () => { + vi.mocked(API.refreshAccountInfo).mockResolvedValue({ success: true }); + await refreshAccountInfo({ id: 'abc' }); + expect(API.refreshAccountInfo).toHaveBeenCalledTimes(1); + }); + + it('throws when profile has no id', async () => { + vi.mocked(API.refreshAccountInfo).mockResolvedValue({ success: true }); + // API receives undefined; test that the call is made with undefined + await refreshAccountInfo({ id: undefined }); + expect(API.refreshAccountInfo).toHaveBeenCalledWith(undefined); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/utils/forms/__tests__/ChannelBatchUtils.test.js b/frontend/src/utils/forms/__tests__/ChannelBatchUtils.test.js new file mode 100644 index 00000000..96736358 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/ChannelBatchUtils.test.js @@ -0,0 +1,554 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + getChannelGroupChange, + getLogoChange, + getStreamProfileChange, + getUserLevelChange, + getMatureContentChange, + getRegexNameChange, + getEpgChange, + updateChannels, + bulkRegexRenameChannels, + batchSetEPG, + getEpgData, + setChannelNamesFromEpg, + setChannelLogosFromEpg, + setChannelTvgIdsFromEpg, + computeRegexPreview, + buildSubmitValues, + buildEpgAssociations, +} from '../ChannelBatchUtils.js'; + +// ── API mock ─────────────────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + updateChannels: vi.fn(), + bulkRegexRenameChannels: vi.fn(), + batchSetEPG: vi.fn(), + getEPGData: vi.fn(), + setChannelNamesFromEpg: vi.fn(), + setChannelLogosFromEpg: vi.fn(), + setChannelTvgIdsFromEpg: vi.fn(), + }, +})); + +import API from '../../../api.js'; + +describe('ChannelBatchUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + +// ── getChannelGroupChange ────────────────────────────────────────────────────── + + describe('getChannelGroupChange', () => { + const channelGroups = { + 1: { name: 'Sports' }, + 2: { name: 'News' }, + }; + + it('returns null when selectedChannelGroup is falsy', () => { + expect(getChannelGroupChange(null, channelGroups)).toBeNull(); + expect(getChannelGroupChange(undefined, channelGroups)).toBeNull(); + expect(getChannelGroupChange('', channelGroups)).toBeNull(); + }); + + it('returns null when selectedChannelGroup is "-1"', () => { + expect(getChannelGroupChange('-1', channelGroups)).toBeNull(); + }); + + it('returns the group name when a valid group is selected', () => { + expect(getChannelGroupChange('1', channelGroups)).toBe('• Channel Group: Sports'); + }); + + it('returns "Unknown" when group id is not found in channelGroups', () => { + expect(getChannelGroupChange('99', channelGroups)).toBe('• Channel Group: Unknown'); + }); + }); + +// ── getLogoChange ───────────────────────────────────────────────────────────── + + describe('getLogoChange', () => { + const channelLogos = { + 5: { name: 'HBO Logo' }, + 6: { name: 'ESPN Logo' }, + }; + + it('returns null when selectedLogoId is falsy', () => { + expect(getLogoChange(null, channelLogos)).toBeNull(); + expect(getLogoChange(undefined, channelLogos)).toBeNull(); + expect(getLogoChange('', channelLogos)).toBeNull(); + }); + + it('returns null when selectedLogoId is "-1"', () => { + expect(getLogoChange('-1', channelLogos)).toBeNull(); + }); + + it('returns "Use Default" message when selectedLogoId is "0"', () => { + expect(getLogoChange('0', channelLogos)).toBe('• Logo: Use Default'); + }); + + it('returns the logo name when a valid logo is selected', () => { + expect(getLogoChange('5', channelLogos)).toBe('• Logo: HBO Logo'); + }); + + it('returns "Selected Logo" fallback when logo id is not found', () => { + expect(getLogoChange('99', channelLogos)).toBe('• Logo: Selected Logo'); + }); + }); + +// ── getStreamProfileChange ──────────────────────────────────────────────────── + + describe('getStreamProfileChange', () => { + const streamProfiles = [ + { id: 1, name: 'HD Profile' }, + { id: 2, name: 'SD Profile' }, + ]; + + it('returns null when streamProfileId is falsy', () => { + expect(getStreamProfileChange(null, streamProfiles)).toBeNull(); + expect(getStreamProfileChange(undefined, streamProfiles)).toBeNull(); + expect(getStreamProfileChange('', streamProfiles)).toBeNull(); + }); + + it('returns null when streamProfileId is "-1"', () => { + expect(getStreamProfileChange('-1', streamProfiles)).toBeNull(); + }); + + it('returns "Use Default" message when streamProfileId is "0"', () => { + expect(getStreamProfileChange('0', streamProfiles)).toBe('• Stream Profile: Use Default'); + }); + + it('returns the profile name when a valid profile is selected', () => { + expect(getStreamProfileChange('1', streamProfiles)).toBe('• Stream Profile: HD Profile'); + }); + + it('matches profile id using string coercion', () => { + expect(getStreamProfileChange(2, streamProfiles)).toBe('• Stream Profile: SD Profile'); + }); + + it('returns "Selected Profile" fallback when profile is not found', () => { + expect(getStreamProfileChange('99', streamProfiles)).toBe('• Stream Profile: Selected Profile'); + }); + }); + +// ── getUserLevelChange ──────────────────────────────────────────────────────── + + describe('getUserLevelChange', () => { + const userLevelLabels = { + 1: 'Admin', + 2: 'Viewer', + }; + + it('returns null when userLevel is falsy', () => { + expect(getUserLevelChange(null, userLevelLabels)).toBeNull(); + expect(getUserLevelChange(undefined, userLevelLabels)).toBeNull(); + expect(getUserLevelChange('', userLevelLabels)).toBeNull(); + }); + + it('returns null when userLevel is "-1"', () => { + expect(getUserLevelChange('-1', userLevelLabels)).toBeNull(); + }); + + it('returns the label when a valid user level is selected', () => { + expect(getUserLevelChange('1', userLevelLabels)).toBe('• User Level: Admin'); + }); + + it('returns the raw userLevel value when label is not found', () => { + expect(getUserLevelChange('99', userLevelLabels)).toBe('• User Level: 99'); + }); + }); + +// ── getMatureContentChange ──────────────────────────────────────────────────── + + describe('getMatureContentChange', () => { + it('returns null when isAdult is falsy', () => { + expect(getMatureContentChange(null)).toBeNull(); + expect(getMatureContentChange(undefined)).toBeNull(); + expect(getMatureContentChange('')).toBeNull(); + }); + + it('returns null when isAdult is "-1"', () => { + expect(getMatureContentChange('-1')).toBeNull(); + }); + + it('returns "Yes" when isAdult is "true"', () => { + expect(getMatureContentChange('true')).toBe('• Mature Content: Yes'); + }); + + it('returns "No" when isAdult is "false"', () => { + expect(getMatureContentChange('false')).toBe('• Mature Content: No'); + }); + }); + +// ── getRegexNameChange ──────────────────────────────────────────────────────── + + describe('getRegexNameChange', () => { + it('returns null when regexFind is falsy', () => { + expect(getRegexNameChange(null, 'replace')).toBeNull(); + expect(getRegexNameChange(undefined, 'replace')).toBeNull(); + expect(getRegexNameChange('', 'replace')).toBeNull(); + }); + + it('returns null when regexFind is only whitespace', () => { + expect(getRegexNameChange(' ', 'replace')).toBeNull(); + }); + + it('returns the regex change description with find and replace', () => { + expect(getRegexNameChange('foo', 'bar')).toBe( + '• Name Change: Apply regex find "foo" replace with "bar"' + ); + }); + + it('uses empty string for replace when regexReplace is falsy', () => { + expect(getRegexNameChange('foo', null)).toBe( + '• Name Change: Apply regex find "foo" replace with ""' + ); + expect(getRegexNameChange('foo', undefined)).toBe( + '• Name Change: Apply regex find "foo" replace with ""' + ); + }); + }); + +// ── getEpgChange ────────────────────────────────────────────────────────────── + + describe('getEpgChange', () => { + const epgs = { + 10: { name: 'XMLTV Source' }, + 11: { name: 'Another EPG' }, + }; + + it('returns null when selectedDummyEpgId is falsy', () => { + expect(getEpgChange(null, epgs)).toBeNull(); + expect(getEpgChange(undefined, epgs)).toBeNull(); + expect(getEpgChange('', epgs)).toBeNull(); + }); + + it('returns clear assignment message when selectedDummyEpgId is "clear"', () => { + expect(getEpgChange('clear', epgs)).toBe('• EPG: Clear Assignment (use default dummy)'); + }); + + it('returns the EPG name when a valid EPG is selected', () => { + expect(getEpgChange('10', epgs)).toBe('• Dummy EPG: XMLTV Source'); + }); + + it('returns "Selected EPG" fallback when EPG id is not found', () => { + expect(getEpgChange('99', epgs)).toBe('• Dummy EPG: Selected EPG'); + }); + }); + +// ── API wrappers ────────────────────────────────────────────────────────────── + + describe('updateChannels', () => { + it('calls API.updateChannels with channelIds and values', () => { + const channelIds = [1, 2, 3]; + const values = { user_level: 1 }; + updateChannels(channelIds, values); + expect(API.updateChannels).toHaveBeenCalledWith(channelIds, values); + }); + + it('returns the API promise', () => { + const mockPromise = Promise.resolve({ success: true }); + vi.mocked(API.updateChannels).mockReturnValue(mockPromise); + expect(updateChannels([1], {})).toBe(mockPromise); + }); + }); + + describe('bulkRegexRenameChannels', () => { + it('calls API.bulkRegexRenameChannels with all arguments', () => { + bulkRegexRenameChannels([1, 2], 'find', 'replace', 'g'); + expect(API.bulkRegexRenameChannels).toHaveBeenCalledWith([1, 2], 'find', 'replace', 'g'); + }); + + it('passes empty string when regexReplace is null', () => { + bulkRegexRenameChannels([1], 'find', null, 'g'); + expect(API.bulkRegexRenameChannels).toHaveBeenCalledWith([1], 'find', '', 'g'); + }); + + it('passes empty string when regexReplace is undefined', () => { + bulkRegexRenameChannels([1], 'find', undefined, 'g'); + expect(API.bulkRegexRenameChannels).toHaveBeenCalledWith([1], 'find', '', 'g'); + }); + }); + + describe('batchSetEPG', () => { + it('calls API.batchSetEPG with associations', () => { + const associations = [{ channel_id: 1, epg_data_id: 5 }]; + batchSetEPG(associations); + expect(API.batchSetEPG).toHaveBeenCalledWith(associations); + }); + }); + + describe('getEpgData', () => { + it('calls API.getEPGData', () => { + getEpgData(); + expect(API.getEPGData).toHaveBeenCalled(); + }); + }); + + describe('setChannelNamesFromEpg', () => { + it('calls API.setChannelNamesFromEpg with channelIds', () => { + setChannelNamesFromEpg([1, 2]); + expect(API.setChannelNamesFromEpg).toHaveBeenCalledWith([1, 2]); + }); + }); + + describe('setChannelLogosFromEpg', () => { + it('calls API.setChannelLogosFromEpg with channelIds', () => { + setChannelLogosFromEpg([1, 2]); + expect(API.setChannelLogosFromEpg).toHaveBeenCalledWith([1, 2]); + }); + }); + + describe('setChannelTvgIdsFromEpg', () => { + it('calls API.setChannelTvgIdsFromEpg with channelIds', () => { + setChannelTvgIdsFromEpg([1, 2]); + expect(API.setChannelTvgIdsFromEpg).toHaveBeenCalledWith([1, 2]); + }); + }); + +// ── computeRegexPreview ─────────────────────────────────────────────────────── + + describe('computeRegexPreview', () => { + const nameById = { + 1: 'HBO East', + 2: 'HBO West', + 3: 'ESPN HD', + 4: 'CNN', + }; + + it('returns empty array when find is falsy', () => { + expect(computeRegexPreview([1, 2], nameById, '')).toEqual([]); + expect(computeRegexPreview([1, 2], nameById, null)).toEqual([]); + expect(computeRegexPreview([1, 2], nameById, undefined)).toEqual([]); + }); + + it('returns invalid regex entry for a bad pattern', () => { + const result = computeRegexPreview([1], nameById, '[invalid'); + expect(result).toEqual([{ before: 'Invalid regex', after: '' }]); + }); + + it('returns before/after pairs for matching channels', () => { + const result = computeRegexPreview([1, 2], nameById, 'HBO', 'Cinemax'); + expect(result).toEqual([ + { before: 'HBO East', after: 'Cinemax East' }, + { before: 'HBO West', after: 'Cinemax West' }, + ]); + }); + + it('excludes channels where name does not change', () => { + const result = computeRegexPreview([1, 2, 4], nameById, 'HBO', 'Cinemax'); + expect(result).toHaveLength(2); + expect(result.find((r) => r.before === 'CNN')).toBeUndefined(); + }); + + it('only includes ids that exist in nameById', () => { + const result = computeRegexPreview([1, 99, 2], nameById, 'HBO', 'Cinemax'); + expect(result).toHaveLength(2); + }); + + it('respects the limit parameter', () => { + const largeNameById = Object.fromEntries( + Array.from({ length: 30 }, (_, i) => [i + 1, `HBO ${i + 1}`]) + ); + const ids = Array.from({ length: 30 }, (_, i) => i + 1); + const result = computeRegexPreview(ids, largeNameById, 'HBO', 'Cinemax', 5); + expect(result).toHaveLength(5); + }); + + it('defaults to a limit of 25', () => { + const largeNameById = Object.fromEntries( + Array.from({ length: 30 }, (_, i) => [i + 1, `HBO ${i + 1}`]) + ); + const ids = Array.from({ length: 30 }, (_, i) => i + 1); + const result = computeRegexPreview(ids, largeNameById, 'HBO', 'Cinemax'); + expect(result).toHaveLength(25); + }); + + it('uses empty string for replace when replace is null', () => { + const result = computeRegexPreview([1], nameById, 'HBO East', null); + expect(result).toEqual([{ before: 'HBO East', after: '' }]); + }); + + it('applies regex globally across the channel name', () => { + const names = { 1: 'aabbaa' }; + const result = computeRegexPreview([1], names, 'a', 'x'); + expect(result).toEqual([{ before: 'aabbaa', after: 'xxbbxx' }]); + }); + }); + +// ── buildSubmitValues ───────────────────────────────────────────────────────── + + describe('buildSubmitValues', () => { + const baseFormValues = { + stream_profile_id: '-1', + user_level: '-1', + is_adult: '-1', + logo: 'some-logo.png', + channel_group: 'some-group', + }; + + it('removes channel_group and logo keys from the result', () => { + const result = buildSubmitValues(baseFormValues, null, null); + expect(result).not.toHaveProperty('channel_group'); + expect(result).not.toHaveProperty('logo'); + }); + + it('removes stream_profile_id when it is "-1"', () => { + const result = buildSubmitValues({ ...baseFormValues, stream_profile_id: '-1' }, null, null); + expect(result).not.toHaveProperty('stream_profile_id'); + }); + + it('removes stream_profile_id when it is falsy', () => { + const result = buildSubmitValues({ ...baseFormValues, stream_profile_id: '' }, null, null); + expect(result).not.toHaveProperty('stream_profile_id'); + }); + + it('converts stream_profile_id "0" to null (use default)', () => { + const result = buildSubmitValues({ ...baseFormValues, stream_profile_id: '0' }, null, null); + expect(result.stream_profile_id).toBeNull(); + }); + + it('preserves a valid stream_profile_id string', () => { + const result = buildSubmitValues({ ...baseFormValues, stream_profile_id: '3' }, null, null); + expect(result.stream_profile_id).toBe('3'); + }); + + it('removes user_level when it is "-1"', () => { + const result = buildSubmitValues({ ...baseFormValues, user_level: '-1' }, null, null); + expect(result).not.toHaveProperty('user_level'); + }); + + it('preserves a valid user_level', () => { + const result = buildSubmitValues({ ...baseFormValues, user_level: '2' }, null, null); + expect(result.user_level).toBe('2'); + }); + + it('removes is_adult when it is "-1"', () => { + const result = buildSubmitValues({ ...baseFormValues, is_adult: '-1' }, null, null); + expect(result).not.toHaveProperty('is_adult'); + }); + + it('converts is_adult "true" to boolean true', () => { + const result = buildSubmitValues({ ...baseFormValues, is_adult: 'true' }, null, null); + expect(result.is_adult).toBe(true); + }); + + it('converts is_adult "false" to boolean false', () => { + const result = buildSubmitValues({ ...baseFormValues, is_adult: 'false' }, null, null); + expect(result.is_adult).toBe(false); + }); + + it('sets channel_group_id as integer when selectedChannelGroup is valid', () => { + const result = buildSubmitValues(baseFormValues, '5', null); + expect(result.channel_group_id).toBe(5); + }); + + it('removes channel_group_id when selectedChannelGroup is null', () => { + const result = buildSubmitValues({ ...baseFormValues, channel_group_id: 99 }, null, null); + expect(result).not.toHaveProperty('channel_group_id'); + }); + + it('removes channel_group_id when selectedChannelGroup is "-1"', () => { + const result = buildSubmitValues({ ...baseFormValues, channel_group_id: 99 }, '-1', null); + expect(result).not.toHaveProperty('channel_group_id'); + }); + + it('sets logo_id to null when selectedLogoId is "0" (use default)', () => { + const result = buildSubmitValues(baseFormValues, null, '0'); + expect(result.logo_id).toBeNull(); + }); + + it('sets logo_id as integer when selectedLogoId is a valid id', () => { + const result = buildSubmitValues(baseFormValues, null, '7'); + expect(result.logo_id).toBe(7); + }); + + it('does not set logo_id when selectedLogoId is null', () => { + const result = buildSubmitValues(baseFormValues, null, null); + expect(result).not.toHaveProperty('logo_id'); + }); + + it('does not set logo_id when selectedLogoId is "-1"', () => { + const result = buildSubmitValues(baseFormValues, null, '-1'); + expect(result).not.toHaveProperty('logo_id'); + }); + + it('does not mutate the original formValues object', () => { + const formValues = { ...baseFormValues }; + buildSubmitValues(formValues, '1', '2'); + expect(formValues).toEqual(baseFormValues); + }); + }); + +// ── buildEpgAssociations ────────────────────────────────────────────────────── + + describe('buildEpgAssociations', () => { + const channelIds = [1, 2, 3]; + + const epgs = { + 10: { name: 'XMLTV Source', epg_data_count: 5 }, + 11: { name: 'Empty EPG', epg_data_count: 0 }, + }; + + const tvgs = [ + { id: 42, epg_source: 10 }, + ]; + + it('returns null when selectedDummyEpgId is falsy', async () => { + await expect(buildEpgAssociations(null, channelIds, epgs, tvgs)).resolves.toBeNull(); + await expect(buildEpgAssociations('', channelIds, epgs, tvgs)).resolves.toBeNull(); + await expect(buildEpgAssociations(undefined, channelIds, epgs, tvgs)).resolves.toBeNull(); + }); + + it('returns clear associations when selectedDummyEpgId is "clear"', async () => { + const result = await buildEpgAssociations('clear', channelIds, epgs, tvgs); + expect(result).toEqual([ + { channel_id: 1, epg_data_id: null }, + { channel_id: 2, epg_data_id: null }, + { channel_id: 3, epg_data_id: null }, + ]); + }); + + it('returns null when the selected EPG has no epg_data_count', async () => { + const result = await buildEpgAssociations('11', channelIds, epgs, tvgs); + expect(result).toBeNull(); + }); + + it('returns null when the selected EPG id is not found in epgs', async () => { + const result = await buildEpgAssociations('99', channelIds, epgs, tvgs); + expect(result).toBeNull(); + }); + + it('uses cached tvgs data when epg_source matches', async () => { + const result = await buildEpgAssociations('10', channelIds, epgs, tvgs); + expect(API.getEPGData).not.toHaveBeenCalled(); + expect(result).toEqual([ + { channel_id: 1, epg_data_id: 42 }, + { channel_id: 2, epg_data_id: 42 }, + { channel_id: 3, epg_data_id: 42 }, + ]); + }); + + it('calls getEpgData when tvgs does not contain a matching epg_source', async () => { + vi.mocked(API.getEPGData).mockResolvedValue([ + { id: 55, epg_source: 10 }, + ]); + const result = await buildEpgAssociations('10', channelIds, epgs, []); + expect(API.getEPGData).toHaveBeenCalled(); + expect(result).toEqual([ + { channel_id: 1, epg_data_id: 55 }, + { channel_id: 2, epg_data_id: 55 }, + { channel_id: 3, epg_data_id: 55 }, + ]); + }); + + it('returns null when getEpgData result has no matching epg_source', async () => { + vi.mocked(API.getEPGData).mockResolvedValue([ + { id: 55, epg_source: 999 }, + ]); + const result = await buildEpgAssociations('10', channelIds, epgs, []); + expect(result).toBeNull(); + }); + }); +}); diff --git a/frontend/src/utils/forms/__tests__/ChannelUtils.test.js b/frontend/src/utils/forms/__tests__/ChannelUtils.test.js new file mode 100644 index 00000000..e18970c1 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/ChannelUtils.test.js @@ -0,0 +1,394 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import API from '../../../api.js'; +import { + matchChannelEpg, + createLogo, + addChannel, + requeryChannels, + getChannelFormDefaultValues, + getFormattedValues, + handleEpgUpdate, +} from '../ChannelUtils.js'; + +// ── API mock ─────────────────────────────────────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + matchChannelEpg: vi.fn(), + createLogo: vi.fn(), + setChannelEPG: vi.fn(), + updateChannel: vi.fn(), + addChannel: vi.fn(), + requeryChannels: vi.fn(), + }, +})); + +// ── Fixtures ─────────────────────────────────────────────────────────────────── +const makeChannel = (overrides = {}) => ({ + id: 'ch-1', + name: 'HBO', + channel_number: 501, + channel_group_id: 2, + stream_profile_id: 3, + tvg_id: 'hbo.us', + tvc_guide_stationid: 'hbo-station', + epg_data_id: 'epg-1', + logo_id: 10, + user_level: 1, + is_adult: false, + ...overrides, +}); + +const makeChannelGroups = () => ({ + 1: { id: 1, name: 'Group A' }, + 2: { id: 2, name: 'Group B' }, +}); + +const makeChannelStreams = () => [{ id: 's1' }, { id: 's2' }]; + +// ────────────────────────────────────────────────────────────────────────────── + +describe('ChannelUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── matchChannelEpg ────────────────────────────────────────────────────────── + + describe('matchChannelEpg', () => { + it('calls API.matchChannelEpg with the channel id', () => { + const channel = makeChannel(); + API.matchChannelEpg.mockResolvedValue({ matched: true }); + matchChannelEpg(channel); + expect(API.matchChannelEpg).toHaveBeenCalledWith('ch-1'); + }); + + it('returns the API response', async () => { + API.matchChannelEpg.mockResolvedValue({ matched: true }); + const result = await matchChannelEpg(makeChannel()); + expect(result).toEqual({ matched: true }); + }); + }); + + // ── createLogo ─────────────────────────────────────────────────────────────── + + describe('createLogo', () => { + it('calls API.createLogo with the provided logo data', () => { + const logoData = { name: 'My Logo', url: '/logo.png' }; + API.createLogo.mockResolvedValue({ id: 99 }); + createLogo(logoData); + expect(API.createLogo).toHaveBeenCalledWith(logoData); + }); + + it('returns the API response', async () => { + API.createLogo.mockResolvedValue({ id: 99 }); + const result = await createLogo({ name: 'Logo' }); + expect(result).toEqual({ id: 99 }); + }); + }); + + // ── addChannel ─────────────────────────────────────────────────────────────── + + describe('addChannel', () => { + it('calls API.addChannel with the channel object', () => { + const channel = makeChannel(); + API.addChannel.mockResolvedValue({ id: 'ch-new' }); + addChannel(channel); + expect(API.addChannel).toHaveBeenCalledWith(channel); + }); + + it('returns the API response', async () => { + API.addChannel.mockResolvedValue({ id: 'ch-new' }); + const result = await addChannel(makeChannel()); + expect(result).toEqual({ id: 'ch-new' }); + }); + }); + + // ── requeryChannels ────────────────────────────────────────────────────────── + + describe('requeryChannels', () => { + it('calls API.requeryChannels', () => { + requeryChannels(); + expect(API.requeryChannels).toHaveBeenCalledTimes(1); + }); + + it('returns undefined', () => { + const result = requeryChannels(); + expect(result).toBeUndefined(); + }); + }); + + // ── getChannelFormDefaultValues ────────────────────────────────────────────── + + describe('getChannelFormDefaultValues', () => { + it('returns all channel field values as strings where required', () => { + const channel = makeChannel(); + const result = getChannelFormDefaultValues(channel, makeChannelGroups()); + expect(result).toEqual({ + name: 'HBO', + channel_number: 501, + channel_group_id: '2', + stream_profile_id: '3', + tvg_id: 'hbo.us', + tvc_guide_stationid: 'hbo-station', + epg_data_id: 'epg-1', + logo_id: '10', + user_level: '1', + is_adult: false, + }); + }); + + it('falls back to first channelGroup key when channel has no channel_group_id', () => { + const channel = makeChannel({ channel_group_id: null }); + const result = getChannelFormDefaultValues(channel, makeChannelGroups()); + expect(result.channel_group_id).toBe('1'); + }); + + it('returns empty string for channel_group_id when channelGroups is empty and channel has no group', () => { + const channel = makeChannel({ channel_group_id: null }); + const result = getChannelFormDefaultValues(channel, {}); + expect(result.channel_group_id).toBe(''); + }); + + it('defaults stream_profile_id to "0" when not set on channel', () => { + const channel = makeChannel({ stream_profile_id: null }); + const result = getChannelFormDefaultValues(channel, makeChannelGroups()); + expect(result.stream_profile_id).toBe('0'); + }); + + it('defaults name to empty string when channel is null', () => { + const result = getChannelFormDefaultValues(null, makeChannelGroups()); + expect(result.name).toBe(''); + }); + + it('defaults channel_number to empty string when channel is null', () => { + const result = getChannelFormDefaultValues(null, makeChannelGroups()); + expect(result.channel_number).toBe(''); + }); + + it('defaults channel_number to empty string when channel_number is null', () => { + const channel = makeChannel({ channel_number: null }); + const result = getChannelFormDefaultValues(channel, makeChannelGroups()); + expect(result.channel_number).toBe(''); + }); + + it('defaults channel_number to empty string when channel_number is undefined', () => { + const channel = makeChannel({ channel_number: undefined }); + const result = getChannelFormDefaultValues(channel, makeChannelGroups()); + expect(result.channel_number).toBe(''); + }); + + it('preserves channel_number of 0 as 0', () => { + const channel = makeChannel({ channel_number: 0 }); + const result = getChannelFormDefaultValues(channel, makeChannelGroups()); + expect(result.channel_number).toBe(0); + }); + + it('defaults tvg_id to empty string when not set', () => { + const channel = makeChannel({ tvg_id: null }); + const result = getChannelFormDefaultValues(channel, makeChannelGroups()); + expect(result.tvg_id).toBe(''); + }); + + it('defaults tvc_guide_stationid to empty string when not set', () => { + const channel = makeChannel({ tvc_guide_stationid: null }); + const result = getChannelFormDefaultValues(channel, makeChannelGroups()); + expect(result.tvc_guide_stationid).toBe(''); + }); + + it('defaults epg_data_id to empty string when channel is null', () => { + const result = getChannelFormDefaultValues(null, makeChannelGroups()); + expect(result.epg_data_id).toBe(''); + }); + + it('defaults logo_id to empty string when not set', () => { + const channel = makeChannel({ logo_id: null }); + const result = getChannelFormDefaultValues(channel, makeChannelGroups()); + expect(result.logo_id).toBe(''); + }); + + it('defaults user_level to "0" when channel is null', () => { + const result = getChannelFormDefaultValues(null, makeChannelGroups()); + expect(result.user_level).toBe('0'); + }); + + it('defaults is_adult to false when channel is null', () => { + const result = getChannelFormDefaultValues(null, makeChannelGroups()); + expect(result.is_adult).toBe(false); + }); + + it('returns all defaults when channel is null', () => { + const groups = makeChannelGroups(); + const result = getChannelFormDefaultValues(null, groups); + expect(result).toEqual({ + name: '', + channel_number: '', + channel_group_id: '1', + stream_profile_id: '0', + tvg_id: '', + tvc_guide_stationid: '', + epg_data_id: '', + logo_id: '', + user_level: '0', + is_adult: false, + }); + }); + }); + + // ── getFormattedValues ─────────────────────────────────────────────────────── + + describe('getFormattedValues', () => { + it('converts "0" stream_profile_id to null', () => { + const result = getFormattedValues({ stream_profile_id: '0', tvg_id: 'x', tvc_guide_stationid: 'y' }); + expect(result.stream_profile_id).toBeNull(); + }); + + it('converts empty string stream_profile_id to null', () => { + const result = getFormattedValues({ stream_profile_id: '', tvg_id: 'x', tvc_guide_stationid: 'y' }); + expect(result.stream_profile_id).toBeNull(); + }); + + it('preserves non-zero stream_profile_id', () => { + const result = getFormattedValues({ stream_profile_id: '5', tvg_id: 'x', tvc_guide_stationid: 'y' }); + expect(result.stream_profile_id).toBe('5'); + }); + + it('converts empty tvg_id to null', () => { + const result = getFormattedValues({ stream_profile_id: '1', tvg_id: '', tvc_guide_stationid: 'y' }); + expect(result.tvg_id).toBeNull(); + }); + + it('preserves non-empty tvg_id', () => { + const result = getFormattedValues({ stream_profile_id: '1', tvg_id: 'hbo.us', tvc_guide_stationid: 'y' }); + expect(result.tvg_id).toBe('hbo.us'); + }); + + it('converts empty tvc_guide_stationid to null', () => { + const result = getFormattedValues({ stream_profile_id: '1', tvg_id: 'x', tvc_guide_stationid: '' }); + expect(result.tvc_guide_stationid).toBeNull(); + }); + + it('preserves non-empty tvc_guide_stationid', () => { + const result = getFormattedValues({ stream_profile_id: '1', tvg_id: 'x', tvc_guide_stationid: 'hbo-station' }); + expect(result.tvc_guide_stationid).toBe('hbo-station'); + }); + + it('does not mutate the original values object', () => { + const values = { stream_profile_id: '0', tvg_id: '', tvc_guide_stationid: '' }; + getFormattedValues(values); + expect(values.stream_profile_id).toBe('0'); + }); + + it('passes through unrelated fields unchanged', () => { + const result = getFormattedValues({ stream_profile_id: '1', tvg_id: 'x', tvc_guide_stationid: 'y', name: 'HBO' }); + expect(result.name).toBe('HBO'); + }); + }); + + // ── handleEpgUpdate ────────────────────────────────────────────────────────── + + describe('handleEpgUpdate', () => { + const makeValues = (overrides = {}) => ({ + name: 'HBO', + stream_profile_id: '3', + tvg_id: 'hbo.us', + tvc_guide_stationid: 'hbo-station', + epg_data_id: 'epg-new', + ...overrides, + }); + + const makeFormattedValues = (overrides = {}) => ({ + name: 'HBO', + stream_profile_id: '3', + tvg_id: 'hbo.us', + tvc_guide_stationid: 'hbo-station', + epg_data_id: 'epg-new', + ...overrides, + }); + + beforeEach(() => { + API.setChannelEPG.mockResolvedValue(undefined); + API.updateChannel.mockResolvedValue(undefined); + }); + + describe('when epg_data_id has changed', () => { + it('calls API.setChannelEPG with channel id and new epg_data_id', async () => { + const channel = makeChannel({ epg_data_id: 'epg-old' }); + const values = makeValues({ epg_data_id: 'epg-new' }); + await handleEpgUpdate(channel, values, makeFormattedValues({ epg_data_id: 'epg-new' }), makeChannelStreams()); + expect(API.setChannelEPG).toHaveBeenCalledWith('ch-1', 'epg-new'); + }); + + it('calls API.updateChannel with remaining fields and stream ids', async () => { + const channel = makeChannel({ epg_data_id: 'epg-old' }); + const values = makeValues({ epg_data_id: 'epg-new' }); + const formatted = makeFormattedValues({ epg_data_id: 'epg-new' }); + await handleEpgUpdate(channel, values, formatted, makeChannelStreams()); + expect(API.updateChannel).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'ch-1', + streams: ['s1', 's2'], + }) + ); + // epg_data_id must NOT be in the updateChannel call + const callArg = API.updateChannel.mock.calls[0][0]; + expect(callArg).not.toHaveProperty('epg_data_id'); + }); + + it('does not call API.updateChannel when formattedValues only contains epg_data_id', async () => { + const channel = makeChannel({ epg_data_id: 'epg-old' }); + const values = makeValues({ epg_data_id: 'epg-new' }); + // Only epg_data_id in formatted — after stripping it, nothing remains + await handleEpgUpdate(channel, values, { epg_data_id: 'epg-new' }, makeChannelStreams()); + expect(API.updateChannel).not.toHaveBeenCalled(); + }); + }); + + describe('when epg_data_id has not changed', () => { + it('does not call API.setChannelEPG', async () => { + const channel = makeChannel({ epg_data_id: 'epg-1' }); + const values = makeValues({ epg_data_id: 'epg-1' }); + await handleEpgUpdate(channel, values, makeFormattedValues({ epg_data_id: 'epg-1' }), makeChannelStreams()); + expect(API.setChannelEPG).not.toHaveBeenCalled(); + }); + + it('calls API.updateChannel with all formatted values and stream ids', async () => { + const channel = makeChannel({ epg_data_id: 'epg-1' }); + const values = makeValues({ epg_data_id: 'epg-1' }); + const formatted = makeFormattedValues({ epg_data_id: 'epg-1' }); + await handleEpgUpdate(channel, values, formatted, makeChannelStreams()); + expect(API.updateChannel).toHaveBeenCalledWith({ + id: 'ch-1', + ...formatted, + streams: ['s1', 's2'], + }); + }); + + it('handles empty channel streams array', async () => { + const channel = makeChannel({ epg_data_id: 'epg-1' }); + const values = makeValues({ epg_data_id: 'epg-1' }); + await handleEpgUpdate(channel, values, makeFormattedValues({ epg_data_id: 'epg-1' }), []); + expect(API.updateChannel).toHaveBeenCalledWith( + expect.objectContaining({ streams: [] }) + ); + }); + }); + + it('propagates API.setChannelEPG rejection', async () => { + API.setChannelEPG.mockRejectedValue(new Error('EPG error')); + const channel = makeChannel({ epg_data_id: 'epg-old' }); + const values = makeValues({ epg_data_id: 'epg-new' }); + await expect( + handleEpgUpdate(channel, values, makeFormattedValues({ epg_data_id: 'epg-new' }), makeChannelStreams()) + ).rejects.toThrow('EPG error'); + }); + + it('propagates API.updateChannel rejection', async () => { + API.updateChannel.mockRejectedValue(new Error('Update error')); + const channel = makeChannel({ epg_data_id: 'epg-1' }); + const values = makeValues({ epg_data_id: 'epg-1' }); + await expect( + handleEpgUpdate(channel, values, makeFormattedValues({ epg_data_id: 'epg-1' }), makeChannelStreams()) + ).rejects.toThrow('Update error'); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/utils/forms/__tests__/ConnectionUtils.test.js b/frontend/src/utils/forms/__tests__/ConnectionUtils.test.js new file mode 100644 index 00000000..69417038 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/ConnectionUtils.test.js @@ -0,0 +1,330 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + EVENT_OPTIONS, + updateConnectIntegration, + createConnectIntegration, + setConnectSubscriptions, + buildConfig, + buildSubscriptions, + parseApiError, +} from '../ConnectionUtils.js'; + +// ── Module mocks ─────────────────────────────────────────────────────────────── + +vi.mock('../../../api.js', () => ({ + default: { + updateConnectIntegration: vi.fn(), + createConnectIntegration: vi.fn(), + setConnectSubscriptions: vi.fn(), + }, +})); + +vi.mock('../../../constants.js', () => ({ + SUBSCRIPTION_EVENTS: { + channel_added: 'Channel Added', + channel_removed: 'Channel Removed', + recording_started: 'Recording Started', + }, +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── + +import API from '../../../api.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const makeConnection = (overrides = {}) => ({ id: 'conn-1', ...overrides }); + +const makeValues = (overrides = {}) => ({ + name: 'Test Integration', + type: 'webhook', + enabled: true, + url: 'https://example.com/hook', + script_path: '', + ...overrides, +}); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('ConnectionUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── EVENT_OPTIONS ──────────────────────────────────────────────────────────── + + describe('EVENT_OPTIONS', () => { + it('maps SUBSCRIPTION_EVENTS entries to { value, label } objects', () => { + expect(EVENT_OPTIONS).toEqual([ + { value: 'channel_added', label: 'Channel Added' }, + { value: 'channel_removed', label: 'Channel Removed' }, + { value: 'recording_started', label: 'Recording Started' }, + ]); + }); + + it('has the same length as SUBSCRIPTION_EVENTS', () => { + expect(EVENT_OPTIONS).toHaveLength(3); + }); + }); + + // ── createConnectIntegration ───────────────────────────────────────────────── + + describe('createConnectIntegration', () => { + it('calls API.createConnectIntegration with the correct payload', async () => { + const values = makeValues(); + const config = { url: 'https://example.com/hook' }; + vi.mocked(API.createConnectIntegration).mockResolvedValue({ id: 'new-1' }); + + await createConnectIntegration(values, config); + + expect(API.createConnectIntegration).toHaveBeenCalledWith({ + name: 'Test Integration', + type: 'webhook', + config, + enabled: true, + }); + }); + + it('returns the API response', async () => { + vi.mocked(API.createConnectIntegration).mockResolvedValue({ id: 'new-1' }); + const result = await createConnectIntegration(makeValues(), {}); + expect(result).toEqual({ id: 'new-1' }); + }); + + it('propagates API errors', async () => { + vi.mocked(API.createConnectIntegration).mockRejectedValue(new Error('Network error')); + await expect(createConnectIntegration(makeValues(), {})).rejects.toThrow('Network error'); + }); + }); + + // ── updateConnectIntegration ───────────────────────────────────────────────── + + describe('updateConnectIntegration', () => { + it('calls API.updateConnectIntegration with connection.id and correct payload', async () => { + const connection = makeConnection(); + const values = makeValues(); + const config = { url: 'https://example.com/hook' }; + vi.mocked(API.updateConnectIntegration).mockResolvedValue({ id: 'conn-1' }); + + await updateConnectIntegration(connection, values, config); + + expect(API.updateConnectIntegration).toHaveBeenCalledWith('conn-1', { + name: 'Test Integration', + type: 'webhook', + config, + enabled: true, + }); + }); + + it('returns the API response', async () => { + vi.mocked(API.updateConnectIntegration).mockResolvedValue({ id: 'conn-1', name: 'Updated' }); + const result = await updateConnectIntegration(makeConnection(), makeValues(), {}); + expect(result).toEqual({ id: 'conn-1', name: 'Updated' }); + }); + + it('propagates API errors', async () => { + vi.mocked(API.updateConnectIntegration).mockRejectedValue(new Error('Server error')); + await expect( + updateConnectIntegration(makeConnection(), makeValues(), {}) + ).rejects.toThrow('Server error'); + }); + }); + + // ── setConnectSubscriptions ────────────────────────────────────────────────── + + describe('setConnectSubscriptions', () => { + it('calls API.setConnectSubscriptions with connection.id and subs', async () => { + const connection = makeConnection(); + const subs = [{ event: 'channel_added', enabled: true, payload_template: null }]; + vi.mocked(API.setConnectSubscriptions).mockResolvedValue(undefined); + + await setConnectSubscriptions(connection, subs); + + expect(API.setConnectSubscriptions).toHaveBeenCalledWith('conn-1', subs); + }); + + it('propagates API errors', async () => { + vi.mocked(API.setConnectSubscriptions).mockRejectedValue(new Error('Failed')); + await expect(setConnectSubscriptions(makeConnection(), [])).rejects.toThrow('Failed'); + }); + }); + + // ── buildConfig ────────────────────────────────────────────────────────────── + + describe('buildConfig', () => { + describe('webhook type', () => { + it('returns config with url only when no headers are provided', () => { + const values = makeValues({ type: 'webhook', url: 'https://example.com/hook' }); + expect(buildConfig(values, [])).toEqual({ url: 'https://example.com/hook' }); + }); + + it('includes headers when non-empty key/value pairs are present', () => { + const values = makeValues({ type: 'webhook', url: 'https://example.com/hook' }); + const headers = [{ key: 'Authorization', value: 'Bearer token' }]; + expect(buildConfig(values, headers)).toEqual({ + url: 'https://example.com/hook', + headers: { Authorization: 'Bearer token' }, + }); + }); + + it('omits headers with blank keys', () => { + const values = makeValues({ type: 'webhook', url: 'https://example.com/hook' }); + const headers = [ + { key: '', value: 'should-be-ignored' }, + { key: ' ', value: 'also-ignored' }, + { key: 'X-Custom', value: 'kept' }, + ]; + expect(buildConfig(values, headers)).toEqual({ + url: 'https://example.com/hook', + headers: { 'X-Custom': 'kept' }, + }); + }); + + it('omits headers property entirely when all keys are blank', () => { + const values = makeValues({ type: 'webhook', url: 'https://example.com/hook' }); + const headers = [{ key: '', value: 'ignored' }]; + const config = buildConfig(values, headers); + expect(config).not.toHaveProperty('headers'); + }); + + it('supports multiple headers', () => { + const values = makeValues({ type: 'webhook', url: 'https://example.com/hook' }); + const headers = [ + { key: 'X-One', value: '1' }, + { key: 'X-Two', value: '2' }, + ]; + expect(buildConfig(values, headers)).toEqual({ + url: 'https://example.com/hook', + headers: { 'X-One': '1', 'X-Two': '2' }, + }); + }); + }); + + describe('script type', () => { + it('returns config with path from script_path', () => { + const values = makeValues({ type: 'script', script_path: '/usr/local/bin/notify.sh' }); + expect(buildConfig(values, [])).toEqual({ path: '/usr/local/bin/notify.sh' }); + }); + + it('ignores headers for script type', () => { + const values = makeValues({ type: 'script', script_path: '/usr/bin/run.sh' }); + const headers = [{ key: 'Authorization', value: 'Bearer token' }]; + const config = buildConfig(values, headers); + expect(config).not.toHaveProperty('headers'); + expect(config).toEqual({ path: '/usr/bin/run.sh' }); + }); + }); + }); + + // ── buildSubscriptions ─────────────────────────────────────────────────────── + + describe('buildSubscriptions', () => { + it('returns an entry for every event in SUBSCRIPTION_EVENTS', () => { + const result = buildSubscriptions([], {}); + expect(result).toHaveLength(3); + expect(result.map((s) => s.event)).toEqual([ + 'channel_added', + 'channel_removed', + 'recording_started', + ]); + }); + + it('marks events as enabled when they are in selectedEvents', () => { + const result = buildSubscriptions(['channel_added'], {}); + const added = result.find((s) => s.event === 'channel_added'); + const removed = result.find((s) => s.event === 'channel_removed'); + expect(added.enabled).toBe(true); + expect(removed.enabled).toBe(false); + }); + + it('sets payload_template from payloadTemplates when present', () => { + const templates = { channel_added: '{"key":"{{value}}"}' }; + const result = buildSubscriptions(['channel_added'], templates); + const added = result.find((s) => s.event === 'channel_added'); + expect(added.payload_template).toBe('{"key":"{{value}}"}'); + }); + + it('sets payload_template to null when not in payloadTemplates', () => { + const result = buildSubscriptions([], {}); + result.forEach((s) => expect(s.payload_template).toBeNull()); + }); + + it('sets payload_template to null when value is undefined in payloadTemplates', () => { + const templates = { channel_added: undefined }; + const result = buildSubscriptions([], templates); + const added = result.find((s) => s.event === 'channel_added'); + expect(added.payload_template).toBeNull(); + }); + }); + + // ── parseApiError ──────────────────────────────────────────────────────────── + + describe('parseApiError', () => { + it('returns message when error has no body', () => { + const error = { message: 'Network failure' }; + expect(parseApiError(error)).toEqual({ fieldErrors: {}, apiError: 'Network failure' }); + }); + + it('returns "Unknown error" when error has no body and no message', () => { + expect(parseApiError({})).toEqual({ fieldErrors: {}, apiError: 'Unknown error' }); + }); + + it('returns message when body is a string (not an object)', () => { + const error = { body: 'Bad Request', message: 'HTTP 400' }; + expect(parseApiError(error)).toEqual({ fieldErrors: {}, apiError: 'HTTP 400' }); + }); + + it('extracts known field errors from body', () => { + const error = { body: { name: 'This field is required.', type: 'Invalid choice.' } }; + const { fieldErrors } = parseApiError(error); + expect(fieldErrors).toEqual({ + name: 'This field is required.', + type: 'Invalid choice.', + }); + }); + + it('ignores unknown fields in body', () => { + const error = { body: { unknown_field: 'some error' } }; + const { fieldErrors } = parseApiError(error); + expect(fieldErrors).toEqual({}); + }); + + it('uses non_field_errors as apiError when present', () => { + const error = { body: { non_field_errors: 'Invalid credentials.' } }; + const { apiError } = parseApiError(error); + expect(apiError).toBe('Invalid credentials.'); + }); + + it('falls back to detail as apiError when non_field_errors is absent', () => { + const error = { body: { detail: 'Authentication required.' } }; + const { apiError } = parseApiError(error); + expect(apiError).toBe('Authentication required.'); + }); + + it('prefers non_field_errors over detail', () => { + const error = { body: { non_field_errors: 'NF error', detail: 'Detail error' } }; + const { apiError } = parseApiError(error); + expect(apiError).toBe('NF error'); + }); + + it('sets apiError to empty string when field errors are present but no non_field_errors', () => { + const error = { body: { name: 'Required.' } }; + const { apiError } = parseApiError(error); + expect(apiError).toBe(''); + }); + + it('JSON.stringifies body as fallback when no field errors and no non_field_errors', () => { + const error = { body: { unexpected: 'data' } }; + const { apiError } = parseApiError(error); + expect(apiError).toBe(JSON.stringify({ unexpected: 'data' })); + }); + + it('handles null error gracefully', () => { + expect(parseApiError(null)).toEqual({ fieldErrors: {}, apiError: 'Unknown error' }); + }); + + it('handles undefined error gracefully', () => { + expect(parseApiError(undefined)).toEqual({ fieldErrors: {}, apiError: 'Unknown error' }); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/utils/forms/__tests__/CronBuilderUtils.test.js b/frontend/src/utils/forms/__tests__/CronBuilderUtils.test.js new file mode 100644 index 00000000..92ad83bb --- /dev/null +++ b/frontend/src/utils/forms/__tests__/CronBuilderUtils.test.js @@ -0,0 +1,349 @@ +import { describe, it, expect } from 'vitest'; +import { + buildCron, + parseCronPreset, + updateCronPart, + PRESETS, + DAYS_OF_WEEK, + FREQUENCY_OPTIONS, + CRON_FIELDS, +} from '../CronBuilderUtils.js'; + +describe('CronBuilderUtils', () => { + // ── PRESETS ──────────────────────────────────────────────────────────────────── + + describe('PRESETS', () => { + it('is a non-empty array', () => { + expect(Array.isArray(PRESETS)).toBe(true); + expect(PRESETS.length).toBeGreaterThan(0); + }); + + it('every preset has label, value, and description', () => { + PRESETS.forEach((preset) => { + expect(preset).toHaveProperty('label'); + expect(preset).toHaveProperty('value'); + expect(preset).toHaveProperty('description'); + }); + }); + + it('every preset value is a valid 5-part cron string', () => { + PRESETS.forEach((preset) => { + expect(preset.value.split(' ')).toHaveLength(5); + }); + }); + }); + +// ── DAYS_OF_WEEK ─────────────────────────────────────────────────────────────── + + describe('DAYS_OF_WEEK', () => { + it('contains 8 entries (wildcard + 7 days)', () => { + expect(DAYS_OF_WEEK).toHaveLength(8); + }); + + it('first entry is the wildcard "Every day"', () => { + expect(DAYS_OF_WEEK[0]).toEqual({ value: '*', label: 'Every day' }); + }); + + it('every entry has value and label', () => { + DAYS_OF_WEEK.forEach((day) => { + expect(day).toHaveProperty('value'); + expect(day).toHaveProperty('label'); + }); + }); + + it('numeric entries run 0-6', () => { + const numeric = DAYS_OF_WEEK.filter((d) => d.value !== '*'); + expect(numeric.map((d) => d.value)).toEqual(['0', '1', '2', '3', '4', '5', '6']); + }); + }); + +// ── FREQUENCY_OPTIONS ────────────────────────────────────────────────────────── + + describe('FREQUENCY_OPTIONS', () => { + it('contains exactly 4 options', () => { + expect(FREQUENCY_OPTIONS).toHaveLength(4); + }); + + it('contains hourly, daily, weekly, monthly', () => { + const values = FREQUENCY_OPTIONS.map((o) => o.value); + expect(values).toEqual(['hourly', 'daily', 'weekly', 'monthly']); + }); + + it('every option has value and label', () => { + FREQUENCY_OPTIONS.forEach((opt) => { + expect(opt).toHaveProperty('value'); + expect(opt).toHaveProperty('label'); + }); + }); + }); + +// ── CRON_FIELDS ──────────────────────────────────────────────────────────────── + + describe('CRON_FIELDS', () => { + it('contains exactly 5 fields', () => { + expect(CRON_FIELDS).toHaveLength(5); + }); + + it('indexes run 0-4 in order', () => { + expect(CRON_FIELDS.map((f) => f.index)).toEqual([0, 1, 2, 3, 4]); + }); + + it('every field has index, label, and placeholder', () => { + CRON_FIELDS.forEach((field) => { + expect(field).toHaveProperty('index'); + expect(field).toHaveProperty('label'); + expect(field).toHaveProperty('placeholder'); + }); + }); + }); + +// ── buildCron ────────────────────────────────────────────────────────────────── + + describe('buildCron', () => { + describe('hourly', () => { + it('returns minute-only expression', () => { + expect(buildCron('hourly', 0, 0, '*', 1)).toBe('0 * * * *'); + }); + + it('uses the provided minute value', () => { + expect(buildCron('hourly', 30, 12, '*', 1)).toBe('30 * * * *'); + }); + + it('ignores hour, dayOfWeek, and dayOfMonth', () => { + expect(buildCron('hourly', 15, 9, '1', 5)).toBe('15 * * * *'); + }); + }); + + describe('daily', () => { + it('returns minute and hour expression', () => { + expect(buildCron('daily', 0, 3, '*', 1)).toBe('0 3 * * *'); + }); + + it('uses the provided minute and hour', () => { + expect(buildCron('daily', 30, 12, '*', 1)).toBe('30 12 * * *'); + }); + + it('ignores dayOfWeek and dayOfMonth', () => { + expect(buildCron('daily', 0, 0, '2', 15)).toBe('0 0 * * *'); + }); + }); + + describe('weekly', () => { + it('uses specific dayOfWeek when provided', () => { + expect(buildCron('weekly', 0, 3, '1', 1)).toBe('0 3 * * 1'); + }); + + it('defaults dayOfWeek to 0 when wildcard is passed', () => { + expect(buildCron('weekly', 0, 3, '*', 1)).toBe('0 3 * * 0'); + }); + + it('uses the provided minute and hour', () => { + expect(buildCron('weekly', 15, 9, '5', 1)).toBe('15 9 * * 5'); + }); + + it('ignores dayOfMonth', () => { + expect(buildCron('weekly', 0, 0, '0', 31)).toBe('0 0 * * 0'); + }); + }); + + describe('monthly', () => { + it('returns expression with day of month', () => { + expect(buildCron('monthly', 30, 2, '*', 1)).toBe('30 2 1 * *'); + }); + + it('uses the provided dayOfMonth', () => { + expect(buildCron('monthly', 0, 0, '*', 15)).toBe('0 0 15 * *'); + }); + + it('ignores dayOfWeek', () => { + expect(buildCron('monthly', 0, 6, '3', 10)).toBe('0 6 10 * *'); + }); + }); + + describe('unknown frequency', () => { + it('returns wildcard expression for unknown frequency', () => { + expect(buildCron('unknown', 0, 0, '*', 1)).toBe('* * * * *'); + }); + + it('returns wildcard expression for empty string', () => { + expect(buildCron('', 0, 0, '*', 1)).toBe('* * * * *'); + }); + }); + }); + +// ── parseCronPreset ──────────────────────────────────────────────────────────── + + describe('parseCronPreset', () => { + describe('hourly detection', () => { + it('detects hourly when hour is wildcard', () => { + const result = parseCronPreset('0 * * * *'); + expect(result.frequency).toBe('hourly'); + }); + + it('returns correct minute for hourly', () => { + expect(parseCronPreset('0 * * * *').minute).toBe(0); + }); + + it('returns dayOfWeek as wildcard for hourly', () => { + expect(parseCronPreset('0 * * * *').dayOfWeek).toBe('*'); + }); + + it('returns dayOfMonth as 1 for hourly', () => { + expect(parseCronPreset('0 * * * *').dayOfMonth).toBe(1); + }); + + it('parses step-based hourly (*/6)', () => { + const result = parseCronPreset('0 */6 * * *'); + expect(result.frequency).toBe('daily'); + expect(result.hour).toBe(6); + }); + }); + + describe('weekly detection', () => { + it('detects weekly when weekday is not wildcard', () => { + const result = parseCronPreset('0 3 * * 1'); + expect(result.frequency).toBe('weekly'); + }); + + it('returns correct dayOfWeek', () => { + expect(parseCronPreset('0 3 * * 1').dayOfWeek).toBe('1'); + }); + + it('returns correct minute and hour for weekly', () => { + const result = parseCronPreset('15 9 * * 5'); + expect(result.minute).toBe(15); + expect(result.hour).toBe(9); + }); + + it('returns dayOfMonth as 1 for weekly', () => { + expect(parseCronPreset('0 3 * * 0').dayOfMonth).toBe(1); + }); + }); + + describe('monthly detection', () => { + it('detects monthly when day of month is not wildcard', () => { + const result = parseCronPreset('30 2 1 * *'); + expect(result.frequency).toBe('monthly'); + }); + + it('returns correct dayOfMonth', () => { + expect(parseCronPreset('0 0 15 * *').dayOfMonth).toBe(15); + }); + + it('returns correct minute and hour for monthly', () => { + const result = parseCronPreset('30 2 1 * *'); + expect(result.minute).toBe(30); + expect(result.hour).toBe(2); + }); + + it('returns dayOfWeek as wildcard for monthly', () => { + expect(parseCronPreset('30 2 1 * *').dayOfWeek).toBe('*'); + }); + }); + + describe('daily detection', () => { + it('detects daily as the default when no other conditions match', () => { + const result = parseCronPreset('0 3 * * *'); + expect(result.frequency).toBe('daily'); + }); + + it('returns correct minute and hour for daily', () => { + const result = parseCronPreset('30 12 * * *'); + expect(result.minute).toBe(30); + expect(result.hour).toBe(12); + }); + + it('returns dayOfWeek as wildcard for daily', () => { + expect(parseCronPreset('0 3 * * *').dayOfWeek).toBe('*'); + }); + + it('returns dayOfMonth as 1 for daily', () => { + expect(parseCronPreset('0 3 * * *').dayOfMonth).toBe(1); + }); + }); + + describe('edge cases', () => { + it('defaults minute to 0 for non-numeric minute field', () => { + expect(parseCronPreset('* 3 * * *').minute).toBe(0); + }); + + it('defaults hour to 0 for wildcard hour in non-hourly cron', () => { + expect(parseCronPreset('0 0 * * *').hour).toBe(0); + }); + + it('round-trips all PRESETS without throwing', () => { + PRESETS.forEach((preset) => { + expect(() => parseCronPreset(preset.value)).not.toThrow(); + }); + }); + }); + }); + +// ── updateCronPart ───────────────────────────────────────────────────────────── + + describe('updateCronPart', () => { + describe('valid 5-part cron', () => { + it('updates the minute field (index 0)', () => { + expect(updateCronPart('* * * * *', 0, '30')).toBe('30 * * * *'); + }); + + it('updates the hour field (index 1)', () => { + expect(updateCronPart('0 * * * *', 1, '6')).toBe('0 6 * * *'); + }); + + it('updates the day of month field (index 2)', () => { + expect(updateCronPart('0 3 * * *', 2, '15')).toBe('0 3 15 * *'); + }); + + it('updates the month field (index 3)', () => { + expect(updateCronPart('0 3 * * *', 3, '6')).toBe('0 3 * 6 *'); + }); + + it('updates the day of week field (index 4)', () => { + expect(updateCronPart('0 3 * * *', 4, '1')).toBe('0 3 * * 1'); + }); + + it('preserves other parts when updating one field', () => { + expect(updateCronPart('5 4 3 2 1', 0, '10')).toBe('10 4 3 2 1'); + }); + }); + + describe('empty or falsy value', () => { + it('replaces empty string with wildcard', () => { + expect(updateCronPart('0 3 * * *', 0, '')).toBe('* 3 * * *'); + }); + + it('replaces undefined with wildcard', () => { + expect(updateCronPart('0 3 * * *', 0, undefined)).toBe('* 3 * * *'); + }); + + it('replaces null with wildcard', () => { + expect(updateCronPart('0 3 * * *', 0, null)).toBe('* 3 * * *'); + }); + }); + + describe('invalid or short cron string', () => { + it('falls back to all-wildcards base for a short cron string', () => { + expect(updateCronPart('0 3', 0, '5')).toBe('5 * * * *'); + }); + + it('falls back to all-wildcards for an empty string', () => { + expect(updateCronPart('', 0, '5')).toBe('5 * * * *'); + }); + }); + + describe('step and range values', () => { + it('accepts step values like */15', () => { + expect(updateCronPart('* * * * *', 0, '*/15')).toBe('*/15 * * * *'); + }); + + it('accepts range values like 9-17', () => { + expect(updateCronPart('* * * * *', 1, '9-17')).toBe('* 9-17 * * *'); + }); + + it('accepts comma-separated values like 0,15,30,45', () => { + expect(updateCronPart('* * * * *', 0, '0,15,30,45')).toBe('0,15,30,45 * * * *'); + }); + }); + }); +}); diff --git a/frontend/src/utils/forms/__tests__/DummyEpgUtils.test.js b/frontend/src/utils/forms/__tests__/DummyEpgUtils.test.js new file mode 100644 index 00000000..c4d1a7c3 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/DummyEpgUtils.test.js @@ -0,0 +1,562 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Module mocks (must be before imports) ───────────────────────────────────── +vi.mock('../../../api.js', () => ({ + default: { + getTimezones: vi.fn(), + addEPG: vi.fn(), + updateEPG: vi.fn(), + }, +})); + +vi.mock('../../dateTimeUtils.js', () => ({ + format: vi.fn(), + getDay: vi.fn(), + getHour: vi.fn(), + getMinute: vi.fn(), + getMonth: vi.fn(), + getNow: vi.fn(), + getYear: vi.fn(), + MONTH_ABBR: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'], + MONTH_NAMES: ['January','February','March','April','May','June', + 'July','August','September','October','November','December'], + setHour: vi.fn((dt) => dt), + setMinute: vi.fn((dt) => dt), + setSecond: vi.fn((dt) => dt), + setTz: vi.fn(), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import API from '../../../api.js'; +import * as dateTimeUtils from '../../dateTimeUtils.js'; +import { + getTimezones, + updateEPG, + addEPG, + getDummyEpgFormInitialValues, + buildCustomProperties, + validateCustomTitlePattern, + validateCustomNameSource, + validateCustomStreamIndex, + matchPattern, + addNormalizedGroups, + applyTemplates, + buildTimePlaceholders, +} from '../DummyEpgUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeEPG = (overrides = {}) => ({ + id: 42, + name: 'Test EPG', + is_active: true, + source_type: 'dummy', + custom_properties: {}, + ...overrides, +}); + +const makeCustom = (overrides = {}) => ({ + title_pattern: '(?.+)', + time_pattern: '(?<hour>\\d+):(?<minute>\\d+)', + date_pattern: '', + timezone: 'US/Eastern', + output_timezone: '', + program_duration: 180, + sample_title: 'Test Show 9:00 PM', + title_template: '{title}', + subtitle_template: '', + description_template: '', + upcoming_title_template: '', + upcoming_description_template: '', + ended_title_template: '', + ended_description_template: '', + fallback_title_template: '', + fallback_description_template: '', + channel_logo_url: '', + program_poster_url: '', + name_source: 'channel', + stream_index: 1, + category: '', + include_date: true, + include_live: false, + include_new: false, + ...overrides, +}); + +// ────────────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────────────── +describe('DummyEpgUtils', () => { + + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── getTimezones ─────────────────────────────────────────────────────────── + describe('getTimezones', () => { + it('calls API.getTimezones and returns its result', async () => { + vi.mocked(API.getTimezones).mockResolvedValue({ timezones: ['UTC', 'US/Eastern'] }); + const result = await getTimezones(); + expect(API.getTimezones).toHaveBeenCalledTimes(1); + expect(result).toEqual({ timezones: ['UTC', 'US/Eastern'] }); + }); + + it('propagates rejection from API.getTimezones', async () => { + vi.mocked(API.getTimezones).mockRejectedValue(new Error('Network error')); + await expect(getTimezones()).rejects.toThrow('Network error'); + }); + }); + + // ── addEPG ───────────────────────────────────────────────────────────────── + describe('addEPG', () => { + it('calls API.addEPG with the provided values', async () => { + const values = { name: 'New EPG', source_type: 'dummy' }; + vi.mocked(API.addEPG).mockResolvedValue({ id: 1, ...values }); + const result = await addEPG(values); + expect(API.addEPG).toHaveBeenCalledWith(values); + expect(result).toMatchObject({ id: 1, name: 'New EPG' }); + }); + + it('propagates rejection from API.addEPG', async () => { + vi.mocked(API.addEPG).mockRejectedValue(new Error('Server error')); + await expect(addEPG({})).rejects.toThrow('Server error'); + }); + }); + + // ── updateEPG ────────────────────────────────────────────────────────────── + describe('updateEPG', () => { + it('calls API.updateEPG with values merged with epg.id', async () => { + const epg = makeEPG({ id: 42 }); + const values = { name: 'Updated EPG' }; + vi.mocked(API.updateEPG).mockResolvedValue({ id: 42, name: 'Updated EPG' }); + + const result = await updateEPG(values, epg); + + expect(API.updateEPG).toHaveBeenCalledWith({ name: 'Updated EPG', id: 42 }); + expect(result).toMatchObject({ id: 42 }); + }); + + it('passes the correct id when multiple epgs exist', async () => { + const epg = makeEPG({ id: 99 }); + vi.mocked(API.updateEPG).mockResolvedValue({}); + await updateEPG({ name: 'X' }, epg); + expect(API.updateEPG).toHaveBeenCalledWith(expect.objectContaining({ id: 99 })); + }); + + it('propagates rejection from API.updateEPG', async () => { + vi.mocked(API.updateEPG).mockRejectedValue(new Error('Update failed')); + await expect(updateEPG({}, makeEPG())).rejects.toThrow('Update failed'); + }); + }); + + // ── getDummyEpgFormInitialValues ─────────────────────────────────────────── + describe('getDummyEpgFormInitialValues', () => { + it('returns an object with name, is_active, source_type, and custom_properties', () => { + const result = getDummyEpgFormInitialValues(); + expect(result).toMatchObject({ + name: expect.any(String), + is_active: expect.any(Boolean), + source_type: 'dummy', + }); + expect(result.custom_properties).toBeDefined(); + }); + + it('sets is_active to true by default', () => { + expect(getDummyEpgFormInitialValues().is_active).toBe(true); + }); + + it('sets include_date to true by default', () => { + expect(getDummyEpgFormInitialValues().custom_properties.include_date).toBe(true); + }); + + it('sets include_live and include_new to false by default', () => { + const { custom_properties: cp } = getDummyEpgFormInitialValues(); + expect(cp.include_live).toBe(false); + expect(cp.include_new).toBe(false); + }); + + it('defaults name_source to "channel"', () => { + expect(getDummyEpgFormInitialValues().custom_properties.name_source).toBe('channel'); + }); + + it('defaults program_duration to 180', () => { + expect(getDummyEpgFormInitialValues().custom_properties.program_duration).toBe(180); + }); + + it('defaults stream_index to 1', () => { + expect(getDummyEpgFormInitialValues().custom_properties.stream_index).toBe(1); + }); + }); + + // ── buildCustomProperties ────────────────────────────────────────────────── + describe('buildCustomProperties', () => { + it('returns defaults when called with no arguments', () => { + const result = buildCustomProperties(); + expect(result.timezone).toBe('US/Eastern'); + expect(result.program_duration).toBe(180); + expect(result.name_source).toBe('channel'); + expect(result.stream_index).toBe(1); + expect(result.include_date).toBe(true); + expect(result.include_live).toBe(false); + expect(result.include_new).toBe(false); + }); + + it('preserves provided values over defaults', () => { + const custom = makeCustom({ timezone: 'US/Pacific', program_duration: 60 }); + const result = buildCustomProperties(custom); + expect(result.timezone).toBe('US/Pacific'); + expect(result.program_duration).toBe(60); + }); + + it('preserves include_date: false when explicitly set', () => { + const result = buildCustomProperties({ include_date: false }); + expect(result.include_date).toBe(false); + }); + + it('preserves include_live: true when explicitly set', () => { + const result = buildCustomProperties({ include_live: true }); + expect(result.include_live).toBe(true); + }); + + it('preserves include_new: true when explicitly set', () => { + const result = buildCustomProperties({ include_new: true }); + expect(result.include_new).toBe(true); + }); + + it('maps all template fields', () => { + const custom = makeCustom({ + title_template: 'T:{title}', + subtitle_template: 'S:{title}', + description_template: 'D:{title}', + upcoming_title_template: 'U:{title}', + upcoming_description_template: 'UD:{title}', + ended_title_template: 'E:{title}', + ended_description_template: 'ED:{title}', + fallback_title_template: 'FT:{title}', + fallback_description_template: 'FD:{title}', + channel_logo_url: 'http://logo', + program_poster_url: 'http://poster', + }); + const result = buildCustomProperties(custom); + expect(result.title_template).toBe('T:{title}'); + expect(result.subtitle_template).toBe('S:{title}'); + expect(result.description_template).toBe('D:{title}'); + expect(result.upcoming_title_template).toBe('U:{title}'); + expect(result.upcoming_description_template).toBe('UD:{title}'); + expect(result.ended_title_template).toBe('E:{title}'); + expect(result.ended_description_template).toBe('ED:{title}'); + expect(result.fallback_title_template).toBe('FT:{title}'); + expect(result.fallback_description_template).toBe('FD:{title}'); + expect(result.channel_logo_url).toBe('http://logo'); + expect(result.program_poster_url).toBe('http://poster'); + }); + + it('falls back to empty string for missing string fields', () => { + const result = buildCustomProperties({}); + expect(result.title_pattern).toBe(''); + expect(result.time_pattern).toBe(''); + expect(result.date_pattern).toBe(''); + expect(result.output_timezone).toBe(''); + expect(result.sample_title).toBe(''); + expect(result.category).toBe(''); + }); + }); + + // ── validateCustomTitlePattern ───────────────────────────────────────────── + describe('validateCustomTitlePattern', () => { + it('returns an error message when value is empty', () => { + expect(validateCustomTitlePattern('')).toBeTruthy(); + }); + + it('returns an error message when value is whitespace only', () => { + expect(validateCustomTitlePattern(' ')).toBeTruthy(); + }); + + it('returns null for a valid regex pattern', () => { + expect(validateCustomTitlePattern('(?<title>.+)')).toBeNull(); + }); + + it('returns an error message for an invalid regex', () => { + expect(validateCustomTitlePattern('(?<invalid')).toBeTruthy(); + }); + + it('returns an error message when value is null', () => { + expect(validateCustomTitlePattern(null)).toBeTruthy(); + }); + + it('returns an error message when value is undefined', () => { + expect(validateCustomTitlePattern(undefined)).toBeTruthy(); + }); + + it('returns null for a complex valid regex with named groups', () => { + expect(validateCustomTitlePattern('(?<title>[A-Z].+)\\s(?<time>\\d+:\\d+)')).toBeNull(); + }); + }); + + // ── validateCustomNameSource ─────────────────────────────────────────────── + describe('validateCustomNameSource', () => { + it('returns an error message when value is empty string', () => { + expect(validateCustomNameSource('')).toBeTruthy(); + }); + + it('returns an error message when value is null', () => { + expect(validateCustomNameSource(null)).toBeTruthy(); + }); + + it('returns an error message when value is undefined', () => { + expect(validateCustomNameSource(undefined)).toBeTruthy(); + }); + + it('returns null for "channel"', () => { + expect(validateCustomNameSource('channel')).toBeNull(); + }); + + it('returns null for "stream"', () => { + expect(validateCustomNameSource('stream')).toBeNull(); + }); + }); + + // ── validateCustomStreamIndex (if exported) ──────────────────────────────── + describe('validateCustomStreamIndex', () => { + const streamValues = { custom_properties: { name_source: 'stream' } }; + const channelValues = { custom_properties: { name_source: 'channel' } }; + + it('returns null for a positive integer when name_source is stream', () => { + expect(validateCustomStreamIndex(streamValues, 1)).toBeNull(); + }); + + it('returns null for a large positive integer when name_source is stream', () => { + expect(validateCustomStreamIndex(streamValues, 10)).toBeNull(); + }); + + it('returns an error for 0 when name_source is stream', () => { + expect(validateCustomStreamIndex(streamValues, 0)).toBeTruthy(); + }); + + it('returns an error for a negative number when name_source is stream', () => { + expect(validateCustomStreamIndex(streamValues, -1)).toBeTruthy(); + }); + + it('returns an error for null value when name_source is stream', () => { + expect(validateCustomStreamIndex(streamValues, null)).toBeTruthy(); + }); + + it('returns an error for undefined value when name_source is stream', () => { + expect(validateCustomStreamIndex(streamValues, undefined)).toBeTruthy(); + }); + + it('returns null regardless of value when name_source is channel', () => { + expect(validateCustomStreamIndex(channelValues, 0)).toBeNull(); + expect(validateCustomStreamIndex(channelValues, -1)).toBeNull(); + expect(validateCustomStreamIndex(channelValues, null)).toBeNull(); + }); + + it('returns null when custom_properties is missing', () => { + expect(validateCustomStreamIndex({}, 0)).toBeNull(); + }); + }); + + // ── matchPattern ─────────────────────────────────────────────────────────── + describe('matchPattern', () => { + it('returns matched: false and no error when pattern is empty', () => { + const result = matchPattern('', 'Test Show 9:00 PM'); + expect(result.matched).toBe(false); + expect(result.error).toBeFalsy(); + }); + + it('returns matched: true with named groups when pattern matches', () => { + const result = matchPattern('(?<title>Test Show)', 'Test Show 9:00 PM'); + expect(result.matched).toBe(true); + expect(result.groups).toMatchObject({ title: 'Test Show' }); + expect(result.error).toBeFalsy(); + }); + + it('returns matched: false with no error when pattern does not match', () => { + const result = matchPattern('(?<title>No Match)', 'Test Show 9:00 PM'); + expect(result.matched).toBe(false); + expect(result.error).toBeFalsy(); + }); + + it('returns an error string when regex is invalid', () => { + const result = matchPattern('(?<invalid', 'Test Show'); + expect(result.matched).toBe(false); + expect(result.error).toBeTruthy(); + }); + + it('uses the provided errorLabel in the error message', () => { + const result = matchPattern('(?<bad', 'sample', 'Time Pattern Error'); + expect(result.error).toContain('Time Pattern Error'); + }); + + it('returns matched: false when sample is empty', () => { + const result = matchPattern('(?<title>.+)', ''); + expect(result.matched).toBe(false); + }); + + it('captures multiple named groups', () => { + const result = matchPattern( + '(?<title>.+?)\\s(?<hour>\\d+):(?<minute>\\d+)', + 'Test Show 9:00' + ); + expect(result.matched).toBe(true); + expect(result.groups).toMatchObject({ title: 'Test Show', hour: '9', minute: '00' }); + }); + }); + + // ── addNormalizedGroups ──────────────────────────────────────────────────── + describe('addNormalizedGroups', () => { + it('returns an empty object when groups is empty', () => { + expect(addNormalizedGroups({})).toEqual({}); + }); + + it('preserves original group keys', () => { + const result = addNormalizedGroups({ title: 'Test Show' }); + expect(result.title).toBe('Test Show'); + }); + + it('adds a _normalize key for each group', () => { + const result = addNormalizedGroups({ title: 'Test Show' }); + expect(result.title_normalize).toBeDefined(); + }); + + it('normalizes accented characters', () => { + const result = addNormalizedGroups({ title: 'Héllo Wörld' }); + expect(result.title_normalize).not.toContain('é'); + expect(result.title_normalize).not.toContain('ö'); + }); + + it('adds normalized keys for multiple groups', () => { + const result = addNormalizedGroups({ title: 'Show', subtitle: 'Épilogue' }); + expect(result.title_normalize).toBeDefined(); + expect(result.subtitle_normalize).toBeDefined(); + }); + }); + + // ── applyTemplates ───────────────────────────────────────────────────────── + describe('applyTemplates', () => { + const templates = { + titleTemplate: '{title}', + subtitleTemplate: '{subtitle}', + descriptionTemplate: '{title} - {subtitle}', + upcomingTitleTemplate: 'Upcoming: {title}', + upcomingDescriptionTemplate: 'Details for {title}', + endedTitleTemplate: 'Ended: {title}', + endedDescriptionTemplate: 'Summary of {title}', + channelLogoUrl: 'http://logo/{title}', + programPosterUrl: 'http://poster/{title}', + }; + + const groups = { title: 'Test Show', subtitle: 'Pilot' }; + + it('returns an empty/falsy result when hasMatch is false', () => { + const result = applyTemplates(templates, groups, false); + expect(result.formattedTitle).toBeFalsy(); + }); + + it('fills {title} placeholder in titleTemplate', () => { + const result = applyTemplates(templates, groups, true); + expect(result.formattedTitle).toBe('Test Show'); + }); + + it('fills multiple placeholders in descriptionTemplate', () => { + const result = applyTemplates(templates, groups, true); + expect(result.formattedDescription).toBe('Test Show - Pilot'); + }); + + it('fills upcomingTitleTemplate', () => { + const result = applyTemplates(templates, groups, true); + expect(result.formattedUpcomingTitle).toBe('Upcoming: Test Show'); + }); + + it('fills endedTitleTemplate', () => { + const result = applyTemplates(templates, groups, true); + expect(result.formattedEndedTitle).toBe('Ended: Test Show'); + }); + + it('fills channelLogoUrl', () => { + const result = applyTemplates(templates, groups, true); + expect(result.formattedChannelLogoUrl).toBe('http://logo/Test%20Show'); + }); + + it('fills programPosterUrl', () => { + const result = applyTemplates(templates, groups, true); + expect(result.formattedProgramPosterUrl).toBe('http://poster/Test%20Show'); + }); + + it('returns empty string for template that has no matching placeholder', () => { + const result = applyTemplates( + { ...templates, subtitleTemplate: '{nonexistent}' }, + groups, + true + ); + // placeholder not in groups — expect either empty or the literal token + expect(typeof result.formattedSubtitle).toBe('string'); + }); + + it('returns an object with all expected keys', () => { + const result = applyTemplates(templates, groups, true); + expect(result).toHaveProperty('formattedTitle'); + expect(result).toHaveProperty('formattedSubtitle'); + expect(result).toHaveProperty('formattedDescription'); + expect(result).toHaveProperty('formattedUpcomingTitle'); + expect(result).toHaveProperty('formattedUpcomingDescription'); + expect(result).toHaveProperty('formattedEndedTitle'); + expect(result).toHaveProperty('formattedEndedDescription'); + expect(result).toHaveProperty('formattedChannelLogoUrl'); + expect(result).toHaveProperty('formattedProgramPosterUrl'); + }); + }); + + // ── buildTimePlaceholders ────────────────────────────────────────────────── + describe('buildTimePlaceholders', () => { + beforeEach(() => { + // Provide realistic return values from dateTimeUtils + vi.mocked(dateTimeUtils.getHour).mockReturnValue(21); // 9 PM + vi.mocked(dateTimeUtils.getMinute).mockReturnValue(0); + vi.mocked(dateTimeUtils.getDay).mockReturnValue(15); + vi.mocked(dateTimeUtils.getMonth).mockReturnValue(5); // June (0-indexed) + vi.mocked(dateTimeUtils.getYear).mockReturnValue(2024); + vi.mocked(dateTimeUtils.format).mockImplementation((dt, fmt) => fmt); + vi.mocked(dateTimeUtils.setHour).mockImplementation((dt) => dt); + vi.mocked(dateTimeUtils.setMinute).mockImplementation((dt) => dt); + vi.mocked(dateTimeUtils.setSecond).mockImplementation((dt) => dt); + vi.mocked(dateTimeUtils.setTz).mockImplementation((dt) => dt); + vi.mocked(dateTimeUtils.getNow).mockReturnValue('2024-06-15T21:00:00Z'); + }); + + it('returns an object when given valid time groups', () => { + const timeGroups = { hour: '9', minute: '00', ampm: 'PM' }; + const result = buildTimePlaceholders(timeGroups, {}, 'US/Eastern', '', 180); + expect(result).toBeDefined(); + expect(typeof result).toBe('object'); + }); + + it('returns starttime key when time groups have hour and minute', () => { + const timeGroups = { hour: '9', minute: '00', ampm: 'PM' }; + const result = buildTimePlaceholders(timeGroups, {}, 'US/Eastern', '', 180); + expect(result).toHaveProperty('starttime'); + }); + + it('returns endtime key calculated from duration', () => { + const timeGroups = { hour: '9', minute: '00', ampm: 'PM' }; + const result = buildTimePlaceholders(timeGroups, {}, 'US/Eastern', '', 180); + expect(result).toHaveProperty('endtime'); + }); + + it('returns empty object when timeGroups has no hour', () => { + const result = buildTimePlaceholders({}, {}, 'US/Eastern', '', 180); + expect(Object.keys(result).length).toBe(0); + }); + + it('returns empty object when timeGroups is null', () => { + const result = buildTimePlaceholders(null, {}, 'US/Eastern', '', 180); + expect(Object.keys(result).length).toBe(0); + }); + + it('incorporates date groups when provided', () => { + const timeGroups = { hour: '9', minute: '00' }; + const dateGroups = { year: '2024', month: '06', day: '15' }; + const result = buildTimePlaceholders(timeGroups, dateGroups, 'US/Eastern', '', 180); + expect(result).toBeDefined(); + }); + }); +}); \ No newline at end of file