mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 17:16:26 +00:00
Added utils tests
This commit is contained in:
parent
30b97c4fc5
commit
8f8a7e316e
7 changed files with 2585 additions and 4 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
230
frontend/src/utils/forms/__tests__/AccountInfoModalUtils.test.js
Normal file
230
frontend/src/utils/forms/__tests__/AccountInfoModalUtils.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
554
frontend/src/utils/forms/__tests__/ChannelBatchUtils.test.js
Normal file
554
frontend/src/utils/forms/__tests__/ChannelBatchUtils.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
394
frontend/src/utils/forms/__tests__/ChannelUtils.test.js
Normal file
394
frontend/src/utils/forms/__tests__/ChannelUtils.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
330
frontend/src/utils/forms/__tests__/ConnectionUtils.test.js
Normal file
330
frontend/src/utils/forms/__tests__/ConnectionUtils.test.js
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
349
frontend/src/utils/forms/__tests__/CronBuilderUtils.test.js
Normal file
349
frontend/src/utils/forms/__tests__/CronBuilderUtils.test.js
Normal 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 * * * *');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
562
frontend/src/utils/forms/__tests__/DummyEpgUtils.test.js
Normal file
562
frontend/src/utils/forms/__tests__/DummyEpgUtils.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue