Added utils tests

This commit is contained in:
Nick Sandstrom 2026-04-08 23:13:55 -07:00
parent 30b97c4fc5
commit 8f8a7e316e
7 changed files with 2585 additions and 4 deletions

View file

@ -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);
});
});
});
});

View file

@ -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);
});
});
});

View file

@ -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();
});
});
});

View file

@ -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');
});
});
});

View file

@ -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' });
});
});
});

View file

@ -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 * * * *');
});
});
});
});

View file

@ -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: '(?<title>.+)',
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();
});
});
});