Merge pull request #1274 from nick4810:tests/frontend-unit-tests

Tests/frontend unit tests
This commit is contained in:
SergeantPanda 2026-05-29 09:35:00 -05:00 committed by GitHub
commit e04afff04a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 10958 additions and 1618 deletions

View file

@ -1,86 +1,40 @@
import React, { useEffect, useMemo, useState } from 'react';
import dayjs from 'dayjs';
import API from '../../api';
import {
Alert,
Button,
Group,
Loader,
Modal,
MultiSelect,
SegmentedControl,
Select,
Stack,
SegmentedControl,
MultiSelect,
Group,
TextInput,
Loader,
} from '@mantine/core';
import { DateTimePicker, TimeInput, DatePickerInput } from '@mantine/dates';
import { DatePickerInput, DateTimePicker, TimeInput } from '@mantine/dates';
import { CircleAlert } from 'lucide-react';
import { isNotEmpty, useForm } from '@mantine/form';
import { useForm } from '@mantine/form';
import useChannelsStore from '../../store/channels';
import { notifications } from '@mantine/notifications';
const DAY_OPTIONS = [
{ value: '6', label: 'Sun' },
{ value: '0', label: 'Mon' },
{ value: '1', label: 'Tue' },
{ value: '2', label: 'Wed' },
{ value: '3', label: 'Thu' },
{ value: '4', label: 'Fri' },
{ value: '5', label: 'Sat' },
];
const asDate = (value) => {
if (!value) return null;
if (value instanceof Date) return value;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const toIsoIfDate = (value) => {
const dt = asDate(value);
return dt ? dt.toISOString() : value;
};
// Accepts "h:mm A"/"hh:mm A"/"HH:mm"/Date, returns "HH:mm"
const toTimeString = (value) => {
if (!value) return '00:00';
if (typeof value === 'string') {
const parsed = dayjs(
value,
['HH:mm', 'hh:mm A', 'h:mm A', 'HH:mm:ss'],
true
);
if (parsed.isValid()) return parsed.format('HH:mm');
return value;
}
const dt = asDate(value);
if (!dt) return '00:00';
return dayjs(dt).format('HH:mm');
};
const toDateString = (value) => {
const dt = asDate(value);
if (!dt) return null;
const year = dt.getFullYear();
const month = String(dt.getMonth() + 1).padStart(2, '0');
const day = String(dt.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const createRoundedDate = (minutesAhead = 0) => {
const dt = new Date();
dt.setSeconds(0);
dt.setMilliseconds(0);
dt.setMinutes(Math.ceil(dt.getMinutes() / 30) * 30);
if (minutesAhead) dt.setMinutes(dt.getMinutes() + minutesAhead);
return dt;
};
// robust onChange for TimeInput (string or event)
const timeChange = (setter) => (valOrEvent) => {
if (typeof valOrEvent === 'string') setter(valOrEvent);
else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value);
};
import {
RECURRING_DAY_OPTIONS,
toTimeString,
} from '../../utils/dateTimeUtils.js';
import { showNotification } from '../../utils/notificationUtils.js';
import {
buildRecurringPayload,
buildSinglePayload,
createRecording,
createRecurringRule,
getChannelsSummary,
getRecurringFormDefaults,
getSingleFormDefaults,
numberedChannelLabel,
recurringFormValidators,
singleFormValidators,
sortedChannelOptions,
timeChange,
updateRecording,
} from '../../utils/forms/RecordingUtils.js';
const RecordingModal = ({
recording = null,
@ -98,117 +52,29 @@ const RecordingModal = ({
const [mode, setMode] = useState('single');
const [submitting, setSubmitting] = useState(false);
const defaultStart = createRoundedDate();
const defaultEnd = createRoundedDate(60);
const defaultDate = new Date();
// One-time form
const singleForm = useForm({
mode: 'controlled',
initialValues: {
channel_id: recording
? `${recording.channel}`
: channel
? `${channel.id}`
: '',
start_time: recording
? asDate(recording.start_time) || defaultStart
: defaultStart,
end_time: recording
? asDate(recording.end_time) || defaultEnd
: defaultEnd,
},
validate: {
channel_id: isNotEmpty('Select a channel'),
start_time: isNotEmpty('Select a start time'),
end_time: (value, values) => {
const start = asDate(values.start_time);
const end = asDate(value);
if (!end) return 'Select an end time';
if (start && end <= start) return 'End time must be after start time';
return null;
},
},
initialValues: getSingleFormDefaults(recording, channel),
validate: singleFormValidators,
});
// Recurring form stores times as "HH:mm" strings for stable editing
const recurringForm = useForm({
mode: 'controlled',
validateInputOnChange: false,
validateInputOnBlur: true,
initialValues: {
channel_id: channel ? `${channel.id}` : '',
days_of_week: [],
start_time: dayjs(defaultStart).format('HH:mm'),
end_time: dayjs(defaultEnd).format('HH:mm'),
rule_name: '',
start_date: defaultDate,
end_date: defaultDate,
},
validate: {
channel_id: isNotEmpty('Select a channel'),
days_of_week: (value) =>
value && value.length ? null : 'Pick at least one day',
start_time: (value) => (value ? null : 'Select a start time'),
end_time: (value, values) => {
if (!value) return 'Select an end time';
const start = dayjs(
values.start_time,
['HH:mm', 'hh:mm A', 'h:mm A'],
true
);
const end = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true);
if (
start.isValid() &&
end.isValid() &&
end.diff(start, 'minute') === 0
) {
return 'End time must differ from start time';
}
return null;
},
end_date: (value, values) => {
const end = asDate(value);
const start = asDate(values.start_date);
if (!end) return 'Select an end date';
if (start && end < start) return 'End date cannot be before start date';
return null;
},
},
initialValues: getRecurringFormDefaults(channel),
validate: recurringFormValidators,
});
useEffect(() => {
if (!isOpen) return;
const freshStart = createRoundedDate();
const freshEnd = createRoundedDate(60);
const freshDate = new Date();
if (recording && recording.id) {
if (recording?.id) {
setMode('single');
singleForm.setValues({
channel_id: `${recording.channel}`,
start_time: asDate(recording.start_time) || defaultStart,
end_time: asDate(recording.end_time) || defaultEnd,
});
singleForm.setValues(getSingleFormDefaults(recording, channel));
} else {
// Reset forms for fresh open
singleForm.setValues({
channel_id: channel ? `${channel.id}` : '',
start_time: freshStart,
end_time: freshEnd,
});
const startStr = dayjs(freshStart).format('HH:mm');
recurringForm.setValues({
channel_id: channel ? `${channel.id}` : '',
days_of_week: [],
start_time: startStr,
end_time: dayjs(freshEnd).format('HH:mm'),
rule_name: channel?.name || '',
start_date: freshDate,
end_date: freshDate,
});
singleForm.setValues(getSingleFormDefaults(null, channel));
recurringForm.setValues(getRecurringFormDefaults(channel));
setMode('single');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -221,7 +87,7 @@ const RecordingModal = ({
if (!isOpen) return;
try {
setIsChannelsLoading(true);
const chans = await API.getChannelsSummary();
const chans = await getChannelsSummary();
if (cancelled) return;
setAllChannels(Array.isArray(chans) ? chans : []);
} catch (e) {
@ -238,19 +104,7 @@ const RecordingModal = ({
}, [isOpen]);
const channelOptions = useMemo(() => {
const list = Array.isArray(allChannels) ? [...allChannels] : [];
list.sort((a, b) => {
const aNum = Number(a.channel_number) || 0;
const bNum = Number(b.channel_number) || 0;
if (aNum === bNum) return (a.name || '').localeCompare(b.name || '');
return aNum - bNum;
});
return list.map((item) => ({
value: `${item.id}`,
label: item.channel_number
? `${item.channel_number} - ${item.name || `Channel ${item.id}`}`
: item.name || `Channel ${item.id}`,
}));
return sortedChannelOptions(allChannels, numberedChannelLabel);
}, [allChannels]);
const resetForms = () => {
@ -267,25 +121,18 @@ const RecordingModal = ({
const handleSingleSubmit = async (values) => {
try {
setSubmitting(true);
const payload = buildSinglePayload(values);
if (recording && recording.id) {
await API.updateRecording(recording.id, {
channel: values.channel_id,
start_time: toIsoIfDate(values.start_time),
end_time: toIsoIfDate(values.end_time),
});
notifications.show({
await updateRecording(recording.id, payload);
showNotification({
title: 'Recording updated',
message: 'Recording schedule updated successfully',
color: 'green',
autoClose: 2500,
});
} else {
await API.createRecording({
channel: values.channel_id,
start_time: toIsoIfDate(values.start_time),
end_time: toIsoIfDate(values.end_time),
});
notifications.show({
await createRecording(payload);
showNotification({
title: 'Recording scheduled',
message: 'One-time recording added to DVR queue',
color: 'green',
@ -304,18 +151,10 @@ const RecordingModal = ({
const handleRecurringSubmit = async (values) => {
try {
setSubmitting(true);
await API.createRecurringRule({
channel: values.channel_id,
days_of_week: (values.days_of_week || []).map((d) => Number(d)),
start_time: toTimeString(values.start_time),
end_time: toTimeString(values.end_time),
start_date: toDateString(values.start_date),
end_date: toDateString(values.end_date),
name: values.rule_name?.trim() || '',
});
await createRecurringRule(buildRecurringPayload(values));
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
notifications.show({
showNotification({
title: 'Recurring rule saved',
message: 'Future slots will be scheduled automatically',
color: 'green',
@ -427,7 +266,10 @@ const RecordingModal = ({
key={recurringForm.key('days_of_week')}
label="Every"
placeholder="Select days"
data={DAY_OPTIONS}
data={RECURRING_DAY_OPTIONS.map((opt) => ({
value: String(opt.value),
label: opt.label,
}))}
searchable
clearable
nothingFoundMessage="No match"

View file

@ -1,11 +1,13 @@
import useChannelsStore from '../../store/channels.jsx';
import API from '../../api';
import {
format,
isAfter,
isBefore,
useDateTimeFormat,
useTimeHelpers,
} from '../../utils/dateTimeUtils.js';
import React from 'react';
import { Pencil, RefreshCcw, Check, X } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Check, Pencil, RefreshCcw, X } from 'lucide-react';
import {
ActionIcon,
Badge,
@ -21,7 +23,6 @@ import {
TextInput,
} from '@mantine/core';
import useVideoStore from '../../store/useVideoStore.jsx';
import { notifications } from '@mantine/notifications';
import defaultLogo from '../../images/logo.png';
import {
deleteRecordingById,
@ -33,10 +34,98 @@ import {
runComSkip,
} from '../../utils/cards/RecordingCardUtils.js';
import {
getChannel,
getRating,
getStatRows,
getUpcomingEpisodes,
refreshArtwork,
updateRecordingMetadata,
} from '../../utils/forms/RecordingDetailsModalUtils.js';
import { showNotification } from '../../utils/notificationUtils.js';
const EpisodeRow = ({
rec,
recording,
channel,
channelsById,
livePosterUrl,
toUserTime,
dateformat,
timeformat,
onOpenChild,
}) => {
const cp = rec.custom_properties || {};
const pr = cp.program || {};
const start = toUserTime(rec.start_time);
const end = toUserTime(rec.end_time);
const season = cp.season ?? pr?.custom_properties?.season;
const episode = cp.episode ?? pr?.custom_properties?.episode;
const onscreen =
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
const se = getSeasonLabel(season, episode, onscreen);
const posterLogoId = cp.poster_logo_id;
const purl = getPosterUrl(posterLogoId, cp, livePosterUrl);
const epChannel =
channelsById[rec.channel] ||
(rec.channel === recording?.channel ? channel : null);
const onRemove = async (e) => {
e?.stopPropagation?.();
try {
await deleteRecordingById(rec.id);
} catch (error) {
console.error('Failed to delete upcoming recording', error);
}
};
return (
<Card
withBorder
radius="md"
padding="sm"
style={{ backgroundColor: '#27272A', cursor: 'pointer' }}
onClick={() => onOpenChild(rec)}
>
<Flex gap="sm" align="center">
<Image
src={purl}
w={64}
h={64}
fit="contain"
radius="sm"
alt={pr.title}
fallbackSrc={getChannelLogoUrl(epChannel) || defaultLogo}
/>
<Stack gap={4} flex={1}>
<Group justify="space-between">
<Text
fw={600}
size="sm"
lineClamp={1}
title={pr.sub_title || pr.title}
>
{pr.sub_title || pr.title}
</Text>
{se && (
<Badge color="gray" variant="light">
{se}
</Badge>
)}
</Group>
<Text size="xs">
{format(start, `${dateformat}, YYYY ${timeformat}`)} {' '}
{format(end, timeformat)}
</Text>
</Stack>
<Group gap={6}>
<Button size="xs" color="red" variant="light" onClick={onRemove}>
Remove
</Button>
</Group>
</Flex>
</Card>
);
};
const RecordingDetailsModal = ({
opened,
@ -51,21 +140,21 @@ const RecordingDetailsModal = ({
}) => {
const allRecordings = useChannelsStore((s) => s.recordings);
// Local channel cache to avoid the global channels map
const [channelsById, setChannelsById] = React.useState({});
const [channelsById, setChannelsById] = useState({});
const { toUserTime, userNow } = useTimeHelpers();
const [childOpen, setChildOpen] = React.useState(false);
const [childRec, setChildRec] = React.useState(null);
const [childOpen, setChildOpen] = useState(false);
const [childRec, setChildRec] = useState(null);
const { timeFormat: timeformat, dateFormat: dateformat } =
useDateTimeFormat();
const [editing, setEditing] = React.useState(false);
const [editing, setEditing] = useState(false);
// Prefer the store version of the recording for live updates
// (e.g., after artwork refresh or metadata edit via WebSocket).
// Preserve _group_count from the categorized prop the store version
// doesn't carry this client-side field, so without merging it back
// isSeriesGroup would always be false and the episode list hidden.
const safeRecording = React.useMemo(() => {
const safeRecording = useMemo(() => {
if (recording?.id && Array.isArray(allRecordings)) {
const found = allRecordings.find((r) => r.id === recording.id);
if (found) {
@ -81,7 +170,7 @@ const RecordingDetailsModal = ({
const program = customProps.program || {};
// Derive poster URL from live store data instead of the stale prop snapshot.
const livePosterUrl = React.useMemo(
const livePosterUrl = useMemo(
() =>
getPosterUrl(
customProps.poster_logo_id,
@ -93,17 +182,17 @@ const RecordingDetailsModal = ({
// Optimistic overrides show saved values immediately without waiting
// for the WebSocket round-trip to refresh the store.
const [savedTitle, setSavedTitle] = React.useState(null);
const [savedDescription, setSavedDescription] = React.useState(null);
const [savedTitle, setSavedTitle] = useState(null);
const [savedDescription, setSavedDescription] = useState(null);
const recordingName = savedTitle ?? (program.title || 'Custom Recording');
const description =
savedDescription ?? (program.description || customProps.description || '');
const [editTitle, setEditTitle] = React.useState('');
const [editDescription, setEditDescription] = React.useState('');
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
// Reset optimistic state when the recording changes
React.useEffect(() => {
useEffect(() => {
setSavedTitle(null);
setSavedDescription(null);
setEditing(false);
@ -129,7 +218,7 @@ const RecordingDetailsModal = ({
const isSeriesGroup = Boolean(
safeRecording._group_count && safeRecording._group_count > 1
);
const upcomingEpisodes = React.useMemo(() => {
const upcomingEpisodes = useMemo(() => {
return getUpcomingEpisodes(
isSeriesGroup,
allRecordings,
@ -147,7 +236,7 @@ const RecordingDetailsModal = ({
]);
// Ensure channel is available for a given id
const loadChannel = React.useCallback(
const loadChannel = useCallback(
async (id) => {
if (!id) {
return null;
@ -159,7 +248,7 @@ const RecordingDetailsModal = ({
}
try {
const ch = await API.getChannel(id);
const ch = await getChannel(id);
if (ch && ch.id === id) {
setChannelsById((prev) => ({ ...prev, [id]: ch }));
return ch;
@ -177,7 +266,7 @@ const RecordingDetailsModal = ({
);
// When opening a child episode, fetch that episode's channel
React.useEffect(() => {
useEffect(() => {
if (!childOpen || !childRec) return;
loadChannel(childRec.channel);
}, [childOpen, childRec, loadChannel]);
@ -188,7 +277,7 @@ const RecordingDetailsModal = ({
const s = toUserTime(rec.start_time);
const e = toUserTime(rec.end_time);
if (now.isAfter(s) && now.isBefore(e)) {
if (isAfter(now, s) && isBefore(now, e)) {
const ch =
channelsById[rec.channel] ||
(rec.channel === recording?.channel ? channel : null);
@ -228,14 +317,11 @@ const RecordingDetailsModal = ({
const saveMetadata = async () => {
try {
await API.updateRecordingMetadata(recording.id, {
title: editTitle || 'Custom Recording',
description: editDescription,
});
await updateRecordingMetadata(recording, editTitle, editDescription);
setSavedTitle(editTitle || 'Custom Recording');
setSavedDescription(editDescription);
setEditing(false);
notifications.show({
showNotification({
title: 'Saved',
message: 'Recording metadata updated',
color: 'green',
@ -249,8 +335,8 @@ const RecordingDetailsModal = ({
const handleRefreshArtwork = async (e) => {
e.stopPropagation?.();
try {
await API.refreshArtwork(recording.id);
notifications.show({
await refreshArtwork(recording.id);
showNotification({
title: 'Refreshing artwork',
message: 'Poster resolution started',
color: 'blue.5',
@ -265,7 +351,7 @@ const RecordingDetailsModal = ({
e.stopPropagation?.();
try {
await runComSkip(recording);
notifications.show({
showNotification({
title: 'Removing commercials',
message: 'Queued comskip for this recording',
color: 'blue.5',
@ -278,86 +364,6 @@ const RecordingDetailsModal = ({
if (!recording) return null;
const EpisodeRow = ({ rec }) => {
const cp = rec.custom_properties || {};
const pr = cp.program || {};
const start = toUserTime(rec.start_time);
const end = toUserTime(rec.end_time);
const season = cp.season ?? pr?.custom_properties?.season;
const episode = cp.episode ?? pr?.custom_properties?.episode;
const onscreen =
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
const se = getSeasonLabel(season, episode, onscreen);
const posterLogoId = cp.poster_logo_id;
const purl = getPosterUrl(posterLogoId, cp, livePosterUrl);
const epChannel =
channelsById[rec.channel] ||
(rec.channel === recording?.channel ? channel : null);
const onRemove = async (e) => {
e?.stopPropagation?.();
try {
await deleteRecordingById(rec.id);
} catch (error) {
console.error('Failed to delete upcoming recording', error);
}
// recording_cancelled WS event triggers the debounced fetchRecordings()
};
const handleOnMainCardClick = () => {
setChildRec(rec);
setChildOpen(true);
};
return (
<Card
withBorder
radius="md"
padding="sm"
style={{ backgroundColor: '#27272A', cursor: 'pointer' }}
onClick={handleOnMainCardClick}
>
<Flex gap="sm" align="center">
<Image
src={purl}
w={64}
h={64}
fit="contain"
radius="sm"
alt={pr.title || recordingName}
fallbackSrc={getChannelLogoUrl(epChannel) || defaultLogo}
/>
<Stack gap={4} flex={1}>
<Group justify="space-between">
<Text
fw={600}
size="sm"
lineClamp={1}
title={pr.sub_title || pr.title}
>
{pr.sub_title || pr.title}
</Text>
{se && (
<Badge color="gray" variant="light">
{se}
</Badge>
)}
</Group>
<Text size="xs">
{start.format(`${dateformat}, YYYY ${timeformat}`)} {' '}
{end.format(timeformat)}
</Text>
</Stack>
<Group gap={6}>
<Button size="xs" color="red" variant="light" onClick={onRemove}>
Remove
</Button>
</Group>
</Flex>
</Card>
);
};
const WatchLive = () => {
return (
<Button
@ -414,7 +420,21 @@ const RecordingDetailsModal = ({
</Text>
)}
{upcomingEpisodes.map((ep) => (
<EpisodeRow key={`ep-${ep.id}`} rec={ep} />
<EpisodeRow
key={`ep-${ep.id}`}
rec={ep}
recording={recording}
channel={channel}
channelsById={channelsById}
livePosterUrl={livePosterUrl}
toUserTime={toUserTime}
dateformat={dateformat}
timeformat={timeformat}
onOpenChild={(rec) => {
setChildRec(rec);
setChildOpen(true);
}}
/>
))}
{childOpen && childRec && (
<RecordingDetailsModal
@ -468,7 +488,7 @@ const RecordingDetailsModal = ({
<Group gap={8}>
{onWatchLive && <WatchLive />}
{onWatchRecording && <WatchRecording />}
{onEdit && start.isAfter(userNow()) && <Edit />}
{onEdit && isAfter(start, userNow()) && <Edit />}
{(customProps.status === 'completed' ||
customProps.status === 'stopped' ||
customProps.status === 'interrupted') &&
@ -486,8 +506,8 @@ const RecordingDetailsModal = ({
</Group>
</Group>
<Text size="sm">
{start.format(`${dateformat}, YYYY ${timeformat}`)} {' '}
{end.format(timeformat)}
{format(start, `${dateformat}, YYYY ${timeformat}`)} {' '}
{format(end, timeformat)}
</Text>
{rating && (
<Group gap={8}>

View file

@ -1,16 +1,15 @@
import useChannelsStore from '../../store/channels.jsx';
import API from '../../api.js';
import {
parseDate,
format,
getNow,
RECURRING_DAY_OPTIONS,
toDate,
toTimeString,
useDateTimeFormat,
useTimeHelpers,
} from '../../utils/dateTimeUtils.js';
import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from '@mantine/form';
import dayjs from 'dayjs';
import { notifications } from '@mantine/notifications';
import {
Badge,
Button,
@ -28,11 +27,18 @@ import { DatePickerInput, TimeInput } from '@mantine/dates';
import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js';
import {
deleteRecurringRuleById,
getChannelOptions,
getFormDefaults,
getUpcomingOccurrences,
updateRecurringRule,
updateRecurringRuleEnabled,
} from '../../utils/forms/RecurringRuleModalUtils.js';
import { showNotification } from '../../utils/notificationUtils.js';
import {
getChannelsSummary,
getRecurringFormDefaults,
recurringFormValidators,
sortedChannelOptions,
} from '../../utils/forms/RecordingUtils.js';
const RecurringRuleModal = ({
opened,
@ -56,66 +62,18 @@ const RecurringRuleModal = ({
const rule = recurringRules.find((r) => r.id === ruleId);
const channelOptions = useMemo(() => {
return getChannelOptions(allChannels);
return sortedChannelOptions(allChannels);
}, [allChannels]);
const form = useForm({
mode: 'controlled',
initialValues: {
channel_id: '',
days_of_week: [],
rule_name: '',
start_time: dayjs().startOf('hour').format('HH:mm'),
end_time: dayjs().startOf('hour').add(1, 'hour').format('HH:mm'),
start_date: dayjs().toDate(),
end_date: dayjs().toDate(),
enabled: true,
},
validate: {
channel_id: (value) => (value ? null : 'Select a channel'),
days_of_week: (value) =>
value && value.length ? null : 'Pick at least one day',
end_time: (value, values) => {
if (!value) return 'Select an end time';
const startValue = dayjs(
values.start_time,
['HH:mm', 'hh:mm A', 'h:mm A'],
true
);
const endValue = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true);
if (
startValue.isValid() &&
endValue.isValid() &&
endValue.diff(startValue, 'minute') === 0
) {
return 'End time must differ from start time';
}
return null;
},
end_date: (value, values) => {
const endDate = dayjs(value);
const startDate = dayjs(values.start_date);
if (!value) return 'Select an end date';
if (startDate.isValid() && endDate.isBefore(startDate, 'day')) {
return 'End date cannot be before start date';
}
return null;
},
},
initialValues: { ...getRecurringFormDefaults(), enabled: true },
validate: recurringFormValidators,
});
useEffect(() => {
if (opened && rule) {
form.setValues({
channel_id: `${rule.channel}`,
days_of_week: (rule.days_of_week || []).map((d) => String(d)),
rule_name: rule.name || '',
start_time: toTimeString(rule.start_time),
end_time: toTimeString(rule.end_time),
start_date: parseDate(rule.start_date) || dayjs().toDate(),
end_date: parseDate(rule.end_date),
enabled: Boolean(rule.enabled),
});
form.setValues(getFormDefaults(rule));
} else {
form.reset();
}
@ -127,7 +85,7 @@ const RecurringRuleModal = ({
let cancelled = false;
(async () => {
try {
const chans = await API.getChannelsSummary();
const chans = await getChannelsSummary();
if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []);
} catch (e) {
console.warn('Failed to load channels for recurring rule modal', e);
@ -149,7 +107,7 @@ const RecurringRuleModal = ({
try {
await updateRecurringRule(ruleId, values);
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
notifications.show({
showNotification({
title: 'Recurring rule updated',
message: 'Schedule adjustments saved',
color: 'green',
@ -169,7 +127,7 @@ const RecurringRuleModal = ({
try {
await deleteRecurringRuleById(ruleId);
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
notifications.show({
showNotification({
title: 'Recurring rule removed',
message: 'All future occurrences were cancelled',
color: 'red',
@ -189,7 +147,7 @@ const RecurringRuleModal = ({
try {
await updateRecurringRuleEnabled(ruleId, checked);
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
notifications.show({
showNotification({
title: checked ? 'Recurring rule enabled' : 'Recurring rule paused',
message: checked
? 'Future occurrences will resume'
@ -210,7 +168,7 @@ const RecurringRuleModal = ({
try {
await deleteRecordingById(occurrence.id);
// recording_cancelled WS event handles recording list update
notifications.show({
showNotification({
title: 'Occurrence cancelled',
message: 'The selected airing was removed',
color: 'yellow',
@ -246,7 +204,7 @@ const RecurringRuleModal = ({
setDeleting(true);
try {
await deleteRecordingById(sourceRecording.id);
notifications.show({
showNotification({
title: 'Recording deleted',
color: 'green',
autoClose: 2500,
@ -275,7 +233,7 @@ const RecurringRuleModal = ({
};
const handleStartDateChange = (value) => {
form.setFieldValue('start_date', value || dayjs().toDate());
form.setFieldValue('start_date', value || toDate(getNow()));
};
const handleEndDateChange = (value) => {
@ -302,10 +260,11 @@ const RecurringRuleModal = ({
<Group justify="space-between" align="center">
<Stack gap={2} flex={1}>
<Text fw={600} size="sm">
{occStart.format(`${dateformat}, YYYY`)}
{format(occStart, `${dateformat}, YYYY`)}
</Text>
<Text size="xs" c="dimmed">
{occStart.format(timeformat)} {occEnd.format(timeformat)}
{format(occStart, timeformat)} {' '}
{format(occEnd, timeformat)}
</Text>
</Stack>
<Group gap={6}>

View file

@ -38,8 +38,8 @@ import {
Popover,
ActionIcon,
Group,
Divider,
SimpleGrid,
PopoverTarget,
PopoverDropdown,
} from '@mantine/core';
import { Info } from 'lucide-react';
import { validateCronExpression } from '../../utils/cronUtils';
@ -118,12 +118,12 @@ export default function ScheduleInput({
<Group gap="xs">
Cron Expression
<Popover width={320} position="top" withArrow shadow="md">
<Popover.Target>
<PopoverTarget>
<ActionIcon variant="subtle" size="xs" color="gray">
<Info size={14} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown p="sm">
</PopoverTarget>
<PopoverDropdown p="sm">
<Text size="xs" fw={600} mb="xs" c="dimmed">
COMMON EXAMPLES
</Text>
@ -169,7 +169,7 @@ export default function ScheduleInput({
<Code size="xs">30 14 1 * *</Code>
</Group>
</Stack>
</Popover.Dropdown>
</PopoverDropdown>
</Popover>
</Group>
}

View file

@ -16,6 +16,19 @@ const TITLE_MODE_LABEL = {
regex: 'Title regex',
};
const renderRuleSummary = (r) => {
const titleMode = (r.title_mode || 'exact').toLowerCase();
const parts = [];
parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode');
if (r.title) {
parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`);
}
if (r.description) {
parts.push(`Description: "${r.description}"`);
}
return parts.join(' | ');
};
export default function SeriesRecordingModal({
opened,
onClose,
@ -59,19 +72,6 @@ export default function SeriesRecordingModal({
onRulesUpdate(updated);
};
const renderRuleSummary = (r) => {
const titleMode = (r.title_mode || 'exact').toLowerCase();
const parts = [];
parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode');
if (r.title) {
parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`);
}
if (r.description) {
parts.push(`Description: "${r.description}"`);
}
return parts.join(' | ');
};
return (
<>
<Modal

View file

@ -1,64 +1,37 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Badge,
Button,
Divider,
Group,
Modal,
ScrollAreaAutosize,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
Textarea,
SegmentedControl,
Group,
Button,
Select,
Badge,
Divider,
ScrollArea,
Alert,
TextInput,
} from '@mantine/core';
import API from '../../api.js';
import useChannelsStore from '../../store/channels.jsx';
import useEPGsStore from '../../store/epgs.jsx';
import { useDebounce } from '../../utils.js';
import { showNotification } from '../../utils/notificationUtils.js';
const TITLE_MODES = [
{ label: 'Exact', value: 'exact' },
{ label: 'Contains', value: 'contains' },
{ label: 'Whole word', value: 'search' },
{ label: 'Regex', value: 'regex' },
];
const DESCRIPTION_MODES = [
{ label: 'Contains', value: 'contains' },
{ label: 'Whole word', value: 'search' },
{ label: 'Regex', value: 'regex' },
];
const EPISODE_MODES = [
{ label: 'All episodes', value: 'all' },
{ label: 'New only', value: 'new' },
];
function formatRange(start, end) {
try {
const s = new Date(start);
const e = new Date(end);
const sameDay = s.toDateString() === e.toDateString();
const dateStr = s.toLocaleDateString();
const startStr = s.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
const endStr = e.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
return sameDay
? `${dateStr} ${startStr} - ${endStr}`
: `${dateStr} ${startStr} -> ${e.toLocaleString()}`;
} catch {
return `${start} - ${end}`;
}
}
import { getChannelsSummary } from '../../utils/forms/RecordingUtils.js';
import {
createSeriesRule,
evaluateSeriesRulesByTvgId,
} from '../../utils/guideUtils.js';
import {
DESCRIPTION_MODES,
EPISODE_MODES,
formatRange,
getChannelOptions,
getTvgOptions,
previewSeriesRule,
TITLE_MODES,
} from '../../utils/forms/SeriesRuleEditorModalUtils.js';
export default function SeriesRuleEditorModal({
opened,
@ -106,7 +79,7 @@ export default function SeriesRuleEditorModal({
useEffect(() => {
if (!opened) return;
let cancelled = false;
API.getChannelsSummary()
getChannelsSummary()
.then((chans) => {
if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []);
})
@ -152,7 +125,7 @@ export default function SeriesRuleEditorModal({
setPreviewLoading(true);
setPreviewError(null);
API.previewSeriesRule(debouncedPreviewKey, { signal: controller.signal })
previewSeriesRule(debouncedPreviewKey, controller)
.then((resp) => {
if (controller.signal.aborted) return;
setPreview(resp || { matches: [], total: 0 });
@ -173,44 +146,11 @@ export default function SeriesRuleEditorModal({
// EPG channel options for the tvg_id selector. Deduplicate by tvg_id value
// since the same channel can appear across multiple EPG sources.
const tvgOptions = useMemo(() => {
const seen = new Set();
const options = [];
for (const t of tvgs || []) {
if (!t.tvg_id || seen.has(t.tvg_id)) continue;
seen.add(t.tvg_id);
options.push({
value: t.tvg_id,
label: t.name ? `${t.name} (${t.tvg_id})` : t.tvg_id,
});
}
return options.sort((a, b) => a.label.localeCompare(b.label));
return getTvgOptions(tvgs);
}, [tvgs]);
// Channel select options: prefer channels matching the selected tvg_id.
const channelOptions = useMemo(() => {
const sorted = [...allChannels].sort((a, b) => {
const aNum = Number(a.channel_number) || 0;
const bNum = Number(b.channel_number) || 0;
if (aNum !== bNum) return aNum - bNum;
return (a.name || '').localeCompare(b.name || '');
});
const matching = [];
const others = [];
for (const c of sorted) {
const item = {
value: String(c.id),
label: c.channel_number
? `${c.channel_number} - ${c.name || `Channel ${c.id}`}`
: c.name || `Channel ${c.id}`,
};
const cTvg = c.epg_data_id ? tvgsById?.[c.epg_data_id]?.tvg_id : null;
if (tvgId && cTvg && String(cTvg) === String(tvgId)) {
matching.push(item);
} else {
others.push(item);
}
}
return [...matching, ...others];
return getChannelOptions(allChannels, tvgsById, tvgId);
}, [allChannels, tvgsById, tvgId]);
const canSave = !!(payload.title || payload.description);
@ -218,10 +158,10 @@ export default function SeriesRuleEditorModal({
const handleSave = async () => {
setSaving(true);
try {
await API.createSeriesRule(payload);
await createSeriesRule(payload);
// Trigger evaluation so matching upcoming programs get scheduled.
try {
await API.evaluateSeriesRules(payload.tvg_id);
await evaluateSeriesRulesByTvgId(payload.tvg_id);
await useChannelsStore.getState().fetchRecordings();
} catch (e) {
console.warn('Failed to evaluate after save', e);
@ -229,6 +169,8 @@ export default function SeriesRuleEditorModal({
showNotification({ title: 'Series rule saved' });
if (onSaved) await onSaved();
onClose();
} catch (e) {
console.error('Failed to save series rule', e);
} finally {
setSaving(false);
}
@ -370,7 +312,7 @@ export default function SeriesRuleEditorModal({
</Alert>
)}
<ScrollArea.Autosize mah={240}>
<ScrollAreaAutosize mah={240}>
<Stack gap={4}>
{(preview.matches || []).map((p) => (
<Group key={p.id} gap="xs" wrap="nowrap" align="flex-start">
@ -407,7 +349,7 @@ export default function SeriesRuleEditorModal({
</Text>
)}
</Stack>
</ScrollArea.Autosize>
</ScrollAreaAutosize>
<Group justify="flex-end" gap="xs">
<Button variant="subtle" onClick={onClose}>

View file

@ -1,17 +1,14 @@
// Modal.js
import React, { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
import { Modal, TextInput, Select, Button, Flex } from '@mantine/core';
import { Button, Flex, Modal, Select, TextInput } from '@mantine/core';
import useChannelsStore from '../../store/channels';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
url: Yup.string().required('URL is required').min(0),
});
import {
addStream,
getResolver,
updateStream,
} from '../../utils/forms/StreamUtils.js';
const Stream = ({ stream = null, isOpen, onClose }) => {
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
@ -40,7 +37,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
watch,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
resolver: getResolver(),
});
const onSubmit = async (values) => {
@ -58,9 +55,9 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
};
if (stream?.id) {
await API.updateStream({ id: stream.id, ...payload });
await updateStream(stream.id, payload);
} else {
await API.addStream(payload);
await addStream(payload);
}
reset();

View file

@ -1,50 +1,25 @@
// StreamProfile form
import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
import useUserAgentsStore from '../../store/userAgents';
import {
Modal,
TextInput,
Textarea,
Select,
Button,
Flex,
Stack,
Checkbox,
Flex,
Modal,
Select,
Stack,
Textarea,
TextInput,
} from '@mantine/core';
// Built-in commands supported by Dispatcharr out of the box.
const BUILT_IN_COMMANDS = [
{ value: 'ffmpeg', label: 'FFmpeg' },
{ value: 'streamlink', label: 'Streamlink' },
{ value: 'cvlc', label: 'VLC' },
{ value: 'yt-dlp', label: 'yt-dlp' },
{ value: '__custom__', label: 'Custom…' },
];
// Default parameter examples for each built-in command.
const COMMAND_EXAMPLES = {
ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1',
streamlink: '{streamUrl} --http-header User-Agent={userAgent} best --stdout',
cvlc: '-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}',
'yt-dlp': '--hls-use-mpegts -f best -o - {streamUrl}',
};
// Returns '__custom__' when the command isn't one of the built-ins,
// otherwise returns the command value itself.
const toCommandSelection = (command) =>
BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__')
? command
: '__custom__';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
command: Yup.string().required('Command is required'),
parameters: Yup.string(),
});
import {
addStreamProfile,
BUILT_IN_COMMANDS,
COMMAND_EXAMPLES,
getResolver,
toCommandSelection,
updateStreamProfile,
} from '../../utils/forms/StreamProfileUtils.js';
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore((state) => state.userAgents);
@ -73,7 +48,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
watch,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
resolver: getResolver(),
});
// Sync form + dropdown selection whenever the target profile or modal state changes
@ -84,9 +59,9 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const onSubmit = async (values) => {
if (profile?.id) {
await API.updateStreamProfile({ id: profile.id, ...values });
await updateStreamProfile(profile.id, values);
} else {
await API.addStreamProfile(values);
await addStreamProfile(values);
}
reset();
@ -102,6 +77,17 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const userAgentValue = watch('user_agent');
const isActiveValue = watch('is_active');
const handleOnChangeCommand = (val) => {
setCommandSelection(val);
// For built-in selections, write the real command value immediately
if (val !== '__custom__') {
setValue('command', val, { shouldValidate: true });
} else {
// Clear so the user enters their own value
setValue('command', '', { shouldValidate: false });
}
};
return (
<Modal opened={isOpen} onClose={onClose} title="Stream Profile">
<form onSubmit={handleSubmit(onSubmit)}>
@ -127,16 +113,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
data={BUILT_IN_COMMANDS}
disabled={isLocked}
value={commandSelection}
onChange={(val) => {
setCommandSelection(val);
// For built-in selections, write the real command value immediately
if (val !== '__custom__') {
setValue('command', val, { shouldValidate: true });
} else {
// Clear so the user enters their own value
setValue('command', '', { shouldValidate: false });
}
}}
onChange={handleOnChangeCommand}
error={isCustom ? undefined : errors.command?.message}
/>

View file

@ -16,13 +16,21 @@ import useAuthStore from '../../store/auth';
import useSettingsStore from '../../store/settings';
import logo from '../../assets/logo.png';
const createSuperUser = (formData) => {
return API.createSuperUser({
username: formData.username,
password: formData.password,
email: formData.email,
});
};
function SuperuserForm() {
const [formData, setFormData] = useState({
username: '',
password: '',
email: '',
});
const [error, setError] = useState('');
const [_error, setError] = useState('');
const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists);
const fetchVersion = useSettingsStore((s) => s.fetchVersion);
const storedVersion = useSettingsStore((s) => s.version);
@ -43,11 +51,7 @@ function SuperuserForm() {
e.preventDefault();
try {
console.log(formData);
const response = await API.createSuperUser({
username: formData.username,
password: formData.password,
email: formData.email,
});
const response = await createSuperUser(formData);
if (response.superuser_exists) {
setSuperuserExists(true);
}

View file

@ -1,43 +1,41 @@
import React, { useState, useEffect } from 'react';
import API from '../../api';
import React, { useEffect, useState } from 'react';
import {
TextInput,
Button,
Modal,
Select,
PasswordInput,
Group,
Stack,
MultiSelect,
ActionIcon,
Switch,
Button,
Group,
Modal,
MultiSelect,
NumberInput,
PasswordInput,
Select,
Stack,
Switch,
Tabs,
TabsList,
TabsPanel,
TabsTab,
TagsInput,
Text,
TextInput,
useMantineTheme,
} from '@mantine/core';
import { RotateCcwKey, X } from 'lucide-react';
import { Copy, Key } from 'lucide-react';
import { Copy, Key, RotateCcwKey, X } from 'lucide-react';
import { useForm } from '@mantine/form';
import useChannelsStore from '../../store/channels';
import useOutputProfilesStore from '../../store/outputProfiles';
import {
USER_LEVELS,
USER_LEVEL_LABELS,
NETWORK_ACCESS_OPTIONS,
} from '../../constants';
import { USER_LEVEL_LABELS, USER_LEVELS } from '../../constants';
import useAuthStore from '../../store/auth';
import { copyToClipboard } from '../../utils';
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../utils/networkUtils';
const isValidNetworkEntry = (entry) =>
entry.match(IPV4_CIDR_REGEX) ||
entry.match(IPV6_CIDR_REGEX) ||
(entry + '/32').match(IPV4_CIDR_REGEX) ||
(entry + '/128').match(IPV6_CIDR_REGEX);
const NETWORK_KEYS = Object.keys(NETWORK_ACCESS_OPTIONS);
import {
createUser,
formValuesToPayload,
generateApiKey,
getFormInitialValues,
getFormValidators,
revokeApiKey,
updateUser,
userToFormValues,
} from '../../utils/forms/UserUtils.js';
const User = ({ user = null, isOpen, onClose }) => {
const profiles = useChannelsStore((s) => s.profiles);
@ -48,52 +46,15 @@ const User = ({ user = null, isOpen, onClose }) => {
const [, setEnableXC] = useState(false);
const [selectedProfiles, setSelectedProfiles] = useState(new Set());
const [generating, setGenerating] = useState(false);
const [generatedKey, setGeneratedKey] = useState(null);
const [_generatedKey, setGeneratedKey] = useState(null);
const [userAPIKey, setUserAPIKey] = useState(user?.api_key || null);
const theme = useMantineTheme();
const form = useForm({
mode: 'uncontrolled',
initialValues: {
username: '',
first_name: '',
last_name: '',
email: '',
user_level: '0',
stream_limit: 0,
password: '',
xc_password: '',
output_format: '',
output_profile: '',
channel_profiles: [],
hide_adult_content: false,
epg_days: 0,
epg_prev_days: 0,
allowed_ips: [],
},
validate: (values) => ({
username: !values.username
? 'Username is required'
: values.user_level == USER_LEVELS.STREAMER &&
!values.username.match(/^[a-z0-9]+$/i)
? 'Streamer username must be alphanumeric'
: null,
password:
!user && !values.password && values.user_level != USER_LEVELS.STREAMER
? 'Password is required'
: null,
xc_password:
values.xc_password && !values.xc_password.match(/^[a-z0-9]+$/i)
? 'XC password must be alphanumeric'
: null,
allowed_ips: (values.allowed_ips || []).some(
(t) => !isValidNetworkEntry(t)
)
? 'Invalid IP address or CIDR range'
: null,
}),
initialValues: getFormInitialValues(),
validate: getFormValidators(user),
});
const onChannelProfilesChange = (values) => {
@ -110,65 +71,18 @@ const User = ({ user = null, isOpen, onClose }) => {
};
const onSubmit = async () => {
const values = form.getValues();
const payload = formValuesToPayload(form.getValues(), user);
const customProps = user?.custom_properties || {};
customProps.xc_password = values.xc_password || '';
delete values.xc_password;
customProps.output_format = values.output_format || null;
delete values.output_format;
customProps.output_profile = values.output_profile
? parseInt(values.output_profile, 10)
: null;
delete values.output_profile;
customProps.hide_adult_content = values.hide_adult_content || false;
delete values.hide_adult_content;
customProps.epg_days = values.epg_days || 0;
delete values.epg_days;
customProps.epg_prev_days = values.epg_prev_days || 0;
delete values.epg_prev_days;
values.custom_properties = customProps;
// Serialize per-user network restrictions into custom_properties (same list for all types)
const joined = (values.allowed_ips || []).join(',');
delete values.allowed_ips;
const allowed_networks = {};
if (joined)
NETWORK_KEYS.forEach((key) => {
allowed_networks[key] = joined;
});
customProps.allowed_networks = allowed_networks;
if (values.channel_profiles.includes('0')) {
values.channel_profiles = [];
}
if (!user && values.user_level == USER_LEVELS.STREAMER) {
values.password = Math.random().toString(36).slice(2);
if (!user && payload.user_level == USER_LEVELS.STREAMER) {
payload.password = Math.random().toString(36).slice(2);
}
if (!user) {
await API.createUser(values);
await createUser(payload);
} else {
if (!values.password) {
delete values.password;
}
const response = await API.updateUser(
user.id,
values,
isAdmin ? false : authUser.id === user.id
);
if (user.id == authUser.id) {
setUser(response);
}
if (!payload.password) delete payload.password;
const response = await updateUser(user.id, payload, isAdmin, authUser);
if (user.id == authUser.id) setUser(response);
}
form.reset();
@ -178,38 +92,9 @@ const User = ({ user = null, isOpen, onClose }) => {
useEffect(() => {
if (user?.id) {
const customProps = user.custom_properties || {};
const networks = customProps.allowed_networks || {};
form.setValues(userToFormValues(user));
form.setValues({
username: user.username,
first_name: user.first_name || '',
last_name: user.last_name || '',
email: user.email,
user_level: `${user.user_level}`,
stream_limit: user.stream_limit || 0,
channel_profiles:
user.channel_profiles.length > 0
? user.channel_profiles.map((id) => `${id}`)
: ['0'],
xc_password: customProps.xc_password || '',
output_format: customProps.output_format || '',
output_profile: customProps.output_profile
? `${customProps.output_profile}`
: '',
hide_adult_content: customProps.hide_adult_content || false,
epg_days: customProps.epg_days || 0,
epg_prev_days: customProps.epg_prev_days || 0,
allowed_ips: [
...new Set(
NETWORK_KEYS.flatMap((key) =>
networks[key] ? networks[key].split(',').filter(Boolean) : []
)
),
],
});
if (customProps.xc_password) {
if (user.custom_properties?.xc_password) {
setEnableXC(true);
}
@ -248,13 +133,13 @@ const User = ({ user = null, isOpen, onClose }) => {
payload.user_id = user.id;
}
const resp = await API.generateApiKey(payload);
const resp = await generateApiKey(payload);
const newKey = resp && (resp.key || resp.raw_key);
if (newKey) {
setGeneratedKey(newKey);
setUserAPIKey(newKey);
}
} catch (e) {
} catch {
// API shows notifications
} finally {
setGenerating(false);
@ -271,7 +156,8 @@ const User = ({ user = null, isOpen, onClose }) => {
payload.user_id = user.id;
}
const resp = await API.revokeApiKey(payload);
const resp = await revokeApiKey(payload);
// backend returns { success: true } - clear local state
if (resp && resp.success) {
setGeneratedKey(null);
setUserAPIKey(null);
@ -280,7 +166,7 @@ const User = ({ user = null, isOpen, onClose }) => {
setUser({ ...authUser, api_key: null });
}
}
} catch (e) {
} catch {
// API shows notifications
} finally {
setGenerating(false);
@ -291,16 +177,16 @@ const User = ({ user = null, isOpen, onClose }) => {
<Modal opened={isOpen} onClose={onClose} title="User" size="xl">
<form onSubmit={form.onSubmit(onSubmit)}>
<Tabs defaultValue="account">
<Tabs.List mb="md">
<Tabs.Tab value="account">Account</Tabs.Tab>
<TabsList mb="md">
<TabsTab value="account">Account</TabsTab>
{showPermissions && (
<Tabs.Tab value="permissions">Permissions</Tabs.Tab>
<TabsTab value="permissions">Permissions</TabsTab>
)}
<Tabs.Tab value="epg">EPG Defaults</Tabs.Tab>
<Tabs.Tab value="api">API &amp; XC</Tabs.Tab>
</Tabs.List>
<TabsTab value="epg">EPG Defaults</TabsTab>
<TabsTab value="api">API &amp; XC</TabsTab>
</TabsList>
<Tabs.Panel value="account">
<TabsPanel value="account">
<Stack gap="sm">
<Group grow align="flex-start">
<TextInput
@ -335,10 +221,10 @@ const User = ({ user = null, isOpen, onClose }) => {
disabled={form.getValues().user_level == USER_LEVELS.STREAMER}
/>
</Stack>
</Tabs.Panel>
</TabsPanel>
{showPermissions && (
<Tabs.Panel value="permissions">
<TabsPanel value="permissions">
<Stack gap="sm">
<Group grow align="flex-start">
<Select
@ -375,10 +261,10 @@ const User = ({ user = null, isOpen, onClose }) => {
key={form.key('hide_adult_content')}
/>
</Stack>
</Tabs.Panel>
</TabsPanel>
)}
<Tabs.Panel value="epg">
<TabsPanel value="epg">
<Stack gap="sm">
<Text size="sm" c="dimmed">
These defaults apply when no URL parameters are specified and
@ -404,9 +290,9 @@ const User = ({ user = null, isOpen, onClose }) => {
/>
</Group>
</Stack>
</Tabs.Panel>
</TabsPanel>
<Tabs.Panel value="api">
<TabsPanel value="api">
<Stack gap="sm">
<TextInput
label="XC Password"
@ -524,7 +410,7 @@ const User = ({ user = null, isOpen, onClose }) => {
</Stack>
)}
</Stack>
</Tabs.Panel>
</TabsPanel>
</Tabs>
<Group justify="flex-end" mt="md">

View file

@ -1,26 +1,12 @@
// Modal.js
import React, { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import API from '../../api';
import { Button, Checkbox, Flex, Modal, Space, TextInput } from '@mantine/core';
import {
LoadingOverlay,
TextInput,
Button,
Checkbox,
Modal,
Flex,
NativeSelect,
FileInput,
Space,
} from '@mantine/core';
import { NETWORK_ACCESS_OPTIONS } from '../../constants';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
user_agent: Yup.string().required('User-Agent is required'),
});
addUserAgent,
getResolver,
updateUserAgent,
} from '../../utils/forms/UserAgentUtils.js';
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
const defaultValues = useMemo(
@ -42,14 +28,14 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
watch,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
resolver: getResolver(),
});
const onSubmit = async (values) => {
if (userAgent?.id) {
await API.updateUserAgent({ id: userAgent.id, ...values });
await updateUserAgent(userAgent.id, values);
} else {
await API.addUserAgent(values);
await addUserAgent(values);
}
reset();

View file

@ -0,0 +1,304 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Dependency mocks
vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
deleteRecordingById: vi.fn(),
deleteSeriesAndRule: vi.fn(),
}));
vi.mock('../../../utils/guideUtils.js', () => ({
deleteSeriesRuleByTvgId: vi.fn(),
}));
vi.mock('../SeriesRuleEditorModal.jsx', () => ({
default: ({ opened, onClose }) =>
opened ? (
<div data-testid="series-rule-editor-modal">
<button data-testid="series-rule-editor-close" onClick={onClose}>
Close Editor
</button>
</div>
) : null,
}));
vi.mock('@mantine/core', async () => ({
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Flex: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, disabled, loading, color, variant }) => (
<button
onClick={onClick}
disabled={disabled || loading}
data-color={color}
data-variant={variant}
>
{children}
</button>
),
Anchor: ({ children, onClick }) => (
<a data-testid="anchor" onClick={onClick}>
{children}
</a>
),
}));
// Imports after mocks
import ProgramRecordingModal from '../ProgramRecordingModal.jsx';
import {
deleteRecordingById,
deleteSeriesAndRule,
} from '../../../utils/cards/RecordingCardUtils.js';
import { deleteSeriesRuleByTvgId } from '../../../utils/guideUtils.js';
// Helpers
const makeProgram = (overrides = {}) => ({
tvg_id: 'tvg-1',
title: 'Test Show',
...overrides,
});
const makeRecording = (overrides = {}) => ({
id: 'rec-1',
...overrides,
});
const defaultProps = (overrides = {}) => ({
opened: true,
onClose: vi.fn(),
program: makeProgram(),
recording: null,
existingRuleMode: null,
existingRule: null,
onRecordOne: vi.fn(),
onRecordSeriesAll: vi.fn(),
onRecordSeriesNew: vi.fn(),
onExistingRuleModeChange: vi.fn(),
...overrides,
});
// ProgramRecordingModal
describe('ProgramRecordingModal', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(deleteRecordingById).mockResolvedValue(undefined);
vi.mocked(deleteSeriesAndRule).mockResolvedValue(undefined);
vi.mocked(deleteSeriesRuleByTvgId).mockResolvedValue(undefined);
});
// Visibility
describe('visibility', () => {
it('renders the modal when opened is true', () => {
render(<ProgramRecordingModal {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render the modal when opened is false', () => {
render(<ProgramRecordingModal {...defaultProps({ opened: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('calls onClose when the modal close button is clicked', () => {
const onClose = vi.fn();
render(<ProgramRecordingModal {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// handleRemoveRecording
describe('handleRemoveRecording', () => {
it('calls deleteRecordingById with the recording id', async () => {
const recording = makeRecording();
render(<ProgramRecordingModal {...defaultProps({ recording })} />);
fireEvent.click(screen.getByText('Remove this recording'));
await waitFor(() => {
expect(deleteRecordingById).toHaveBeenCalledWith('rec-1');
});
});
it('calls onClose after deleting recording', async () => {
const onClose = vi.fn();
const recording = makeRecording();
render(
<ProgramRecordingModal {...defaultProps({ recording, onClose })} />
);
fireEvent.click(screen.getByText('Remove this recording'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('still calls onClose when deleteRecordingById throws', async () => {
vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
const onClose = vi.fn();
const recording = makeRecording();
render(
<ProgramRecordingModal {...defaultProps({ recording, onClose })} />
);
fireEvent.click(screen.getByText('Remove this recording'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
// handleRemoveSeries
describe('handleRemoveSeries', () => {
it('calls deleteSeriesAndRule with tvg_id and title', async () => {
const program = makeProgram({ tvg_id: 'tvg-2', title: 'My Series' });
const recording = makeRecording();
render(
<ProgramRecordingModal
{...defaultProps({ program, recording, existingRuleMode: 'series' })}
/>
);
fireEvent.click(screen.getByText(/Remove this series/i));
await waitFor(() => {
expect(deleteSeriesAndRule).toHaveBeenCalledWith({
tvg_id: 'tvg-2',
title: 'My Series',
});
});
});
it('calls onClose after removing series', async () => {
const onClose = vi.fn();
const recording = makeRecording();
render(
<ProgramRecordingModal
{...defaultProps({ onClose, recording, existingRuleMode: 'series' })}
/>
);
fireEvent.click(screen.getByText(/Remove this series/i));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
// handleRemoveSeriesRule
describe('handleRemoveSeriesRule', () => {
it('calls deleteSeriesRuleByTvgId with tvg_id and title', async () => {
const program = makeProgram({ tvg_id: 'tvg-3', title: 'Rule Show' });
render(
<ProgramRecordingModal
{...defaultProps({ program, existingRuleMode: 'rule' })}
/>
);
fireEvent.click(screen.getByText(/Remove series rule/i));
await waitFor(() => {
expect(deleteSeriesRuleByTvgId).toHaveBeenCalledWith(
'tvg-3',
'Rule Show'
);
});
});
it('calls onExistingRuleModeChange(null) after removing rule', async () => {
const onExistingRuleModeChange = vi.fn();
render(
<ProgramRecordingModal
{...defaultProps({
existingRuleMode: 'rule',
onExistingRuleModeChange,
})}
/>
);
fireEvent.click(screen.getByText(/Remove series rule/i));
await waitFor(() => {
expect(onExistingRuleModeChange).toHaveBeenCalledWith(null);
});
});
it('calls onClose after removing rule', async () => {
const onClose = vi.fn();
render(
<ProgramRecordingModal
{...defaultProps({ existingRuleMode: 'rule', onClose })}
/>
);
fireEvent.click(screen.getByText(/Remove series rule/i));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
// Record actions
describe('record actions', () => {
it('calls onRecordOne when "Record Once" is clicked', () => {
const onRecordOne = vi.fn();
render(<ProgramRecordingModal {...defaultProps({ onRecordOne })} />);
fireEvent.click(screen.getByText('Just this one'));
expect(onRecordOne).toHaveBeenCalled();
});
it('calls onRecordSeriesAll when "Record All" is clicked', () => {
const onRecordSeriesAll = vi.fn();
render(
<ProgramRecordingModal {...defaultProps({ onRecordSeriesAll })} />
);
fireEvent.click(screen.getByText('Every episode'));
expect(onRecordSeriesAll).toHaveBeenCalled();
});
it('calls onRecordSeriesNew when "Record New" is clicked', () => {
const onRecordSeriesNew = vi.fn();
render(
<ProgramRecordingModal {...defaultProps({ onRecordSeriesNew })} />
);
fireEvent.click(screen.getByText('New episodes only'));
expect(onRecordSeriesNew).toHaveBeenCalled();
});
});
// SeriesRuleEditorModal
describe('SeriesRuleEditorModal', () => {
it('opens SeriesRuleEditorModal when "Edit Rule" is clicked', () => {
render(
<ProgramRecordingModal
{...defaultProps({
existingRuleMode: 'rule',
existingRule: { id: 1 },
})}
/>
);
fireEvent.click(screen.getByText(/Customize rule/i));
expect(
screen.getByTestId('series-rule-editor-modal')
).toBeInTheDocument();
});
it('closes SeriesRuleEditorModal when its onClose is called', () => {
render(
<ProgramRecordingModal
{...defaultProps({
existingRuleMode: 'rule',
existingRule: { id: 1 },
})}
/>
);
fireEvent.click(screen.getByText(/Customize rule/i));
fireEvent.click(screen.getByTestId('series-rule-editor-close'));
expect(
screen.queryByTestId('series-rule-editor-modal')
).not.toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,580 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import RecordingModal from '../Recording';
// Store mocks
vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils/dateTimeUtils.js', () => ({
RECURRING_DAY_OPTIONS: [
{ value: 'mon', label: 'Monday' },
{ value: 'tue', label: 'Tuesday' },
],
toTimeString: vi.fn((val) => val),
}));
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
buildRecurringPayload: vi.fn((v) => v),
buildSinglePayload: vi.fn((v) => v),
createRecording: vi.fn(),
createRecurringRule: vi.fn(),
sortedChannelOptions: vi.fn(() => [{ value: 'ch-1', label: '501 - HBO' }]),
numberedChannelLabel: vi.fn((item) =>
item.channel_number ? `${item.channel_number} - ${item.name}` : item.name
),
getChannelsSummary: vi.fn(),
getRecurringFormDefaults: vi.fn(() => ({
channel_id: '',
rule_name: '',
days_of_week: [],
start_date: new Date('2024-01-01'),
end_date: null,
start_time: '08:00',
end_time: '09:00',
})),
getSingleFormDefaults: vi.fn(() => ({
channel_id: 'ch-1',
start_time: new Date('2024-06-01T10:00:00'),
end_time: new Date('2024-06-01T11:00:00'),
})),
recurringFormValidators: {},
singleFormValidators: {},
timeChange: vi.fn((fn) => (e) => fn(e.target.value)),
updateRecording: vi.fn(),
}));
// @mantine/form
vi.mock('@mantine/form', () => ({
useForm: vi.fn(({ initialValues }) => {
const values = { ...initialValues };
return {
values,
key: vi.fn((k) => k),
getInputProps: vi.fn((k) => ({
name: k,
value: values[k] ?? '',
onChange: vi.fn(),
})),
onSubmit: vi.fn((handler) => (e) => {
e?.preventDefault?.();
return handler(values);
}),
reset: vi.fn(),
setValues: vi.fn((newVals) => Object.assign(values, newVals)),
setFieldValue: vi.fn((k, v) => {
values[k] = v;
}),
validateField: vi.fn(),
};
}),
}));
// @mantine/core
vi.mock('@mantine/core', () => ({
Alert: ({ children, title }) => (
<div data-testid="alert">
<span data-testid="alert-title">{title}</span>
{children}
</div>
),
Button: ({ children, onClick, loading, type }) => (
<button
type={type}
onClick={onClick}
data-loading={loading}
disabled={loading}
>
{children}
</button>
),
Group: ({ children }) => <div>{children}</div>,
Loader: ({ size, color }) => (
<span data-testid="loader" data-size={size} data-color={color} />
),
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
MultiSelect: ({ label, placeholder, ...props }) => (
<div>
<label>{label}</label>
<input
data-testid={`multiselect-${label}`}
placeholder={placeholder}
{...props}
/>
</div>
),
SegmentedControl: ({ value, onChange, data, disabled }) => (
<div data-testid="segmented-control">
{data.map((item) => (
<button
key={item.value}
data-testid={`mode-${item.value}`}
data-active={value === item.value}
disabled={disabled}
onClick={() => onChange(item.value)}
>
{item.label}
</button>
))}
</div>
),
Select: ({ label, disabled, rightSection, data, ...props }) => (
<div>
<label>{label}</label>
{rightSection}
<select data-testid={`select-${label}`} disabled={disabled} {...props}>
{(data ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
),
Stack: ({ children }) => <div>{children}</div>,
TextInput: ({ label, placeholder, ...props }) => (
<div>
<label>{label}</label>
<input
data-testid={`textinput-${label}`}
placeholder={placeholder}
{...props}
/>
</div>
),
}));
// @mantine/dates
vi.mock('@mantine/dates', () => ({
DatePickerInput: ({ label, value, onChange }) => (
<div>
<label>{label}</label>
<input
data-testid={`datepicker-${label}`}
value={value ? (value.toISOString?.() ?? value) : ''}
onChange={(e) =>
onChange(e.target.value ? new Date(e.target.value) : null)
}
/>
</div>
),
DateTimePicker: ({ label, ...props }) => (
<div>
<label>{label}</label>
<input data-testid={`datetimepicker-${label}`} {...props} />
</div>
),
TimeInput: ({ label, value, onChange, onBlur }) => (
<div>
<label>{label}</label>
<input
data-testid={`timeinput-${label}`}
value={value ?? ''}
onChange={onChange}
onBlur={onBlur}
/>
</div>
),
}));
// lucide-react
vi.mock('lucide-react', () => ({
CircleAlert: () => <svg data-testid="icon-circle-alert" />,
}));
// Imports after mocks
import useChannelsStore from '../../../store/channels';
import { showNotification } from '../../../utils/notificationUtils.js';
import * as RecordingUtils from '../../../utils/forms/RecordingUtils.js';
const setupStoreMock = () => {
const mockFetchRecordings = vi.fn().mockResolvedValue(undefined);
const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined);
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({
fetchRecordings: mockFetchRecordings,
fetchRecurringRules: mockFetchRecurringRules,
})
);
return { mockFetchRecordings, mockFetchRecurringRules };
};
const makeRecording = (overrides = {}) => ({
id: 'rec-1',
start_time: '2024-06-01T10:00:00Z',
end_time: '2024-06-01T11:00:00Z',
custom_properties: { program: { title: 'Test Show' } },
...overrides,
});
const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 });
describe('RecordingModal', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([
{ id: 'ch-1', name: 'HBO', channel_number: 501 },
]);
vi.mocked(RecordingUtils.createRecording).mockResolvedValue(undefined);
vi.mocked(RecordingUtils.updateRecording).mockResolvedValue(undefined);
vi.mocked(RecordingUtils.createRecurringRule).mockResolvedValue(undefined);
setupStoreMock();
});
// Visibility
describe('visibility', () => {
it('renders the modal when isOpen is true', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render when isOpen is false', () => {
render(<RecordingModal isOpen={false} onClose={vi.fn()} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
});
// Alert
describe('scheduling conflict alert', () => {
it('renders the scheduling conflicts alert', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
expect(screen.getByTestId('alert')).toBeInTheDocument();
expect(screen.getByTestId('alert-title')).toHaveTextContent(
'Scheduling Conflicts'
);
});
});
// Mode switching
describe('mode switching', () => {
it('defaults to "single" mode', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
expect(screen.getByTestId('mode-single')).toHaveAttribute(
'data-active',
'true'
);
});
it('switches to recurring mode when Recurring button clicked', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
expect(screen.getByTestId('mode-recurring')).toHaveAttribute(
'data-active',
'true'
);
});
it('shows DateTimePicker fields in single mode', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
expect(screen.getByTestId('datetimepicker-Start')).toBeInTheDocument();
expect(screen.getByTestId('datetimepicker-End')).toBeInTheDocument();
});
it('shows recurring fields when in recurring mode', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
expect(screen.getByTestId('textinput-Rule name')).toBeInTheDocument();
expect(screen.getByTestId('timeinput-Start time')).toBeInTheDocument();
expect(screen.getByTestId('timeinput-End time')).toBeInTheDocument();
});
it('disables mode toggle when editing an existing recording', () => {
render(
<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />
);
expect(screen.getByTestId('mode-single')).toBeDisabled();
expect(screen.getByTestId('mode-recurring')).toBeDisabled();
});
it('shows "Schedule Recording" submit button in single mode', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
expect(screen.getByText('Schedule Recording')).toBeInTheDocument();
});
it('shows "Save Rule" submit button in recurring mode', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
expect(screen.getByText('Save Rule')).toBeInTheDocument();
});
});
// Channel loading
describe('channel loading', () => {
it('calls getChannelsSummary when modal opens', async () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
await waitFor(() => {
expect(RecordingUtils.getChannelsSummary).toHaveBeenCalled();
});
});
it('calls sortedChannelOptions with loaded channels', async () => {
const channels = [{ id: 'ch-1', name: 'HBO', channel_number: 501 }];
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue(channels);
render(<RecordingModal isOpen onClose={vi.fn()} />);
await waitFor(() => {
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith(
channels,
RecordingUtils.numberedChannelLabel
);
});
});
it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => {
vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(
new Error('fail')
);
render(<RecordingModal isOpen onClose={vi.fn()} />);
await waitFor(() => {
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith(
[],
RecordingUtils.numberedChannelLabel
);
});
});
it('does not load channels when modal is closed', () => {
render(<RecordingModal isOpen={false} onClose={vi.fn()} />);
expect(RecordingUtils.getChannelsSummary).not.toHaveBeenCalled();
});
});
// Single form submit (create)
describe('single mode create recording', () => {
it('calls buildSinglePayload with form values on submit', async () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(RecordingUtils.buildSinglePayload).toHaveBeenCalled();
});
});
it('calls createRecording when no existing recording', async () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(RecordingUtils.createRecording).toHaveBeenCalled();
});
});
it('shows "Recording scheduled" notification after successful create', async () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Recording scheduled',
color: 'green',
})
);
});
});
it('calls fetchRecordings after successful create', async () => {
const { mockFetchRecordings } = setupStoreMock();
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(mockFetchRecordings).toHaveBeenCalled();
});
});
it('calls onClose after successful create', async () => {
const onClose = vi.fn();
render(<RecordingModal isOpen onClose={onClose} />);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('does not call showNotification when createRecording throws', async () => {
vi.mocked(RecordingUtils.createRecording).mockRejectedValue(
new Error('fail')
);
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
// Single form submit (update)
describe('single mode update recording', () => {
it('calls updateRecording when editing an existing recording', async () => {
render(
<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />
);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(RecordingUtils.updateRecording).toHaveBeenCalledWith(
'rec-1',
expect.anything()
);
});
});
it('does not call createRecording when updating', async () => {
render(
<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />
);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(RecordingUtils.createRecording).not.toHaveBeenCalled();
});
});
it('shows "Recording updated" notification after successful update', async () => {
render(
<RecordingModal isOpen onClose={vi.fn()} recording={makeRecording()} />
);
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Recording updated',
color: 'green',
})
);
});
});
});
// Recurring form submit
describe('recurring mode create rule', () => {
it('calls buildRecurringPayload with form values on submit', async () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
await waitFor(() => {
expect(RecordingUtils.buildRecurringPayload).toHaveBeenCalled();
});
});
it('calls createRecurringRule on submit', async () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
await waitFor(() => {
expect(RecordingUtils.createRecurringRule).toHaveBeenCalled();
});
});
it('calls fetchRecurringRules and fetchRecordings on success', async () => {
const { mockFetchRecurringRules, mockFetchRecordings } = setupStoreMock();
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
await waitFor(() => {
expect(mockFetchRecurringRules).toHaveBeenCalled();
expect(mockFetchRecordings).toHaveBeenCalled();
});
});
it('shows "Recurring rule saved" notification on success', async () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Recurring rule saved',
color: 'green',
})
);
});
});
it('calls onClose after successful recurring submit', async () => {
const onClose = vi.fn();
render(<RecordingModal isOpen onClose={onClose} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('does not show notification when createRecurringRule throws', async () => {
vi.mocked(RecordingUtils.createRecurringRule).mockRejectedValue(
new Error('fail')
);
render(<RecordingModal isOpen onClose={vi.fn()} />);
fireEvent.click(screen.getByTestId('mode-recurring'));
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
// Form initialization
describe('form initialization', () => {
it('calls getSingleFormDefaults with recording and channel when opening with existing recording', () => {
const recording = makeRecording();
const channel = makeChannel();
render(
<RecordingModal
isOpen
recording={recording}
channel={channel}
onClose={vi.fn()}
/>
);
expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(
recording,
channel
);
});
it('calls getSingleFormDefaults with null when opening for new recording', () => {
render(<RecordingModal isOpen onClose={vi.fn()} />);
expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(
null,
null
);
});
it('calls getRecurringFormDefaults with channel on open', () => {
const channel = makeChannel();
render(<RecordingModal isOpen channel={channel} onClose={vi.fn()} />);
expect(RecordingUtils.getRecurringFormDefaults).toHaveBeenCalledWith(
channel
);
});
});
// Close / reset
describe('close and reset', () => {
it('calls onClose when modal close button is clicked', () => {
const onClose = vi.fn();
render(<RecordingModal isOpen onClose={onClose} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,806 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Store mocks
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
vi.mock('../../../store/useVideoStore.jsx', () => ({
default: Object.assign(vi.fn(), {
getState: vi.fn(() => ({ showVideo: vi.fn() })),
}),
}));
// Utility mocks
vi.mock('../../../utils/dateTimeUtils.js', () => ({
format: vi.fn(),
isAfter: vi.fn(),
isBefore: vi.fn(),
useDateTimeFormat: vi.fn(),
useTimeHelpers: vi.fn(),
}));
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
deleteRecordingById: vi.fn(),
getChannelLogoUrl: vi.fn(),
getPosterUrl: vi.fn(),
getRecordingUrl: vi.fn(),
getSeasonLabel: vi.fn(),
getShowVideoUrl: vi.fn(),
runComSkip: vi.fn(),
}));
vi.mock('../../../utils/forms/RecordingDetailsModalUtils.js', () => ({
getChannel: vi.fn(),
getRating: vi.fn(),
getStatRows: vi.fn(),
getUpcomingEpisodes: vi.fn(),
refreshArtwork: vi.fn(),
updateRecordingMetadata: vi.fn(),
}));
vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' }));
// lucide-react
vi.mock('lucide-react', () => ({
Check: () => <svg data-testid="icon-check" />,
Pencil: () => <svg data-testid="icon-pencil" />,
RefreshCcw: () => <svg data-testid="icon-refresh" />,
X: () => <svg data-testid="icon-x" />,
}));
// @mantine/core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, disabled }) => (
<button data-testid="action-icon" onClick={onClick} disabled={disabled}>
{children}
</button>
),
Badge: ({ children, color }) => (
<span data-testid="badge" data-color={color}>
{children}
</span>
),
Button: ({ children, onClick, disabled, loading, size, variant, color }) => (
<button
onClick={onClick}
disabled={disabled || loading}
data-size={size}
data-variant={variant}
data-color={color}
data-loading={loading}
>
{children}
</button>
),
Card: ({ children, onClick, style }) => (
<div data-testid="card" onClick={onClick} style={style}>
{children}
</div>
),
Flex: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Image: ({ src, alt, fallbackSrc }) => (
<img src={src} alt={alt} data-fallback={fallbackSrc} />
),
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, size, c, fw, style }) => (
<span data-size={size} data-color={c} data-fw={fw} style={style}>
{children}
</span>
),
Textarea: ({ label, value, onChange, placeholder, ...props }) => (
<div>
<label>{label}</label>
<textarea
data-testid={`textarea-${label ?? placeholder ?? 'unknown'}`}
value={value ?? ''}
onChange={onChange}
{...props}
/>
</div>
),
TextInput: ({ label, value, onChange, placeholder, ...props }) => (
<div>
<label />
<input
data-testid={`textinput-${label ?? placeholder ?? 'unknown'}`}
value={value}
onChange={onChange}
placeholder={placeholder}
style={props.style}
/>
</div>
),
}));
// Imports after mocks
import RecordingDetailsModal from '../RecordingDetailsModal';
import useChannelsStore from '../../../store/channels.jsx';
import useVideoStore from '../../../store/useVideoStore.jsx';
import {
format,
isAfter,
isBefore,
useDateTimeFormat,
useTimeHelpers,
} from '../../../utils/dateTimeUtils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
import * as RecordingCardUtils from '../../../utils/cards/RecordingCardUtils.js';
import * as RecordingDetailsModalUtils from '../../../utils/forms/RecordingDetailsModalUtils.js';
import dayjs from 'dayjs';
// Helpers
const PAST = '2020-01-01T10:00:00Z';
const FUTURE = '2099-01-01T10:00:00Z';
const NOW = '2024-06-01T12:00:00Z';
const makeMoment = (isoString) => {
const d = dayjs(isoString);
return {
isAfter: (other) => d.isAfter(other?._d ?? other),
isBefore: (other) => d.isBefore(other?._d ?? other),
format: vi.fn((fmt) => d.format(fmt)),
_d: d.toDate(),
};
};
const makeRecording = (overrides = {}) => ({
id: 'rec-1',
channel: 'ch-1',
start_time: PAST,
end_time: PAST,
_group_count: 1,
custom_properties: {
status: 'completed',
file_url: '/recordings/test.ts',
program: {
title: 'Test Show',
description: 'A test description',
sub_title: 'Pilot',
},
...overrides.custom_properties,
},
...overrides,
});
const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 });
const mockShowVideo = vi.fn();
const setupMocks = ({ recording = makeRecording(), now = NOW } = {}) => {
const nowMoment = makeMoment(now);
const startMoment = makeMoment(recording.start_time);
const endMoment = makeMoment(recording.end_time);
vi.mocked(useTimeHelpers).mockReturnValue({
toUserTime: (iso) => {
if (iso === recording.start_time) return startMoment;
if (iso === recording.end_time) return endMoment;
return makeMoment(iso);
},
userNow: () => nowMoment,
});
vi.mocked(useDateTimeFormat).mockReturnValue({
timeFormat: 'HH:mm',
dateFormat: 'MM/DD',
});
vi.mocked(format).mockImplementation((moment, fmt) => moment.format(fmt));
vi.mocked(isAfter).mockImplementation((a, b) => a.isAfter(b));
vi.mocked(isBefore).mockImplementation((a, b) => a.isBefore(b));
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({ recordings: [recording] })
);
mockShowVideo.mockReset();
vi.mocked(useVideoStore).mockImplementation((sel) =>
sel({ showVideo: mockShowVideo })
);
useVideoStore.getState = vi.fn(() => ({ showVideo: mockShowVideo }));
vi.mocked(RecordingCardUtils.getPosterUrl).mockReturnValue('/poster.jpg');
vi.mocked(RecordingCardUtils.getChannelLogoUrl).mockReturnValue('/logo.png');
vi.mocked(RecordingCardUtils.getRecordingUrl).mockReturnValue(
'/recordings/test.ts'
);
vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('');
vi.mocked(RecordingCardUtils.getShowVideoUrl).mockReturnValue('/live/ch-1');
vi.mocked(RecordingDetailsModalUtils.getRating).mockReturnValue(null);
vi.mocked(RecordingDetailsModalUtils.getStatRows).mockReturnValue([]);
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue([]);
vi.mocked(RecordingDetailsModalUtils.getChannel).mockResolvedValue(null);
};
const defaultProps = () => ({
opened: true,
onClose: vi.fn(),
recording: makeRecording(),
channel: makeChannel(),
posterUrl: '/poster.jpg',
onWatchLive: vi.fn(),
onWatchRecording: vi.fn(),
env_mode: 'production',
onEdit: vi.fn(),
});
// Tests
describe('RecordingDetailsModal', () => {
beforeEach(() => {
vi.clearAllMocks();
setupMocks();
vi.mocked(
RecordingDetailsModalUtils.updateRecordingMetadata
).mockResolvedValue(undefined);
vi.mocked(RecordingDetailsModalUtils.refreshArtwork).mockResolvedValue(
undefined
);
vi.mocked(RecordingCardUtils.runComSkip).mockResolvedValue(undefined);
vi.mocked(RecordingCardUtils.deleteRecordingById).mockResolvedValue(
undefined
);
});
// Visibility
describe('visibility', () => {
it('renders the modal when opened is true', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render when opened is false', () => {
render(<RecordingDetailsModal {...defaultProps()} opened={false} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('returns null when recording is not provided', () => {
const { container } = render(
<RecordingDetailsModal {...defaultProps()} recording={null} />
);
expect(container.firstChild).toBeNull();
});
it('calls onClose when modal close button is clicked', () => {
const onClose = vi.fn();
render(<RecordingDetailsModal {...defaultProps()} onClose={onClose} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Content rendering
describe('content rendering', () => {
it('renders the recording title', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
expect(screen.getByText(/Test Show/i)).toBeInTheDocument();
});
it('renders "Custom Recording" when no program title', () => {
const recording = makeRecording({
custom_properties: { status: 'completed', program: {} },
});
setupMocks({ recording });
render(
<RecordingDetailsModal {...defaultProps()} recording={recording} />
);
expect(screen.getByText('Custom Recording')).toBeInTheDocument();
});
it('renders the description', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
expect(screen.getByText('A test description')).toBeInTheDocument();
});
it('renders the poster image', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
expect(screen.getByAltText('Test Show')).toHaveAttribute(
'src',
'/poster.jpg'
);
});
it('renders the season/episode label when present', () => {
const seriesRecording = makeRecording({ _group_count: 2 });
const mockEpisode = makeRecording({ id: 'ep-1', _group_count: 1 });
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
[mockEpisode]
);
vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('S01E02');
render(
<RecordingDetailsModal
{...defaultProps()}
recording={seriesRecording}
/>
);
expect(screen.getByText('S01E02')).toBeInTheDocument();
});
it('renders rating when present', () => {
vi.mocked(RecordingDetailsModalUtils.getRating).mockReturnValue('TV-MA');
render(<RecordingDetailsModal {...defaultProps()} />);
expect(screen.getByText('TV-MA')).toBeInTheDocument();
});
it('renders stat rows when available', () => {
vi.mocked(RecordingDetailsModalUtils.getStatRows).mockReturnValue([
['Video Codec', 'H264'],
['Audio Codec', 'AAC'],
]);
render(<RecordingDetailsModal {...defaultProps()} />);
expect(screen.getByText('Stream Stats')).toBeInTheDocument();
expect(screen.getByText('Video Codec')).toBeInTheDocument();
expect(screen.getByText('H264')).toBeInTheDocument();
});
});
// Watch buttons
describe('Watch button', () => {
it('renders Watch button for completed recording', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
expect(screen.getByText('Watch')).toBeInTheDocument();
});
it('calls onWatchRecording when Watch is clicked', () => {
const onWatchRecording = vi.fn();
render(
<RecordingDetailsModal
{...defaultProps()}
onWatchRecording={onWatchRecording}
/>
);
fireEvent.click(screen.getByText('Watch'));
expect(onWatchRecording).toHaveBeenCalled();
});
it('Watch button is disabled when no file_url', () => {
const recording = makeRecording({
custom_properties: {
status: 'completed',
program: { title: 'Test Show' },
},
});
setupMocks({ recording });
render(
<RecordingDetailsModal {...defaultProps()} recording={recording} />
);
expect(screen.getByText('Watch')).toBeDisabled();
});
});
describe('Watch Live button', () => {
it('renders Watch Live button for in-progress recording', () => {
const recording = makeRecording({
start_time: PAST,
end_time: FUTURE,
custom_properties: {
status: 'recording',
file_url: '/f.ts',
program: { title: 'Live Show' },
},
});
setupMocks({ recording });
render(
<RecordingDetailsModal {...defaultProps()} recording={recording} />
);
expect(screen.getByText('Watch Live')).toBeInTheDocument();
});
it('calls onWatchLive when Watch Live is clicked', () => {
const recording = makeRecording({
start_time: PAST,
end_time: FUTURE,
custom_properties: {
status: 'recording',
file_url: '/f.ts',
program: { title: 'Live Show' },
},
});
setupMocks({ recording });
const onWatchLive = vi.fn();
render(
<RecordingDetailsModal
{...defaultProps()}
recording={recording}
onWatchLive={onWatchLive}
/>
);
fireEvent.click(screen.getByText('Watch Live'));
expect(onWatchLive).toHaveBeenCalled();
});
});
// Edit button
describe('Edit button', () => {
it('renders the Edit button', () => {
const futureRecording = makeRecording({
start_time: FUTURE,
end_time: FUTURE,
});
setupMocks({ recording: futureRecording });
render(
<RecordingDetailsModal
{...defaultProps()}
recording={futureRecording}
/>
);
expect(screen.getByText('Edit')).toBeInTheDocument();
});
it('calls onEdit when Edit is clicked', () => {
const futureRecording = makeRecording({
start_time: FUTURE,
end_time: FUTURE,
});
setupMocks({ recording: futureRecording });
const onEdit = vi.fn();
render(
<RecordingDetailsModal
{...defaultProps()}
recording={futureRecording}
onEdit={onEdit}
/>
);
fireEvent.click(screen.getByText('Edit'));
expect(onEdit).toHaveBeenCalledWith(futureRecording);
});
});
// Inline editing
describe('inline metadata editing', () => {
it('shows edit inputs when pencil icon is clicked', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
expect(
screen.getByTestId('textinput-Recording title')
).toBeInTheDocument();
});
it('pre-fills title input with current title', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
expect(screen.getByTestId('textinput-Recording title')).toHaveValue(
'Test Show'
);
});
it('pre-fills description input with current description', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
expect(screen.getByTestId('textarea-Description (optional)')).toHaveValue(
'A test description'
);
});
it('cancels editing when X icon is clicked', () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
expect(
screen.getByTestId('textinput-Recording title')
).toBeInTheDocument();
fireEvent.click(screen.getByTestId('icon-x').closest('button'));
expect(
screen.queryByTestId('textinput-Recording title')
).not.toBeInTheDocument();
});
it('calls updateRecordingMetadata with new title and description on save', async () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
fireEvent.change(screen.getByTestId('textinput-Recording title'), {
target: { value: 'New Title' },
});
fireEvent.change(screen.getByTestId('textarea-Description (optional)'), {
target: { value: 'New Description' },
});
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(
RecordingDetailsModalUtils.updateRecordingMetadata
).toHaveBeenCalledWith(makeRecording(), 'New Title', 'New Description');
});
});
it('shows saved title optimistically after save', async () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
fireEvent.change(screen.getByTestId('textinput-Recording title'), {
target: { value: 'Updated Title' },
});
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(screen.getByText('Updated Title')).toBeInTheDocument();
});
});
it('shows success notification after save', async () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'green' })
);
});
});
it('does not show notification when updateRecordingMetadata throws', async () => {
vi.mocked(
RecordingDetailsModalUtils.updateRecordingMetadata
).mockRejectedValue(new Error('fail'));
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
it('uses "Custom Recording" when title is cleared on save', async () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
fireEvent.change(screen.getByTestId('textinput-Recording title'), {
target: { value: '' },
});
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(screen.getByText('Custom Recording')).toBeInTheDocument();
});
});
});
// Refresh artwork
describe('refresh artwork', () => {
it('calls refreshArtwork with recording id on click', async () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Refresh artwork'));
await waitFor(() => {
expect(RecordingDetailsModalUtils.refreshArtwork).toHaveBeenCalledWith(
'rec-1'
);
});
});
it('shows success notification after refreshArtwork', async () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Refresh artwork'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'blue.5' })
);
});
});
it('does not show notification when refreshArtwork throws', async () => {
vi.mocked(RecordingDetailsModalUtils.refreshArtwork).mockRejectedValue(
new Error('fail')
);
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Refresh artwork'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
// ComSkip
describe('run comskip', () => {
it('calls runComSkip with the recording', async () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Remove commercials'));
await waitFor(() => {
expect(RecordingCardUtils.runComSkip).toHaveBeenCalledWith(
makeRecording()
);
});
});
it('shows success notification after runComSkip', async () => {
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Remove commercials'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: expect.any(String) })
);
});
});
it('does not show notification when runComSkip throws', async () => {
vi.mocked(RecordingCardUtils.runComSkip).mockRejectedValue(
new Error('fail')
);
render(<RecordingDetailsModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Remove commercials'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
it('does not show Remove commercials when comskip is completed', () => {
const recording = makeRecording({
custom_properties: {
status: 'completed',
file_url: '/f.ts',
comskip: { status: 'completed' },
program: { title: 'Test Show' },
},
});
setupMocks({ recording });
render(
<RecordingDetailsModal {...defaultProps()} recording={recording} />
);
expect(screen.queryByText('Remove commercials')).not.toBeInTheDocument();
});
});
// Series / episodes
describe('series group', () => {
const makeSeriesRecording = () =>
makeRecording({
_group_count: 3,
custom_properties: {
status: 'scheduled',
program: { title: 'Series Show', tvg_id: 'series-1' },
},
start_time: FUTURE,
end_time: FUTURE,
});
const makeEpisode = (id = 'ep-1') => ({
id,
channel: 'ch-1',
start_time: FUTURE,
end_time: FUTURE,
custom_properties: {
status: 'scheduled',
program: { title: 'Episode', sub_title: `Sub ${id}` },
},
});
it('renders upcoming episodes list when isSeriesGroup is true', () => {
const recording = makeSeriesRecording();
const episode = makeEpisode();
setupMocks({ recording });
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
[episode]
);
render(
<RecordingDetailsModal {...defaultProps()} recording={recording} />
);
expect(screen.getByText('Sub ep-1')).toBeInTheDocument();
});
it('shows "No upcoming episodes" when episode list is empty for series', () => {
const recording = makeSeriesRecording();
setupMocks({ recording });
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
[]
);
render(
<RecordingDetailsModal {...defaultProps()} recording={recording} />
);
expect(screen.getByText(/no upcoming episodes/i)).toBeInTheDocument();
});
it('opens child modal when an episode card is clicked', async () => {
const recording = makeSeriesRecording();
const episode = makeEpisode();
setupMocks({ recording });
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
[episode]
);
render(
<RecordingDetailsModal {...defaultProps()} recording={recording} />
);
const episodeCards = screen.getAllByTestId('card');
fireEvent.click(episodeCards[episodeCards.length - 1]);
await waitFor(() => {
expect(screen.getAllByTestId('modal').length).toBeGreaterThanOrEqual(1);
});
});
it('calls deleteRecordingById when episode remove is clicked', async () => {
const recording = makeSeriesRecording();
const episode = makeEpisode();
setupMocks({ recording });
vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
[episode]
);
render(
<RecordingDetailsModal {...defaultProps()} recording={recording} />
);
fireEvent.click(screen.getByText('Remove'));
await waitFor(() => {
expect(RecordingCardUtils.deleteRecordingById).toHaveBeenCalledWith(
'ep-1'
);
});
});
});
// safeRecording merging
describe('safeRecording store merge', () => {
it('picks updated recording from store while preserving _group_count', () => {
const original = makeRecording({ _group_count: 3 });
const storeVersion = {
...makeRecording(),
id: 'rec-1',
custom_properties: {
...makeRecording().custom_properties,
program: { title: 'Updated Title' },
},
};
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({ recordings: [storeVersion] })
);
render(
<RecordingDetailsModal {...defaultProps()} recording={original} />
);
expect(screen.getByText(/Updated Title/i)).toBeInTheDocument();
});
});
// Optimistic state reset
describe('optimistic state reset on recording change', () => {
it('resets saved title when recording id changes', async () => {
const { rerender } = render(
<RecordingDetailsModal {...defaultProps()} />
);
// Save a new title
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
fireEvent.change(screen.getByTestId('textinput-Recording title'), {
target: { value: 'Custom' },
});
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() =>
expect(screen.getByText('Custom')).toBeInTheDocument()
);
// Switch to a different recording
const newRecording = makeRecording({ id: 'rec-2' });
setupMocks({ recording: newRecording });
rerender(
<RecordingDetailsModal {...defaultProps()} recording={newRecording} />
);
expect(screen.getByText(/Test Show/i)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,985 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Store mocks
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils/dateTimeUtils.js', () => ({
format: vi.fn((moment, fmt) => `formatted-${fmt}`),
getNow: vi.fn(() => '2024-06-01T12:00:00Z'),
RECURRING_DAY_OPTIONS: [
{ value: 'mon', label: 'Monday' },
{ value: 'tue', label: 'Tuesday' },
{ value: 'wed', label: 'Wednesday' },
],
toDate: vi.fn((val) => new Date(val)),
toTimeString: vi.fn((val) => val),
useDateTimeFormat: vi.fn(),
useTimeHelpers: vi.fn(),
}));
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
deleteRecordingById: vi.fn(),
}));
vi.mock('../../../utils/forms/RecurringRuleModalUtils.js', () => ({
deleteRecurringRuleById: vi.fn(),
getFormDefaults: vi.fn(),
getUpcomingOccurrences: vi.fn(),
updateRecurringRule: vi.fn(),
updateRecurringRuleEnabled: vi.fn(),
}));
vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
getChannelsSummary: vi.fn(),
getRecurringFormDefaults: vi.fn(),
recurringFormValidators: {},
sortedChannelOptions: vi.fn(() => [{ value: 'ch-1', label: 'HBO' }]),
}));
// @mantine/form
vi.mock('@mantine/form', async () => {
const React = await import('react');
return {
useForm: vi.fn(({ initialValues }) => {
const [values, setValuesState] = React.useState({ ...initialValues });
return {
values,
key: vi.fn((k) => k),
getInputProps: vi.fn((k) => ({
name: k,
value: values[k] ?? '',
onChange: vi.fn((e) => {
const v = e?.currentTarget?.value ?? e?.target?.value ?? e;
setValuesState((prev) => ({ ...prev, [k]: v }));
}),
})),
onSubmit: vi.fn((handler) => (e) => {
e?.preventDefault?.();
return handler(values);
}),
reset: vi.fn(() => setValuesState({ ...initialValues })),
setValues: vi.fn((newVals) =>
setValuesState((prev) => ({ ...prev, ...newVals }))
),
setFieldValue: vi.fn((k, v) =>
setValuesState((prev) => ({ ...prev, [k]: v }))
),
};
}),
};
});
// @mantine/core
vi.mock('@mantine/core', () => ({
Badge: ({ children, color }) => (
<span data-testid="badge" data-color={color}>
{children}
</span>
),
Button: ({
children,
onClick,
loading,
type,
variant,
color,
size,
disabled,
}) => (
<button
type={type}
onClick={onClick}
disabled={disabled || loading}
data-loading={loading}
data-variant={variant}
data-color={color}
data-size={size}
>
{children}
</button>
),
Card: ({ children }) => <div data-testid="occurrence-card">{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
MultiSelect: ({ label, data, ...props }) => (
<div>
<label>{label}</label>
<select data-testid={`multiselect-${label}`} multiple {...props}>
{(data ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
),
Select: ({ label, data, ...props }) => (
<div>
<label>{label}</label>
<select data-testid={`select-${label}`} {...props}>
{(data ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Switch: ({ checked, onChange, label, disabled }) => (
<label>
<input
data-testid="switch"
type="checkbox"
checked={checked ?? false}
disabled={disabled}
onChange={(e) => {
// Mirror the event shape the component expects: event.currentTarget.checked
onChange({ currentTarget: { checked: e.target.checked } });
}}
/>
<span>{label}</span>
</label>
),
Text: ({ children, size, c, fw }) => (
<span data-size={size} data-color={c} data-fw={fw}>
{children}
</span>
),
TextInput: ({ label, placeholder, ...props }) => (
<div>
<label>{label}</label>
<input
data-testid={`textinput-${label ?? placeholder ?? 'unknown'}`}
placeholder={placeholder}
{...props}
/>
</div>
),
}));
// @mantine/dates
vi.mock('@mantine/dates', () => ({
DatePickerInput: ({ label, value, onChange }) => (
<div>
<label>{label}</label>
<input
data-testid={`datepicker-${label}`}
value={value ? (value.toISOString?.() ?? String(value)) : ''}
onChange={(e) =>
onChange(e.target.value ? new Date(e.target.value) : null)
}
/>
</div>
),
TimeInput: ({ label, value, onChange }) => (
<div>
<label>{label}</label>
<input
data-testid={`timeinput-${label}`}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
/>
</div>
),
}));
// Imports after mocks
import useChannelsStore from '../../../store/channels.jsx';
import {
format,
toTimeString,
useDateTimeFormat,
useTimeHelpers,
} from '../../../utils/dateTimeUtils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
import { deleteRecordingById } from '../../../utils/cards/RecordingCardUtils.js';
import * as RecurringRuleModalUtils from '../../../utils/forms/RecurringRuleModalUtils.js';
import * as RecordingUtils from '../../../utils/forms/RecordingUtils.js';
import { useForm } from '@mantine/form';
import RecurringRuleModal from '../RecurringRuleModal';
import dayjs from 'dayjs';
// Helpers
const FUTURE = '2099-01-01T10:00:00Z';
const NOW = '2024-06-01T12:00:00Z';
const makeMoment = (isoString) => {
const d = dayjs(isoString);
return {
isAfter: (other) => d.isAfter(other?._d ?? other),
isBefore: (other) => d.isBefore(other?._d ?? other),
format: vi.fn((fmt) => `formatted-${fmt}`),
_d: d.toDate(),
};
};
const makeRule = (overrides = {}) => ({
id: 'rule-1',
name: 'Morning News',
channel: 'ch-1',
channel_id: 'ch-1',
days_of_week: ['mon', 'tue'],
start_date: new Date('2024-01-01'),
end_date: null,
start_time: '08:00',
end_time: '09:00',
enabled: true,
...overrides,
});
const makeOccurrence = (id = 'occ-1') => ({
id,
start_time: FUTURE,
end_time: FUTURE,
custom_properties: { program: { title: 'Episode' } },
});
const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 });
const makeRecording = (overrides = {}) => ({
id: 'rec-1',
start_time: FUTURE,
end_time: FUTURE,
custom_properties: { program: { title: 'Morning News' } },
...overrides,
});
const setupMocks = ({ rule = makeRule(), occurrences = [] } = {}) => {
const nowMoment = makeMoment(NOW);
const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined);
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({
recurringRules: rule ? [rule] : [],
fetchRecurringRules: mockFetchRecurringRules,
recordings: [],
})
);
vi.mocked(useTimeHelpers).mockReturnValue({
toUserTime: (iso) => makeMoment(iso),
userNow: () => nowMoment,
});
vi.mocked(useDateTimeFormat).mockReturnValue({
timeFormat: 'HH:mm',
dateFormat: 'MM/DD',
});
vi.mocked(format).mockImplementation((moment, fmt) => `formatted-${fmt}`);
vi.mocked(RecordingUtils.getRecurringFormDefaults).mockReturnValue({
channel_id: '',
rule_name: '',
days_of_week: [],
start_date: new Date('2024-01-01'),
end_date: null,
start_time: '08:00',
end_time: '09:00',
enabled: rule?.enabled ?? true,
});
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([
makeChannel(),
]);
vi.mocked(RecurringRuleModalUtils.getFormDefaults).mockReturnValue({
channel_id: rule?.channel_id ?? '',
rule_name: rule?.name ?? '',
days_of_week: rule?.days_of_week ?? [],
start_date: rule?.start_date ?? new Date('2024-01-01'),
end_date: rule?.end_date ?? null,
start_time: rule?.start_time ?? '08:00',
end_time: rule?.end_time ?? '09:00',
enabled: rule?.enabled ?? true,
});
vi.mocked(RecurringRuleModalUtils.getUpcomingOccurrences).mockReturnValue(
occurrences
);
vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockResolvedValue(
undefined
);
vi.mocked(
RecurringRuleModalUtils.updateRecurringRuleEnabled
).mockResolvedValue(undefined);
vi.mocked(RecurringRuleModalUtils.deleteRecurringRuleById).mockResolvedValue(
undefined
);
vi.mocked(deleteRecordingById).mockResolvedValue(undefined);
return { mockFetchRecurringRules };
};
const defaultProps = (overrides = {}) => ({
opened: true,
onClose: vi.fn(),
ruleId: 'rule-1',
recording: null,
onEditOccurrence: vi.fn(),
...overrides,
});
// Tests
describe('RecurringRuleModal', () => {
beforeEach(() => {
vi.clearAllMocks();
setupMocks();
});
// Visibility
describe('visibility', () => {
it('renders the modal when opened is true', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render when opened is false', () => {
render(<RecurringRuleModal {...defaultProps()} opened={false} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('uses rule name as modal title', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Morning News'
);
});
it('falls back to "Recurring Rule" when rule has no name', () => {
setupMocks({ rule: makeRule({ name: '' }) });
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Recurring Rule'
);
});
it('calls onClose when modal close button is clicked', () => {
const onClose = vi.fn();
render(<RecurringRuleModal {...defaultProps()} onClose={onClose} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Missing rule fallback
describe('missing rule fallback', () => {
beforeEach(() => {
setupMocks({ rule: null });
});
it('renders fallback message when rule does not exist', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(
screen.getByText(
'The recurring rule for this recording no longer exists.'
)
).toBeInTheDocument();
});
it('shows Delete Recording button when sourceRecording is provided', () => {
render(
<RecurringRuleModal {...defaultProps()} recording={makeRecording()} />
);
expect(screen.getByText('Delete Recording')).toBeInTheDocument();
});
it('does not show Delete Recording button when sourceRecording is null', () => {
render(<RecurringRuleModal {...defaultProps()} recording={null} />);
expect(screen.queryByText('Delete Recording')).not.toBeInTheDocument();
});
it('calls deleteRecordingById when Delete Recording is confirmed', async () => {
const onClose = vi.fn();
render(
<RecurringRuleModal
{...defaultProps()}
onClose={onClose}
recording={makeRecording()}
/>
);
fireEvent.click(screen.getByText('Delete Recording'));
await waitFor(() => {
expect(deleteRecordingById).toHaveBeenCalledWith('rec-1');
});
});
it('shows "Recording deleted" notification after deleting orphaned recording', async () => {
render(
<RecurringRuleModal {...defaultProps()} recording={makeRecording()} />
);
fireEvent.click(screen.getByText('Delete Recording'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Recording deleted',
color: 'green',
})
);
});
});
it('calls onClose after deleting orphaned recording', async () => {
const onClose = vi.fn();
render(
<RecurringRuleModal
{...defaultProps()}
onClose={onClose}
recording={makeRecording()}
/>
);
fireEvent.click(screen.getByText('Delete Recording'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('calls onClose when Cancel is clicked in fallback', () => {
const onClose = vi.fn();
render(
<RecurringRuleModal
{...defaultProps()}
onClose={onClose}
recording={makeRecording()}
/>
);
fireEvent.click(screen.getByText('Cancel'));
expect(onClose).toHaveBeenCalled();
});
it('does not show notification when deleteRecordingById throws', async () => {
vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
render(
<RecurringRuleModal {...defaultProps()} recording={makeRecording()} />
);
fireEvent.click(screen.getByText('Delete Recording'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
// Form fields
describe('form fields', () => {
it('renders the Channel select', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('select-Channel')).toBeInTheDocument();
});
it('renders the Rule name input', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('textinput-Rule name')).toBeInTheDocument();
});
it('renders the Every (days of week) multiselect', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('multiselect-Every')).toBeInTheDocument();
});
it('renders Start date and End date pickers', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('datepicker-Start date')).toBeInTheDocument();
expect(screen.getByTestId('datepicker-End date')).toBeInTheDocument();
});
it('renders Start time and End time inputs', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('timeinput-Start time')).toBeInTheDocument();
expect(screen.getByTestId('timeinput-End time')).toBeInTheDocument();
});
it('renders the enabled/paused Switch', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('switch')).toBeInTheDocument();
});
it('switch reflects rule enabled state', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('switch')).toBeChecked();
});
it('switch is unchecked when rule is paused', () => {
setupMocks({ rule: makeRule({ enabled: false }) });
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('switch')).not.toBeChecked();
});
it('renders channel name in header', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByText('HBO')).toBeInTheDocument();
});
it('renders channel number fallback when channel not in allChannels', async () => {
vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([]);
render(<RecurringRuleModal {...defaultProps()} />);
await waitFor(() => {
expect(screen.getByText('Channel ch-1')).toBeInTheDocument();
});
});
});
// Channel loading
describe('channel loading', () => {
it('calls getChannelsSummary when opened', async () => {
render(<RecurringRuleModal {...defaultProps()} />);
await waitFor(() => {
expect(RecordingUtils.getChannelsSummary).toHaveBeenCalled();
});
});
it('does not call getChannelsSummary when closed', () => {
render(<RecurringRuleModal {...defaultProps()} opened={false} />);
expect(RecordingUtils.getChannelsSummary).not.toHaveBeenCalled();
});
it('calls sortedChannelOptions with loaded channels', async () => {
render(<RecurringRuleModal {...defaultProps()} />);
await waitFor(() => {
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith([
makeChannel(),
]);
});
});
it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => {
vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(
new Error('fail')
);
render(<RecurringRuleModal {...defaultProps()} />);
await waitFor(() => {
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith([]);
});
});
});
// Form initialization from rule
describe('form initialization', () => {
it('calls getFormDefaults with rule when opened', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(RecurringRuleModalUtils.getFormDefaults).toHaveBeenCalledWith(
makeRule()
);
});
it('calls form.setValues with getFormDefaults result', () => {
const expectedDefaults = {
channel_id: 'ch-1',
rule_name: 'Morning News',
days_of_week: ['mon', 'tue'],
start_date: new Date('2024-01-01'),
end_date: null,
start_time: '08:00',
end_time: '09:00',
enabled: true,
};
vi.mocked(RecurringRuleModalUtils.getFormDefaults).mockReturnValue(
expectedDefaults
);
render(<RecurringRuleModal {...defaultProps()} />);
// form instance is created during render read after
const formMock = vi.mocked(useForm).mock.results[0].value;
expect(formMock.setValues).toHaveBeenCalledWith(expectedDefaults);
});
});
// Save changes
describe('save changes', () => {
it('calls updateRecurringRule on form submit', async () => {
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.submit(screen.getByText('Save changes').closest('form'));
await waitFor(() => {
expect(
RecurringRuleModalUtils.updateRecurringRule
).toHaveBeenCalledWith('rule-1', expect.any(Object));
});
});
it('calls fetchRecurringRules after save', async () => {
const { mockFetchRecurringRules } = setupMocks();
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.submit(screen.getByText('Save changes').closest('form'));
await waitFor(() => {
expect(mockFetchRecurringRules).toHaveBeenCalled();
});
});
it('shows "Recurring rule updated" notification on success', async () => {
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.submit(screen.getByText('Save changes').closest('form'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Recurring rule updated',
color: 'green',
})
);
});
});
it('calls onClose after successful save', async () => {
const onClose = vi.fn();
render(<RecurringRuleModal {...defaultProps()} onClose={onClose} />);
fireEvent.submit(screen.getByText('Save changes').closest('form'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('does not show notification when updateRecurringRule throws', async () => {
vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockRejectedValue(
new Error('fail')
);
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.submit(screen.getByText('Save changes').closest('form'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
it('Save changes button shows loading state while saving', async () => {
let resolve;
vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockReturnValue(
new Promise((r) => {
resolve = r;
})
);
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.submit(screen.getByText('Save changes').closest('form'));
await waitFor(() => {
expect(screen.getByText('Save changes')).toHaveAttribute(
'data-loading',
'true'
);
});
resolve();
});
});
// Delete rule
describe('delete rule', () => {
it('renders the Delete rule button', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByText('Delete rule')).toBeInTheDocument();
});
it('calls deleteRecurringRuleById when Delete rule is clicked', async () => {
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Delete rule'));
await waitFor(() => {
expect(
RecurringRuleModalUtils.deleteRecurringRuleById
).toHaveBeenCalledWith('rule-1');
});
});
it('calls fetchRecurringRules after delete', async () => {
const { mockFetchRecurringRules } = setupMocks();
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Delete rule'));
await waitFor(() => {
expect(mockFetchRecurringRules).toHaveBeenCalled();
});
});
it('shows "Recurring rule removed" notification on success', async () => {
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Delete rule'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Recurring rule removed',
color: 'red',
})
);
});
});
it('calls onClose after successful delete', async () => {
const onClose = vi.fn();
render(<RecurringRuleModal {...defaultProps()} onClose={onClose} />);
fireEvent.click(screen.getByText('Delete rule'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('does not show notification when deleteRecurringRuleById throws', async () => {
vi.mocked(
RecurringRuleModalUtils.deleteRecurringRuleById
).mockRejectedValue(new Error('fail'));
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Delete rule'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
// Toggle enabled
describe('toggle enabled', () => {
it('calls updateRecurringRuleEnabled with true when switch is toggled on', async () => {
setupMocks({ rule: makeRule({ enabled: false }) });
render(<RecurringRuleModal {...defaultProps()} />);
const sw = screen.getByTestId('switch');
expect(sw).not.toBeChecked();
fireEvent.click(sw);
await waitFor(() => {
expect(
RecurringRuleModalUtils.updateRecurringRuleEnabled
).toHaveBeenCalledWith('rule-1', true);
});
});
it('calls updateRecurringRuleEnabled with false when switch is toggled off', async () => {
render(<RecurringRuleModal {...defaultProps()} />);
const sw = screen.getByTestId('switch');
expect(sw).toBeChecked();
fireEvent.click(sw);
await waitFor(() => {
expect(
RecurringRuleModalUtils.updateRecurringRuleEnabled
).toHaveBeenCalledWith('rule-1', false);
});
});
it('shows "Recurring rule enabled" notification when enabling', async () => {
setupMocks({ rule: makeRule({ enabled: false }) });
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('switch'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Recurring rule enabled',
color: 'green',
})
);
});
});
it('shows "Recurring rule paused" notification when disabling', async () => {
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('switch'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Recurring rule paused',
color: 'yellow',
})
);
});
});
it('calls fetchRecurringRules after toggle', async () => {
const { mockFetchRecurringRules } = setupMocks();
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('switch'));
await waitFor(() => {
expect(mockFetchRecurringRules).toHaveBeenCalled();
});
});
it('does not show notification when updateRecurringRuleEnabled throws', async () => {
vi.mocked(
RecurringRuleModalUtils.updateRecurringRuleEnabled
).mockRejectedValue(new Error('fail'));
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByTestId('switch'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
// Upcoming occurrences
describe('upcoming occurrences', () => {
it('renders the occurrence count badge', () => {
setupMocks({ occurrences: [makeOccurrence(), makeOccurrence('occ-2')] });
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByTestId('badge')).toHaveTextContent('2');
});
it('shows "No future airings" when no upcoming occurrences', () => {
setupMocks({ occurrences: [] });
render(<RecurringRuleModal {...defaultProps()} />);
expect(
screen.getByText('No future airings currently scheduled.')
).toBeInTheDocument();
});
it('renders occurrence cards when occurrences exist', () => {
setupMocks({
occurrences: [makeOccurrence(), makeOccurrence('occ-2')],
});
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getAllByTestId('occurrence-card')).toHaveLength(2);
});
it('renders Edit and Cancel buttons for each occurrence', () => {
setupMocks({ occurrences: [makeOccurrence()] });
render(<RecurringRuleModal {...defaultProps()} />);
expect(screen.getByText('Edit')).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
});
it('calls getUpcomingOccurrences with recordings, userNow, ruleId, toUserTime', () => {
render(<RecurringRuleModal {...defaultProps()} />);
expect(
RecurringRuleModalUtils.getUpcomingOccurrences
).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Function),
'rule-1',
expect.any(Function)
);
});
});
// Edit occurrence
describe('edit occurrence', () => {
it('calls onClose and onEditOccurrence when Edit is clicked', () => {
const occ = makeOccurrence();
setupMocks({ occurrences: [occ] });
const onClose = vi.fn();
const onEditOccurrence = vi.fn();
render(
<RecurringRuleModal
{...defaultProps()}
onClose={onClose}
onEditOccurrence={onEditOccurrence}
/>
);
fireEvent.click(screen.getByText('Edit'));
expect(onClose).toHaveBeenCalled();
expect(onEditOccurrence).toHaveBeenCalledWith(occ);
});
it('does not throw when onEditOccurrence is not provided', () => {
const occ = makeOccurrence();
setupMocks({ occurrences: [occ] });
render(
<RecurringRuleModal {...defaultProps()} onEditOccurrence={undefined} />
);
expect(() => fireEvent.click(screen.getByText('Edit'))).not.toThrow();
});
});
// Cancel occurrence
describe('cancel occurrence', () => {
it('calls deleteRecordingById when Cancel is clicked', async () => {
const occ = makeOccurrence();
setupMocks({ occurrences: [occ] });
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(deleteRecordingById).toHaveBeenCalledWith('occ-1');
});
});
it('shows "Occurrence cancelled" notification on success', async () => {
const occ = makeOccurrence();
setupMocks({ occurrences: [occ] });
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Occurrence cancelled',
color: 'yellow',
})
);
});
});
it('does not show notification when deleteRecordingById throws', async () => {
vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
setupMocks({ occurrences: [makeOccurrence()] });
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
it('shows loading state on Cancel button while deleting occurrence', async () => {
let resolve;
vi.mocked(deleteRecordingById).mockReturnValue(
new Promise((r) => {
resolve = r;
})
);
setupMocks({ occurrences: [makeOccurrence()] });
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(screen.getByText('Cancel')).toHaveAttribute(
'data-loading',
'true'
);
});
resolve();
});
});
// Time/date field handlers
describe('field change handlers', () => {
it('calls toTimeString when start time changes', () => {
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.change(screen.getByTestId('timeinput-Start time'), {
target: { value: '09:00' },
});
expect(toTimeString).toHaveBeenCalledWith('09:00');
});
it('calls toTimeString when end time changes', () => {
render(<RecurringRuleModal {...defaultProps()} />);
fireEvent.change(screen.getByTestId('timeinput-End time'), {
target: { value: '10:00' },
});
expect(toTimeString).toHaveBeenCalledWith('10:00');
});
it('updates end_date field when end date picker changes', () => {
render(<RecurringRuleModal {...defaultProps()} />);
const picker = screen.getByTestId('datepicker-End date');
fireEvent.change(picker, { target: { value: '2024-12-31' } });
// No throw handler wired correctly
});
it('updates start_date field when start date picker changes', () => {
render(<RecurringRuleModal {...defaultProps()} />);
const picker = screen.getByTestId('datepicker-Start date');
fireEvent.change(picker, { target: { value: '2024-07-01' } });
// No throw handler wired correctly
});
});
});

View file

@ -0,0 +1,427 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ScheduleInput from '../ScheduleInput';
// Mantine core
vi.mock('@mantine/core', () => ({
TextInput: ({
label,
placeholder,
description,
value,
onChange,
error,
disabled,
}) => (
<div>
<label>{typeof label === 'string' ? label : 'Cron Expression'}</label>
<input
data-testid="textinput-cron"
placeholder={placeholder}
value={value}
onChange={onChange}
disabled={disabled}
aria-invalid={!!error}
/>
{description && <span data-testid="cron-description">{description}</span>}
{error && <span data-testid="cron-error">{error}</span>}
</div>
),
NumberInput: ({ label, description, value, onChange, min, disabled }) => (
<div>
<label>{label}</label>
<input
data-testid="numberinput-interval"
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
min={min}
disabled={disabled}
/>
{description && (
<span data-testid="interval-description">{description}</span>
)}
</div>
),
Anchor: ({ children, onClick }) => (
<a
data-testid={`anchor-${String(children).replace(/\s+/g, '-').toLowerCase()}`}
onClick={onClick}
href="#"
>
{children}
</a>
),
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
Code: ({ children }) => <code>{children}</code>,
Group: ({ children }) => <div>{children}</div>,
Popover: ({ children }) => <div>{children}</div>,
PopoverTarget: ({ children }) => <div>{children}</div>,
PopoverDropdown: ({ children }) => (
<div data-testid="popover-dropdown">{children}</div>
),
ActionIcon: ({ children, onClick }) => (
<button data-testid="action-icon-info" onClick={onClick}>
{children}
</button>
),
}));
// lucide-react
vi.mock('lucide-react', () => ({
Info: () => <svg data-testid="icon-info" />,
}));
// cronUtils
vi.mock('../../../utils/cronUtils', () => ({
validateCronExpression: vi.fn(),
}));
// CronBuilder
vi.mock('../CronBuilder', () => ({
default: ({ opened, onClose, onApply, currentValue }) =>
opened ? (
<div data-testid="cron-builder">
<span data-testid="cron-builder-value">{currentValue}</span>
<button
data-testid="cron-builder-apply"
onClick={() => onApply('0 3 * * *')}
>
Apply
</button>
<button data-testid="cron-builder-close" onClick={onClose}>
Close
</button>
</div>
) : null,
}));
//
import { validateCronExpression } from '../../../utils/cronUtils';
const defaultIntervalProps = () => ({
scheduleType: 'interval',
onScheduleTypeChange: vi.fn(),
intervalValue: 6,
onIntervalChange: vi.fn(),
cronValue: '',
onCronChange: vi.fn(),
});
const defaultCronProps = () => ({
scheduleType: 'cron',
onScheduleTypeChange: vi.fn(),
cronValue: '0 3 * * *',
onCronChange: vi.fn(),
intervalValue: 0,
onIntervalChange: vi.fn(),
});
describe('ScheduleInput', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
});
// Interval mode
describe('interval mode', () => {
it('renders NumberInput in interval mode', () => {
render(<ScheduleInput {...defaultIntervalProps()} />);
expect(screen.getByTestId('numberinput-interval')).toBeInTheDocument();
});
it('displays the intervalValue', () => {
render(<ScheduleInput {...defaultIntervalProps()} />);
expect(screen.getByTestId('numberinput-interval')).toHaveValue(6);
});
it('renders default interval label', () => {
render(<ScheduleInput {...defaultIntervalProps()} />);
expect(screen.getByText('Refresh Interval (hours)')).toBeInTheDocument();
});
it('renders custom intervalLabel', () => {
render(
<ScheduleInput
{...defaultIntervalProps()}
intervalLabel="My Custom Label"
/>
);
expect(screen.getByText('My Custom Label')).toBeInTheDocument();
});
it('renders default interval description', () => {
render(<ScheduleInput {...defaultIntervalProps()} />);
expect(screen.getByTestId('interval-description')).toHaveTextContent(
'How often to refresh (0 to disable)'
);
});
it('calls onIntervalChange when value changes', () => {
const props = defaultIntervalProps();
render(<ScheduleInput {...props} />);
fireEvent.change(screen.getByTestId('numberinput-interval'), {
target: { value: '12' },
});
expect(props.onIntervalChange).toHaveBeenCalledWith(12);
});
it('shows "Use cron schedule" link in interval mode', () => {
render(<ScheduleInput {...defaultIntervalProps()} />);
expect(
screen.getByTestId('anchor-use-cron-schedule')
).toBeInTheDocument();
});
it('calls onScheduleTypeChange("cron") when "Use cron schedule" is clicked', () => {
const props = defaultIntervalProps();
render(<ScheduleInput {...props} />);
fireEvent.click(screen.getByTestId('anchor-use-cron-schedule'));
expect(props.onScheduleTypeChange).toHaveBeenCalledWith('cron');
});
it('does not show cron switch link when disabled', () => {
render(<ScheduleInput {...defaultIntervalProps()} disabled />);
expect(
screen.queryByTestId('anchor-use-cron-schedule')
).not.toBeInTheDocument();
});
it('disables the NumberInput when disabled prop is true', () => {
render(<ScheduleInput {...defaultIntervalProps()} disabled />);
expect(screen.getByTestId('numberinput-interval')).toBeDisabled();
});
it('renders custom switchToCronLabel', () => {
render(
<ScheduleInput
{...defaultIntervalProps()}
switchToCronLabel="Use custom cron schedule"
/>
);
expect(screen.getByText('Use custom cron schedule')).toBeInTheDocument();
});
});
// Children (simple) mode
describe('children (simple) mode', () => {
it('renders children instead of NumberInput', () => {
render(
<ScheduleInput {...defaultIntervalProps()}>
<div data-testid="custom-child">Custom content</div>
</ScheduleInput>
);
expect(screen.getByTestId('custom-child')).toBeInTheDocument();
expect(
screen.queryByTestId('numberinput-interval')
).not.toBeInTheDocument();
});
it('shows cron toggle link below children', () => {
render(
<ScheduleInput {...defaultIntervalProps()}>
<div>Child</div>
</ScheduleInput>
);
expect(
screen.getByTestId('anchor-use-cron-schedule')
).toBeInTheDocument();
});
it('does not show cron link when disabled with children', () => {
render(
<ScheduleInput {...defaultIntervalProps()} disabled>
<div>Child</div>
</ScheduleInput>
);
expect(
screen.queryByTestId('anchor-use-cron-schedule')
).not.toBeInTheDocument();
});
});
// Cron mode
describe('cron mode', () => {
it('renders TextInput in cron mode', () => {
render(<ScheduleInput {...defaultCronProps()} />);
expect(screen.getByTestId('textinput-cron')).toBeInTheDocument();
});
it('displays the cronValue', () => {
render(<ScheduleInput {...defaultCronProps()} />);
expect(screen.getByTestId('textinput-cron')).toHaveValue('0 3 * * *');
});
it('shows "Use interval schedule" link in cron mode', () => {
render(<ScheduleInput {...defaultCronProps()} />);
expect(
screen.getByTestId('anchor-use-interval-schedule')
).toBeInTheDocument();
});
it('shows "Open Cron Builder" link', () => {
render(<ScheduleInput {...defaultCronProps()} />);
expect(
screen.getByTestId('anchor-open-cron-builder')
).toBeInTheDocument();
});
it('calls onScheduleTypeChange("interval") and onCronChange("") when switching to interval', () => {
const props = defaultCronProps();
render(<ScheduleInput {...props} />);
fireEvent.click(screen.getByTestId('anchor-use-interval-schedule'));
expect(props.onScheduleTypeChange).toHaveBeenCalledWith('interval');
expect(props.onCronChange).toHaveBeenCalledWith('');
});
it('calls onCronChange when cron input changes', () => {
const props = defaultCronProps();
render(<ScheduleInput {...props} />);
fireEvent.change(screen.getByTestId('textinput-cron'), {
target: { value: '0 5 * * *' },
});
expect(props.onCronChange).toHaveBeenCalledWith('0 5 * * *');
});
it('disables the TextInput when disabled prop is true', () => {
render(<ScheduleInput {...defaultCronProps()} disabled />);
expect(screen.getByTestId('textinput-cron')).toBeDisabled();
});
it('does not show cron links when disabled', () => {
render(<ScheduleInput {...defaultCronProps()} disabled />);
expect(
screen.queryByTestId('anchor-use-interval-schedule')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('anchor-open-cron-builder')
).not.toBeInTheDocument();
});
it('renders custom switchToIntervalLabel', () => {
render(
<ScheduleInput
{...defaultCronProps()}
switchToIntervalLabel="Use simple schedule"
/>
);
expect(screen.getByText('Use simple schedule')).toBeInTheDocument();
});
});
// Cron validation
describe('cron validation', () => {
it('shows no error when cron is valid', () => {
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
render(<ScheduleInput {...defaultCronProps()} />);
expect(screen.queryByTestId('cron-error')).not.toBeInTheDocument();
});
it('shows error message when cron is invalid', async () => {
vi.mocked(validateCronExpression).mockReturnValue({
valid: false,
error: 'Invalid cron expression',
});
render(<ScheduleInput {...defaultCronProps()} cronValue="bad cron" />);
await waitFor(() => {
expect(screen.getByTestId('cron-error')).toHaveTextContent(
'Invalid cron expression'
);
});
});
it('clears error when cron input is cleared', async () => {
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
const props = { ...defaultCronProps(), cronValue: '' };
render(<ScheduleInput {...props} />);
await waitFor(() => {
expect(screen.queryByTestId('cron-error')).not.toBeInTheDocument();
});
});
it('calls validateCronExpression on cron value change', () => {
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
const props = defaultCronProps();
render(<ScheduleInput {...props} />);
fireEvent.change(screen.getByTestId('textinput-cron'), {
target: { value: '0 6 * * 1' },
});
expect(validateCronExpression).toHaveBeenCalledWith('0 6 * * 1');
});
it('does not call validateCronExpression in interval mode', () => {
render(<ScheduleInput {...defaultIntervalProps()} />);
expect(validateCronExpression).not.toHaveBeenCalled();
});
});
// Cron Builder
describe('CronBuilder', () => {
it('CronBuilder is not visible initially', () => {
render(<ScheduleInput {...defaultCronProps()} />);
expect(screen.queryByTestId('cron-builder')).not.toBeInTheDocument();
});
it('opens CronBuilder when "Open Cron Builder" is clicked', () => {
render(<ScheduleInput {...defaultCronProps()} />);
fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
expect(screen.getByTestId('cron-builder')).toBeInTheDocument();
});
it('passes current cronValue to CronBuilder', () => {
render(<ScheduleInput {...defaultCronProps()} cronValue="0 4 * * *" />);
fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
expect(screen.getByTestId('cron-builder-value')).toHaveTextContent(
'0 4 * * *'
);
});
it('closes CronBuilder when onClose is triggered', () => {
render(<ScheduleInput {...defaultCronProps()} />);
fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
fireEvent.click(screen.getByTestId('cron-builder-close'));
expect(screen.queryByTestId('cron-builder')).not.toBeInTheDocument();
});
it('calls onCronChange with applied value from CronBuilder', () => {
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
const props = defaultCronProps();
render(<ScheduleInput {...props} />);
fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
fireEvent.click(screen.getByTestId('cron-builder-apply'));
expect(props.onCronChange).toHaveBeenCalledWith('0 3 * * *');
});
});
// Default props
describe('default props', () => {
it('defaults to interval mode when scheduleType is not provided', () => {
render(
<ScheduleInput
onScheduleTypeChange={vi.fn()}
onIntervalChange={vi.fn()}
onCronChange={vi.fn()}
/>
);
expect(screen.getByTestId('numberinput-interval')).toBeInTheDocument();
});
it('defaults intervalValue to 0', () => {
render(
<ScheduleInput
onScheduleTypeChange={vi.fn()}
onIntervalChange={vi.fn()}
onCronChange={vi.fn()}
/>
);
expect(screen.getByTestId('numberinput-interval')).toHaveValue(0);
});
});
});

View file

@ -0,0 +1,455 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import SeriesRecordingModal from '../SeriesRecordingModal';
// Mantine core
vi.mock('@mantine/core', () => ({
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, size, c, fw }) => (
<span data-testid="text" data-size={size} data-color={c} data-fw={fw}>
{children}
</span>
),
Flex: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, variant, color, disabled }) => (
<button
onClick={onClick}
data-variant={variant}
data-color={color}
disabled={disabled}
>
{children}
</button>
),
Badge: ({ children, color }) => (
<span data-testid="badge" data-color={color}>
{children}
</span>
),
}));
// Store
vi.mock('../../../store/channels.jsx', () => ({
default: { getState: vi.fn() },
}));
// Utils
vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
deleteSeriesAndRule: vi.fn(),
}));
vi.mock('../../../utils/guideUtils.js', () => ({
evaluateSeriesRulesByTvgId: vi.fn(),
fetchRules: vi.fn(),
}));
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
// SeriesRuleEditorModal
vi.mock('../SeriesRuleEditorModal', () => ({
default: ({ opened, onClose, initialRule, onSaved }) =>
opened ? (
<div data-testid="series-rule-editor">
<span data-testid="editor-rule">
{initialRule ? initialRule.title : 'new'}
</span>
<button data-testid="editor-close" onClick={onClose}>
Close
</button>
<button data-testid="editor-save" onClick={onSaved}>
Save
</button>
</div>
) : null,
}));
//
import useChannelsStore from '../../../store/channels.jsx';
import { deleteSeriesAndRule } from '../../../utils/cards/RecordingCardUtils.js';
import {
evaluateSeriesRulesByTvgId,
fetchRules,
} from '../../../utils/guideUtils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
const makeRule = (overrides = {}) => ({
tvg_id: 'tvg-1',
title: 'Test Show',
mode: 'new',
title_mode: 'exact',
description: '',
channel_id: null,
...overrides,
});
const defaultProps = (overrides = {}) => ({
opened: true,
onClose: vi.fn(),
rules: [makeRule()],
onRulesUpdate: vi.fn(),
...overrides,
});
const mockFetchRecordings = vi.fn().mockResolvedValue(undefined);
const setupMocks = () => {
vi.mocked(useChannelsStore.getState).mockReturnValue({
fetchRecordings: mockFetchRecordings,
});
vi.mocked(fetchRules).mockResolvedValue([makeRule()]);
vi.mocked(evaluateSeriesRulesByTvgId).mockResolvedValue(undefined);
vi.mocked(deleteSeriesAndRule).mockResolvedValue(undefined);
};
describe('SeriesRecordingModal', () => {
beforeEach(() => {
vi.clearAllMocks();
setupMocks();
});
// Visibility
describe('visibility', () => {
it('renders when opened is true', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render when opened is false', () => {
render(<SeriesRecordingModal {...defaultProps({ opened: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('renders modal title "Series Recording Rules"', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Series Recording Rules'
);
});
it('calls onClose when modal close button is clicked', () => {
const props = defaultProps();
render(<SeriesRecordingModal {...props} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(props.onClose).toHaveBeenCalled();
});
});
// Empty state
describe('empty state', () => {
it('shows "No series rules configured" when rules is empty', () => {
render(<SeriesRecordingModal {...defaultProps({ rules: [] })} />);
expect(
screen.getByText('No series rules configured')
).toBeInTheDocument();
});
it('shows "No series rules configured" when rules is null', () => {
render(<SeriesRecordingModal {...defaultProps({ rules: null })} />);
expect(
screen.getByText('No series rules configured')
).toBeInTheDocument();
});
it('does not show "No series rules configured" when rules are present', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(
screen.queryByText('No series rules configured')
).not.toBeInTheDocument();
});
});
// Rule rendering
describe('rule rendering', () => {
it('renders rule title', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(screen.getByText('Test Show')).toBeInTheDocument();
});
it('falls back to tvg_id when rule has no title', () => {
render(
<SeriesRecordingModal
{...defaultProps({
rules: [makeRule({ title: '', tvg_id: 'tvg-fallback' })],
})}
/>
);
expect(screen.getByText('tvg-fallback')).toBeInTheDocument();
});
it('renders "Pinned channel" badge when channel_id is set', () => {
render(
<SeriesRecordingModal
{...defaultProps({ rules: [makeRule({ channel_id: 'ch-1' })] })}
/>
);
expect(screen.getByText('Pinned channel')).toBeInTheDocument();
});
it('does not render "Pinned channel" badge when channel_id is null', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(screen.queryByText('Pinned channel')).not.toBeInTheDocument();
});
it('renders multiple rules', () => {
const rules = [
makeRule({ tvg_id: 'tvg-1', title: 'Show One' }),
makeRule({ tvg_id: 'tvg-2', title: 'Show Two' }),
];
render(<SeriesRecordingModal {...defaultProps({ rules })} />);
expect(screen.getByText('Show One')).toBeInTheDocument();
expect(screen.getByText('Show Two')).toBeInTheDocument();
});
it('renders Edit, Evaluate Now, and Remove buttons for each rule', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(screen.getByText('Edit')).toBeInTheDocument();
expect(screen.getByText('Evaluate Now')).toBeInTheDocument();
expect(screen.getByText('Remove')).toBeInTheDocument();
});
it('renders "Add rule" button', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(screen.getByText('Add rule')).toBeInTheDocument();
});
});
// Rule summary
describe('rule summary', () => {
it('shows "New episodes" for mode new', () => {
render(
<SeriesRecordingModal
{...defaultProps({ rules: [makeRule({ mode: 'new' })] })}
/>
);
expect(screen.getByText(/New episodes/)).toBeInTheDocument();
});
it('shows "Every episode" for mode all', () => {
render(
<SeriesRecordingModal
{...defaultProps({ rules: [makeRule({ mode: 'all' })] })}
/>
);
expect(screen.getByText(/Every episode/)).toBeInTheDocument();
});
it('shows exact title label in summary', () => {
render(
<SeriesRecordingModal
{...defaultProps({
rules: [makeRule({ title_mode: 'exact', title: 'Test Show' })],
})}
/>
);
expect(screen.getByText(/Exact title: "Test Show"/)).toBeInTheDocument();
});
it('shows contains label in summary', () => {
render(
<SeriesRecordingModal
{...defaultProps({
rules: [makeRule({ title_mode: 'contains', title: 'Test' })],
})}
/>
);
expect(screen.getByText(/Title contains: "Test"/)).toBeInTheDocument();
});
it('shows regex label in summary', () => {
render(
<SeriesRecordingModal
{...defaultProps({
rules: [makeRule({ title_mode: 'regex', title: '^Test' })],
})}
/>
);
expect(screen.getByText(/Title regex: "\^Test"/)).toBeInTheDocument();
});
it('shows description in summary when present', () => {
render(
<SeriesRecordingModal
{...defaultProps({ rules: [makeRule({ description: 'A drama' })] })}
/>
);
expect(screen.getByText(/Description: "A drama"/)).toBeInTheDocument();
});
it('does not show description in summary when empty', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
expect(screen.queryByText(/Description:/)).not.toBeInTheDocument();
});
});
// Evaluate Now
describe('Evaluate Now', () => {
it('calls evaluateSeriesRulesByTvgId with rule tvg_id', async () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Evaluate Now'));
await waitFor(() => {
expect(evaluateSeriesRulesByTvgId).toHaveBeenCalledWith('tvg-1');
});
});
it('calls fetchRecordings after evaluation', async () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Evaluate Now'));
await waitFor(() => {
expect(mockFetchRecordings).toHaveBeenCalled();
});
});
it('shows "Evaluated" notification after evaluation', async () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Evaluate Now'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Evaluated',
message: 'Checked for episodes',
})
);
});
});
it('still shows notification when fetchRecordings rejects', async () => {
mockFetchRecordings.mockRejectedValueOnce(new Error('network'));
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Evaluate Now'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Evaluated' })
);
});
});
});
// Remove series
describe('Remove series', () => {
it('calls deleteSeriesAndRule with tvg_id and title', async () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Remove'));
await waitFor(() => {
expect(deleteSeriesAndRule).toHaveBeenCalledWith({
tvg_id: 'tvg-1',
title: 'Test Show',
});
});
});
it('calls fetchRecordings after removal', async () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Remove'));
await waitFor(() => {
expect(mockFetchRecordings).toHaveBeenCalled();
});
});
it('calls fetchRules after removal', async () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Remove'));
await waitFor(() => {
expect(fetchRules).toHaveBeenCalled();
});
});
it('calls onRulesUpdate with fetched rules after removal', async () => {
const updated = [makeRule({ title: 'Updated Show' })];
vi.mocked(fetchRules).mockResolvedValue(updated);
const props = defaultProps();
render(<SeriesRecordingModal {...props} />);
fireEvent.click(screen.getByText('Remove'));
await waitFor(() => {
expect(props.onRulesUpdate).toHaveBeenCalledWith(updated);
});
});
it('still calls onRulesUpdate when fetchRecordings rejects', async () => {
mockFetchRecordings.mockRejectedValueOnce(new Error('network'));
const props = defaultProps();
render(<SeriesRecordingModal {...props} />);
fireEvent.click(screen.getByText('Remove'));
await waitFor(() => {
expect(props.onRulesUpdate).toHaveBeenCalled();
});
});
});
// Add rule / Editor
describe('Add rule', () => {
it('opens SeriesRuleEditorModal when "Add rule" is clicked', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add rule'));
expect(screen.getByTestId('series-rule-editor')).toBeInTheDocument();
});
it('passes null initialRule when opening via "Add rule"', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add rule'));
expect(screen.getByTestId('editor-rule')).toHaveTextContent('new');
});
it('closes SeriesRuleEditorModal when editor onClose fires', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Add rule'));
fireEvent.click(screen.getByTestId('editor-close'));
expect(
screen.queryByTestId('series-rule-editor')
).not.toBeInTheDocument();
});
});
// Edit rule
describe('Edit rule', () => {
it('opens SeriesRuleEditorModal with the selected rule when Edit is clicked', () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Edit'));
expect(screen.getByTestId('series-rule-editor')).toBeInTheDocument();
expect(screen.getByTestId('editor-rule')).toHaveTextContent('Test Show');
});
it('calls fetchRules and onRulesUpdate when editor onSaved fires', async () => {
const updated = [makeRule({ title: 'Saved Show' })];
vi.mocked(fetchRules).mockResolvedValue(updated);
const props = defaultProps();
render(<SeriesRecordingModal {...props} />);
fireEvent.click(screen.getByText('Edit'));
fireEvent.click(screen.getByTestId('editor-save'));
await waitFor(() => {
expect(fetchRules).toHaveBeenCalled();
expect(props.onRulesUpdate).toHaveBeenCalledWith(updated);
});
});
it('closes editor after save', async () => {
render(<SeriesRecordingModal {...defaultProps()} />);
fireEvent.click(screen.getByText('Edit'));
fireEvent.click(screen.getByTestId('editor-save'));
await waitFor(() => {
// editor is still open (onSaved doesn't auto-close) just verify no error
expect(fetchRules).toHaveBeenCalled();
});
});
});
});

View file

@ -0,0 +1,730 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Store mocks
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
vi.mock('../../../store/epgs.jsx', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils.js', () => ({
useDebounce: vi.fn((val) => val),
}));
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
getChannelsSummary: vi.fn(),
}));
vi.mock('../../../utils/guideUtils.js', () => ({
createSeriesRule: vi.fn(),
evaluateSeriesRulesByTvgId: vi.fn(),
}));
vi.mock('../../../utils/forms/SeriesRuleEditorModalUtils.js', () => ({
TITLE_MODES: [
{ label: 'Exact', value: 'exact' },
{ label: 'Contains', value: 'contains' },
],
DESCRIPTION_MODES: [
{ label: 'Contains', value: 'contains' },
{ label: 'Exact', value: 'exact' },
],
EPISODE_MODES: [
{ label: 'All', value: 'all' },
{ label: 'New only', value: 'new' },
],
formatRange: vi.fn((start, end) => `${start} - ${end}`),
getChannelOptions: vi.fn(() => [{ value: '10', label: '501 - HBO' }]),
getTvgOptions: vi.fn(),
previewSeriesRule: vi.fn(),
}));
// Mantine core
vi.mock('@mantine/core', () => ({
Alert: ({ children, color }) => (
<div data-testid="alert" data-color={color}>
{children}
</div>
),
Badge: ({ children, color }) => (
<span data-testid="badge" data-color={color}>
{children}
</span>
),
Button: ({ children, onClick, disabled, loading, variant }) => (
<button
onClick={onClick}
disabled={disabled || loading}
data-loading={loading}
data-variant={variant}
>
{children}
</button>
),
Divider: () => <hr />,
Group: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
ScrollAreaAutosize: ({ children }) => <div>{children}</div>,
SegmentedControl: ({ data, value, onChange, size }) => (
<div data-testid="segmented-control" data-size={size}>
{data.map((item) => (
<button
key={item.value}
data-active={value === item.value}
onClick={() => onChange(item.value)}
>
{item.label}
</button>
))}
</div>
),
Select: ({ label, placeholder, data, value, onChange, description }) => (
<div>
<label>{label}</label>
{description && (
<span data-testid="select-description">{description}</span>
)}
<select
data-testid={`select-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
onChange={(e) => onChange(e.target.value || null)}
>
<option value="">{placeholder}</option>
{(data || []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, size, c, fw }) => (
<span data-size={size} data-color={c} data-fw={fw}>
{children}
</span>
),
Textarea: ({ label, placeholder, value, onChange, description }) => (
<div>
<label>{label}</label>
{description && <span>{description}</span>}
<textarea
data-testid="textarea-description"
placeholder={placeholder}
value={value}
onChange={onChange}
/>
</div>
),
TextInput: ({ label, placeholder, value, onChange, description }) => (
<div>
<label>{label}</label>
{description && <span>{description}</span>}
<input
data-testid="input-title"
placeholder={placeholder}
value={value}
onChange={onChange}
/>
</div>
),
}));
// Imports after mocks
import SeriesRuleEditorModal from '../SeriesRuleEditorModal';
import useChannelsStore from '../../../store/channels.jsx';
import useEPGsStore from '../../../store/epgs.jsx';
import { useDebounce } from '../../../utils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
import { getChannelsSummary } from '../../../utils/forms/RecordingUtils.js';
import {
createSeriesRule,
evaluateSeriesRulesByTvgId,
} from '../../../utils/guideUtils.js';
import {
getTvgOptions,
previewSeriesRule,
formatRange,
} from '../../../utils/forms/SeriesRuleEditorModalUtils.js';
// Shared test data
const mockTvgs = [
{ id: 1, tvg_id: 'tvg-1', name: 'Channel One' },
{ id: 2, tvg_id: 'tvg-2', name: 'Channel Two' },
];
const mockTvgsById = {
1: { tvg_id: 'tvg-1', name: 'Channel One' },
2: { tvg_id: 'tvg-2', name: 'Channel Two' },
};
const mockChannels = [
{ id: 10, name: 'HBO', channel_number: 501, epg_data_id: 1 },
{ id: 20, name: 'CNN', channel_number: 102, epg_data_id: 2 },
];
const mockTvgOptions = [
{ value: 'tvg-1', label: 'Channel One (tvg-1)' },
{ value: 'tvg-2', label: 'Channel Two (tvg-2)' },
];
const mockPreviewResponse = {
matches: [
{
id: 'p1',
title: 'Match Show',
sub_title: 'Episode 1',
description: 'A great show',
start_time: '2024-01-01T10:00:00Z',
end_time: '2024-01-01T11:00:00Z',
tvg_id: 'tvg-1',
is_new: true,
},
],
total: 1,
epg_found: true,
};
// Setup helper
const setupMocks = () => {
const mockFetchRecordings = vi.fn().mockResolvedValue(undefined);
vi.mocked(useEPGsStore).mockImplementation((selector) =>
selector({ tvgs: mockTvgs, tvgsById: mockTvgsById })
);
// Mock getState for the imperative call in handleSave
useChannelsStore.getState = vi.fn().mockReturnValue({
fetchRecordings: mockFetchRecordings,
});
vi.mocked(getTvgOptions).mockReturnValue(mockTvgOptions);
vi.mocked(getChannelsSummary).mockResolvedValue(mockChannels);
vi.mocked(previewSeriesRule).mockResolvedValue({
matches: [],
total: 0,
epg_found: true,
});
vi.mocked(createSeriesRule).mockResolvedValue({ id: 'rule-1' });
vi.mocked(evaluateSeriesRulesByTvgId).mockResolvedValue(undefined);
vi.mocked(useDebounce).mockImplementation((val) => val);
vi.mocked(formatRange).mockImplementation((s, e) => `${s} - ${e}`);
return { mockFetchRecordings };
};
//
describe('SeriesRuleEditorModal', () => {
let defaultProps;
beforeEach(() => {
vi.clearAllMocks();
setupMocks();
defaultProps = {
opened: true,
onClose: vi.fn(),
initialRule: null,
onSaved: vi.fn(),
};
});
afterEach(() => {
vi.clearAllMocks();
});
// Rendering
describe('rendering', () => {
it('renders the modal when opened is true', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render the modal when opened is false', () => {
render(<SeriesRuleEditorModal {...defaultProps} opened={false} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('shows "New Series Rule" title when initialRule is null', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'New Series Rule'
);
});
it('shows "Edit Series Rule" title when initialRule is provided', () => {
render(
<SeriesRuleEditorModal
{...defaultProps}
initialRule={{ tvg_id: 'tvg-1', mode: 'all', title: 'Test' }}
/>
);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Edit Series Rule'
);
});
it('renders the title input', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
expect(screen.getByTestId('input-title')).toBeInTheDocument();
});
it('renders the description textarea', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
expect(screen.getByTestId('textarea-description')).toBeInTheDocument();
});
it('renders Cancel and Save rule buttons', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
expect(screen.getByText('Cancel')).toBeInTheDocument();
expect(screen.getByText('Save rule')).toBeInTheDocument();
});
it('Save rule button is disabled when no title or description', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
expect(screen.getByText('Save rule')).toBeDisabled();
});
it('Save rule button is enabled when title is provided', async () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'My Show' },
});
expect(screen.getByText('Save rule')).not.toBeDisabled();
});
it('Save rule button is enabled when description is provided', async () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('textarea-description'), {
target: { value: 'some description' },
});
expect(screen.getByText('Save rule')).not.toBeDisabled();
});
});
// State hydration from initialRule
describe('state hydration', () => {
const initialRule = {
tvg_id: 'tvg-1',
mode: 'new',
title: 'Pre-filled Title',
title_mode: 'contains',
description: 'Pre-filled description',
description_mode: 'exact',
channel_id: 10,
};
it('pre-fills title from initialRule', () => {
render(
<SeriesRuleEditorModal {...defaultProps} initialRule={initialRule} />
);
expect(screen.getByTestId('input-title')).toHaveValue('Pre-filled Title');
});
it('pre-fills description from initialRule', () => {
render(
<SeriesRuleEditorModal {...defaultProps} initialRule={initialRule} />
);
expect(screen.getByTestId('textarea-description')).toHaveValue(
'Pre-filled description'
);
});
it('uses default mode "all" when initialRule has no mode', () => {
render(
<SeriesRuleEditorModal {...defaultProps} initialRule={{ title: 'X' }} />
);
// "All" segmented button should be active (data-active=true)
const allBtn = screen
.getAllByText('All')
.find((el) => el.closest('[data-testid="segmented-control"]'));
expect(allBtn).toBeTruthy();
});
it('resets fields when modal is reopened without initialRule', async () => {
const { rerender } = render(
<SeriesRuleEditorModal {...defaultProps} initialRule={initialRule} />
);
rerender(
<SeriesRuleEditorModal
{...defaultProps}
opened={false}
initialRule={null}
/>
);
rerender(
<SeriesRuleEditorModal
{...defaultProps}
opened={true}
initialRule={null}
/>
);
expect(screen.getByTestId('input-title')).toHaveValue('');
expect(screen.getByTestId('textarea-description')).toHaveValue('');
});
});
// Channel loading
describe('channel loading', () => {
it('calls getChannelsSummary when modal opens', async () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
await waitFor(() => {
expect(getChannelsSummary).toHaveBeenCalled();
});
});
it('does not call getChannelsSummary when modal is closed', () => {
render(<SeriesRuleEditorModal {...defaultProps} opened={false} />);
expect(getChannelsSummary).not.toHaveBeenCalled();
});
it('handles getChannelsSummary rejection gracefully', async () => {
vi.mocked(getChannelsSummary).mockRejectedValue(
new Error('Network error')
);
render(<SeriesRuleEditorModal {...defaultProps} />);
// Should not throw; channel options just empty
await waitFor(() => {
expect(getChannelsSummary).toHaveBeenCalled();
});
});
});
// TVG options
describe('EPG channel options', () => {
it('calls getTvgOptions with tvgs from store', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
expect(getTvgOptions).toHaveBeenCalledWith(mockTvgs);
});
});
// Preview
describe('preview', () => {
it('does not call previewSeriesRule when title, description, and tvg_id are all empty', async () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
await waitFor(() => expect(getChannelsSummary).toHaveBeenCalled());
expect(previewSeriesRule).not.toHaveBeenCalled();
});
it('calls previewSeriesRule when title is entered', async () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'My Show' },
});
await waitFor(() => {
expect(previewSeriesRule).toHaveBeenCalledWith(
expect.objectContaining({ title: 'My Show' }),
expect.any(Object)
);
});
});
it('calls previewSeriesRule when description is entered', async () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('textarea-description'), {
target: { value: 'some words' },
});
await waitFor(() => {
expect(previewSeriesRule).toHaveBeenCalledWith(
expect.objectContaining({ description: 'some words' }),
expect.any(Object)
);
});
});
it('renders preview match results', async () => {
vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'Match Show' },
});
await waitFor(() => {
expect(screen.getByText(/Match Show/)).toBeInTheDocument();
});
});
it('renders preview badge with match count', async () => {
vi.mocked(previewSeriesRule).mockResolvedValue({
matches: [{ id: 'p1', title: 'X' }],
total: 5,
epg_found: true,
});
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'X' },
});
await waitFor(() => {
expect(screen.getByTestId('badge')).toHaveTextContent('1 of 5');
});
});
it('shows preview error alert when previewSeriesRule rejects', async () => {
vi.mocked(previewSeriesRule).mockRejectedValue(
new Error('Preview failed')
);
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'Boom' },
});
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
expect(screen.getByTestId('alert')).toHaveTextContent('Preview failed');
});
});
it('shows "No EPG channel matches" warning when epg_found is false and tvg_id is set', async () => {
vi.mocked(previewSeriesRule).mockResolvedValue({
matches: [],
total: 0,
epg_found: false,
});
render(
<SeriesRuleEditorModal
{...defaultProps}
initialRule={{ tvg_id: 'tvg-999', title: 'X' }}
/>
);
await waitFor(() => {
expect(
screen.getByText(/No EPG channel matches this tvg_id/)
).toBeInTheDocument();
});
});
it('shows warn alert when preview.warn is true', async () => {
vi.mocked(previewSeriesRule).mockResolvedValue({
matches: Array(50).fill({ id: 'x', title: 'X' }),
total: 50,
epg_found: true,
warn: true,
});
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'X' },
});
await waitFor(() => {
expect(
screen.getByText(/This rule matches many programs/)
).toBeInTheDocument();
});
});
it('shows "No matching upcoming programs" when matches empty and filter set', async () => {
vi.mocked(previewSeriesRule).mockResolvedValue({
matches: [],
total: 0,
epg_found: true,
});
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'Unknown Show' },
});
await waitFor(() => {
expect(
screen.getByText(/No matching upcoming programs/)
).toBeInTheDocument();
});
});
it('renders sub_title in preview match', async () => {
vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'Match Show' },
});
await waitFor(() => {
expect(screen.getByText(/Episode 1/)).toBeInTheDocument();
});
});
it('renders "(NEW)" marker for new episodes in preview', async () => {
vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'Match Show' },
});
await waitFor(() => {
expect(screen.getByText(/(NEW)/)).toBeInTheDocument();
});
});
});
// handleSave
describe('handleSave', () => {
const renderWithTitle = () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'My Show' },
});
};
it('calls createSeriesRule with correct payload', async () => {
renderWithTitle();
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(createSeriesRule).toHaveBeenCalledWith(
expect.objectContaining({ title: 'My Show', mode: 'all' })
);
});
});
it('calls evaluateSeriesRulesByTvgId after createSeriesRule', async () => {
renderWithTitle();
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(evaluateSeriesRulesByTvgId).toHaveBeenCalled();
});
});
it('calls fetchRecordings after save', async () => {
const { mockFetchRecordings } = setupMocks();
renderWithTitle();
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(mockFetchRecordings).toHaveBeenCalled();
});
});
it('calls showNotification on success', async () => {
renderWithTitle();
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Series rule saved' })
);
});
});
it('calls onSaved callback on success', async () => {
renderWithTitle();
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(defaultProps.onSaved).toHaveBeenCalled();
});
});
it('calls onClose after save', async () => {
renderWithTitle();
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
it('does not call showNotification when createSeriesRule throws', async () => {
vi.mocked(createSeriesRule).mockRejectedValue(new Error('Server error'));
renderWithTitle();
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(createSeriesRule).toHaveBeenCalled();
});
expect(showNotification).not.toHaveBeenCalled();
});
it('does not crash when evaluateSeriesRulesByTvgId throws', async () => {
vi.mocked(evaluateSeriesRulesByTvgId).mockRejectedValue(
new Error('eval fail')
);
renderWithTitle();
await expect(
waitFor(() => {
fireEvent.click(screen.getByText('Save rule'));
})
).resolves.not.toThrow();
});
it('does not crash when fetchRecordings throws', async () => {
useChannelsStore.getState = vi.fn().mockReturnValue({
fetchRecordings: vi.fn().mockRejectedValue(new Error('fetch fail')),
});
renderWithTitle();
await expect(
waitFor(() => {
fireEvent.click(screen.getByText('Save rule'));
})
).resolves.not.toThrow();
});
it('includes channel_id in payload when channelId is set', async () => {
vi.mocked(getChannelsSummary).mockResolvedValue(mockChannels);
render(
<SeriesRuleEditorModal
{...defaultProps}
initialRule={{ channel_id: 10, title: 'Show' }}
/>
);
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(createSeriesRule).toHaveBeenCalledWith(
expect.objectContaining({ channel_id: 10 })
);
});
});
it('omits channel_id from payload when no channel selected', async () => {
renderWithTitle();
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
const call = vi.mocked(createSeriesRule).mock.calls[0][0];
expect(call).not.toHaveProperty('channel_id');
});
});
});
// Cancel button
describe('Cancel button', () => {
it('calls onClose when Cancel is clicked', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.click(screen.getByText('Cancel'));
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
// SegmentedControl interactions
describe('SegmentedControl', () => {
it('changes title mode when SegmentedControl is clicked', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.click(
screen
.getAllByText('Contains')
.find((el) => el.closest('[data-testid="segmented-control"]'))
);
// No error thrown; state updated without crash
});
it('changes episode mode to "New only"', () => {
render(<SeriesRuleEditorModal {...defaultProps} />);
fireEvent.click(screen.getByText('New only'));
// Payload mode becomes 'new'; verify via save
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'Test' },
});
fireEvent.click(screen.getByText('Save rule'));
waitFor(() => {
expect(createSeriesRule).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'new' })
);
});
});
});
});

View file

@ -0,0 +1,572 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Module-level form state (survives re-renders, accessible in beforeEach)
const __form = { values: {}, resetSpy: null };
// Store mocks
vi.mock('../../../store/streamProfiles.jsx', () => ({ default: vi.fn() }));
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils/forms/StreamUtils.js', () => ({
addStream: vi.fn(),
getResolver: vi.fn(() => undefined),
updateStream: vi.fn(),
}));
// react-hook-form
vi.mock('react-hook-form', async () => {
const React = await import('react');
return {
useForm: vi.fn(({ defaultValues } = {}) => {
const [formValues, setFormValues] = React.useState(() => {
const vals = defaultValues || {};
Object.assign(__form.values, vals);
return vals;
});
const updateField = (name, value) => {
__form.values[name] = value;
setFormValues((prev) => ({ ...prev, [name]: value }));
};
const register = (name) => ({
name,
value: __form.values[name] ?? '',
onChange: (e) => updateField(name, e.target.value),
onBlur: () => {},
});
const setValue = (name, value) => updateField(name, value);
const watch = (name) => formValues[name];
const handleSubmit = (onSubmit) => (e) => {
e?.preventDefault?.();
return onSubmit({ ...__form.values });
};
// Keep reset stable across re-renders so useEffect([defaultValues, reset])
// in Stream.jsx does not fire on every render and cause an infinite loop.
const resetImpl = React.useCallback((newValues) => {
const vals = newValues || defaultValues || {};
Object.assign(__form.values, vals);
setFormValues({ ...vals });
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const resetRef = React.useRef(null);
if (!resetRef.current) {
resetRef.current = vi.fn((...args) => resetImpl(...args));
__form.resetSpy = resetRef.current;
}
const reset = resetRef.current;
return {
register,
handleSubmit,
formState: { errors: {}, isSubmitting: false },
reset,
setValue,
watch,
};
}),
};
});
// @mantine/core
vi.mock('@mantine/core', () => ({
Button: ({ children, type, disabled }) => (
<button type={type} disabled={disabled}>
{children}
</button>
),
Flex: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Select: ({ label, value, onChange, data, placeholder, error }) => (
<div>
<label>{label}</label>
<select
data-testid={`select-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
onChange={(e) => onChange(e.target.value || null)}
>
<option value="">{placeholder ?? `-- ${label} --`}</option>
{(data ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
TextInput: ({ label, name, value, onChange, error, ...rest }) => (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
data-testid={`input-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
onChange={onChange}
{...rest}
/>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
}));
// Imports after mocks
import Stream from '../Stream';
import useStreamProfilesStore from '../../../store/streamProfiles.jsx';
import useChannelsStore from '../../../store/channels.jsx';
import * as StreamUtils from '../../../utils/forms/StreamUtils.js';
// Shared test data
const mockProfiles = [
{ id: 1, name: 'Default' },
{ id: 2, name: 'HD Profile' },
];
const mockChannelGroups = {
10: { id: 10, name: 'Sports' },
20: { id: 20, name: 'News' },
};
const makeStream = (overrides = {}) => ({
id: 'stream-1',
name: 'Test Stream',
url: 'http://example.com/stream',
channel_group: 10,
stream_profile_id: 1,
...overrides,
});
const setupMocks = () => {
vi.mocked(useStreamProfilesStore).mockImplementation((sel) =>
sel({ profiles: mockProfiles })
);
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({ channelGroups: mockChannelGroups })
);
vi.mocked(StreamUtils.addStream).mockResolvedValue(undefined);
vi.mocked(StreamUtils.updateStream).mockResolvedValue(undefined);
};
const defaultProps = (overrides = {}) => ({
stream: null,
isOpen: true,
onClose: vi.fn(),
...overrides,
});
//
describe('Stream', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset module-level form state before each test
__form.values = {};
setupMocks();
});
// Visibility
describe('visibility', () => {
it('renders the modal when isOpen is true', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render when isOpen is false', () => {
render(<Stream {...defaultProps({ isOpen: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('renders "Stream" as the modal title', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Stream');
});
it('calls onClose when modal close button is clicked', () => {
const onClose = vi.fn();
render(<Stream {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Form fields
describe('form fields', () => {
it('renders Stream Name input', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByTestId('input-stream-name')).toBeInTheDocument();
});
it('renders Stream URL input', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByTestId('input-stream-url')).toBeInTheDocument();
});
it('renders Group select', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByTestId('select-group')).toBeInTheDocument();
});
it('renders Stream Profile select', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByTestId('select-stream-profile')).toBeInTheDocument();
});
it('renders Submit button', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByText('Submit')).toBeInTheDocument();
});
it('populates Group select with channel groups', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByText('Sports')).toBeInTheDocument();
expect(screen.getByText('News')).toBeInTheDocument();
});
it('populates Stream Profile select with profiles', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByText('Default')).toBeInTheDocument();
expect(screen.getByText('HD Profile')).toBeInTheDocument();
});
it('shows "Optional" placeholder for Stream Profile', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByText('Optional')).toBeInTheDocument();
});
});
// Default values
describe('default values', () => {
it('pre-fills name from stream prop', () => {
render(<Stream {...defaultProps({ stream: makeStream() })} />);
expect(screen.getByTestId('input-stream-name')).toHaveValue(
'Test Stream'
);
});
it('pre-fills url from stream prop', () => {
render(<Stream {...defaultProps({ stream: makeStream() })} />);
expect(screen.getByTestId('input-stream-url')).toHaveValue(
'http://example.com/stream'
);
});
it('pre-selects group from stream prop', () => {
render(<Stream {...defaultProps({ stream: makeStream() })} />);
expect(screen.getByTestId('select-group')).toHaveValue('10');
});
it('pre-selects stream profile from stream prop', () => {
render(<Stream {...defaultProps({ stream: makeStream() })} />);
expect(screen.getByTestId('select-stream-profile')).toHaveValue('1');
});
it('renders empty name when no stream provided', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByTestId('input-stream-name')).toHaveValue('');
});
it('renders empty url when no stream provided', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByTestId('input-stream-url')).toHaveValue('');
});
it('renders empty group when stream has no channel_group', () => {
render(
<Stream
{...defaultProps({ stream: makeStream({ channel_group: null }) })}
/>
);
expect(screen.getByTestId('select-group')).toHaveValue('');
});
it('renders empty profile when stream has no stream_profile_id', () => {
render(
<Stream
{...defaultProps({ stream: makeStream({ stream_profile_id: null }) })}
/>
);
expect(screen.getByTestId('select-stream-profile')).toHaveValue('');
});
});
// Create stream
describe('create stream (no existing stream)', () => {
it('calls addStream on submit when no stream id', async () => {
render(<Stream {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-stream-name'), {
target: { value: 'New Stream' },
});
fireEvent.change(screen.getByTestId('input-stream-url'), {
target: { value: 'http://new.stream/live' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(StreamUtils.addStream).toHaveBeenCalledWith(
expect.objectContaining({
name: 'New Stream',
url: 'http://new.stream/live',
})
);
});
});
it('does not call updateStream when creating', async () => {
render(<Stream {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-stream-name'), {
target: { value: 'New Stream' },
});
fireEvent.change(screen.getByTestId('input-stream-url'), {
target: { value: 'http://new.stream' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(StreamUtils.updateStream).not.toHaveBeenCalled();
});
});
it('calls onClose after successful create', async () => {
const onClose = vi.fn();
render(<Stream {...defaultProps({ onClose })} />);
fireEvent.change(screen.getByTestId('input-stream-name'), {
target: { value: 'New Stream' },
});
fireEvent.change(screen.getByTestId('input-stream-url'), {
target: { value: 'http://new.stream' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('converts channel_group string to integer in payload', async () => {
render(<Stream {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-stream-name'), {
target: { value: 'S' },
});
fireEvent.change(screen.getByTestId('input-stream-url'), {
target: { value: 'http://x.com' },
});
fireEvent.change(screen.getByTestId('select-group'), {
target: { value: '10' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(StreamUtils.addStream).toHaveBeenCalledWith(
expect.objectContaining({ channel_group: 10 })
);
});
});
it('sets channel_group to null when no group selected', async () => {
render(<Stream {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-stream-name'), {
target: { value: 'S' },
});
fireEvent.change(screen.getByTestId('input-stream-url'), {
target: { value: 'http://x.com' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
const call = vi.mocked(StreamUtils.addStream).mock.calls[0][0];
expect(call.channel_group).toBeNull();
});
});
it('converts stream_profile_id string to integer in payload', async () => {
render(<Stream {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-stream-name'), {
target: { value: 'S' },
});
fireEvent.change(screen.getByTestId('input-stream-url'), {
target: { value: 'http://x.com' },
});
fireEvent.change(screen.getByTestId('select-stream-profile'), {
target: { value: '2' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(StreamUtils.addStream).toHaveBeenCalledWith(
expect.objectContaining({ stream_profile_id: 2 })
);
});
});
it('sets stream_profile_id to null when no profile selected', async () => {
render(<Stream {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-stream-name'), {
target: { value: 'S' },
});
fireEvent.change(screen.getByTestId('input-stream-url'), {
target: { value: 'http://x.com' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
const call = vi.mocked(StreamUtils.addStream).mock.calls[0][0];
expect(call.stream_profile_id).toBeNull();
});
});
});
// Update stream
describe('update stream (existing stream)', () => {
it('calls updateStream with the stream id on submit', async () => {
render(<Stream {...defaultProps({ stream: makeStream() })} />);
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(StreamUtils.updateStream).toHaveBeenCalledWith(
'stream-1',
expect.objectContaining({
name: 'Test Stream',
url: 'http://example.com/stream',
})
);
});
});
it('does not call addStream when updating', async () => {
render(<Stream {...defaultProps({ stream: makeStream() })} />);
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(StreamUtils.addStream).not.toHaveBeenCalled();
});
});
it('calls onClose after successful update', async () => {
const onClose = vi.fn();
render(<Stream {...defaultProps({ stream: makeStream(), onClose })} />);
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('converts channel_group back to integer for update payload', async () => {
render(<Stream {...defaultProps({ stream: makeStream() })} />);
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(StreamUtils.updateStream).toHaveBeenCalledWith(
'stream-1',
expect.objectContaining({ channel_group: 10 })
);
});
});
it('converts stream_profile_id back to integer for update payload', async () => {
render(<Stream {...defaultProps({ stream: makeStream() })} />);
fireEvent.submit(
screen.getByRole('button', { name: 'Submit' }).closest('form')
);
await waitFor(() => {
expect(StreamUtils.updateStream).toHaveBeenCalledWith(
'stream-1',
expect.objectContaining({ stream_profile_id: 1 })
);
});
});
});
// getResolver
describe('getResolver', () => {
it('calls getResolver on mount', () => {
render(<Stream {...defaultProps()} />);
expect(StreamUtils.getResolver).toHaveBeenCalled();
});
});
// Form reset on stream change
describe('form reset', () => {
it('resets the form when stream prop changes', () => {
const { rerender } = render(<Stream {...defaultProps()} />);
rerender(<Stream {...defaultProps({ stream: makeStream() })} />);
expect(__form.resetSpy).toHaveBeenCalled();
});
});
// Submit button state
describe('submit button', () => {
it('Submit button is not disabled by default', () => {
render(<Stream {...defaultProps()} />);
expect(screen.getByText('Submit')).not.toBeDisabled();
});
});
});

View file

@ -0,0 +1,647 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Module-level form state
const __form = { values: {}, resetSpy: null };
// Store mocks
vi.mock('../../../store/userAgents', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils/forms/StreamProfileUtils.js', () => ({
addStreamProfile: vi.fn(),
updateStreamProfile: vi.fn(),
getResolver: vi.fn(() => undefined),
toCommandSelection: vi.fn((cmd) => {
const builtins = ['ffmpeg', 'streamlink', 'cvlc', 'yt-dlp'];
return builtins.includes(cmd) ? cmd : '__custom__';
}),
BUILT_IN_COMMANDS: [
{ value: 'ffmpeg', label: 'FFmpeg' },
{ value: 'streamlink', label: 'Streamlink' },
{ value: 'cvlc', label: 'VLC' },
{ value: 'yt-dlp', label: 'yt-dlp' },
{ value: '__custom__', label: 'Custom…' },
],
COMMAND_EXAMPLES: {
ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1',
streamlink:
'{streamUrl} --http-header User-Agent={userAgent} best --stdout',
},
}));
// react-hook-form
vi.mock('react-hook-form', async () => {
const React = await import('react');
return {
useForm: vi.fn(({ defaultValues } = {}) => {
const [formValues, setFormValues] = React.useState(() => {
const vals = defaultValues || {};
Object.assign(__form.values, vals);
return vals;
});
const updateField = (name, value) => {
__form.values[name] = value;
setFormValues((prev) => ({ ...prev, [name]: value }));
};
const register = (name) => ({
name,
value: __form.values[name] ?? '',
onChange: (e) => updateField(name, e.target.value),
onBlur: () => {},
});
const setValue = (name, value) => updateField(name, value);
const watch = (name) => formValues[name];
const handleSubmit = (onSubmit) => (e) => {
e?.preventDefault?.();
return onSubmit({ ...__form.values });
};
const resetImpl = React.useCallback((newValues) => {
const vals = newValues || defaultValues || {};
Object.assign(__form.values, vals);
setFormValues({ ...vals });
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const resetRef = React.useRef(null);
if (!resetRef.current) {
resetRef.current = vi.fn((...args) => resetImpl(...args));
__form.resetSpy = resetRef.current;
}
return {
register,
handleSubmit,
formState: { errors: {}, isSubmitting: false },
reset: resetRef.current,
setValue,
watch,
};
}),
};
});
// @mantine/core
vi.mock('@mantine/core', () => ({
Button: ({ children, type, disabled }) => (
<button type={type} disabled={disabled}>
{children}
</button>
),
Checkbox: ({ label, checked, onChange }) => (
<div>
<label htmlFor="checkbox-is-active">{label}</label>
<input
id="checkbox-is-active"
data-testid="checkbox-is-active"
type="checkbox"
checked={checked ?? false}
onChange={(e) =>
onChange({ currentTarget: { checked: e.target.checked } })
}
/>
</div>
),
Flex: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Select: ({
label,
value,
onChange,
data,
placeholder,
error,
clearable,
disabled,
description,
}) => (
<div>
<label>{label}</label>
{description && <div>{description}</div>}
<select
data-testid={`select-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
disabled={disabled}
onChange={(e) => onChange(e.target.value || (clearable ? null : ''))}
>
<option value="">{placeholder ?? `-- ${label} --`}</option>
{(data ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Textarea: ({
label,
name,
value,
onChange,
error,
placeholder,
description,
disabled,
...rest
}) => (
<div>
<label htmlFor={name}>{label}</label>
{description && (
<div data-testid={`desc-${label?.replace(/\s+/g, '-').toLowerCase()}`}>
{description}
</div>
)}
<textarea
id={name}
name={name}
data-testid={`textarea-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
placeholder={placeholder}
onChange={onChange}
disabled={disabled}
{...rest}
/>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
TextInput: ({ label, name, value, onChange, error, disabled, ...rest }) => (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
data-testid={`input-${label?.replace(/\s+/g, '-').toLowerCase()}`}
value={value ?? ''}
onChange={onChange}
disabled={disabled}
{...rest}
/>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
}));
// Imports after mocks
import StreamProfile from '../StreamProfile';
import useUserAgentsStore from '../../../store/userAgents';
import * as StreamProfileUtils from '../../../utils/forms/StreamProfileUtils.js';
// Shared test data
const mockUserAgents = [
{ id: 1, name: 'Chrome' },
{ id: 2, name: 'Firefox' },
];
const makeProfile = (overrides = {}) => ({
id: 'sp-1',
name: 'My Profile',
command: 'ffmpeg',
parameters: '-i {streamUrl}',
is_active: true,
user_agent: '1',
locked: false,
...overrides,
});
const setupMocks = () => {
vi.mocked(useUserAgentsStore).mockImplementation((sel) =>
sel({ userAgents: mockUserAgents })
);
vi.mocked(StreamProfileUtils.addStreamProfile).mockResolvedValue(undefined);
vi.mocked(StreamProfileUtils.updateStreamProfile).mockResolvedValue(
undefined
);
vi.mocked(StreamProfileUtils.getResolver).mockReturnValue(undefined);
vi.mocked(StreamProfileUtils.toCommandSelection).mockImplementation((cmd) => {
const builtins = ['ffmpeg', 'streamlink', 'cvlc', 'yt-dlp'];
return builtins.includes(cmd) ? cmd : '__custom__';
});
};
const defaultProps = (overrides = {}) => ({
profile: null,
isOpen: true,
onClose: vi.fn(),
...overrides,
});
describe('StreamProfile', () => {
beforeEach(() => {
vi.resetAllMocks();
__form.values = {};
__form.resetSpy = null;
setupMocks();
});
// Visibility
describe('visibility', () => {
it('renders the modal when isOpen is true', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render the modal when isOpen is false', () => {
render(<StreamProfile {...defaultProps({ isOpen: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('renders "Stream Profile" as the modal title', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Stream Profile'
);
});
it('calls onClose when modal close button is clicked', () => {
const onClose = vi.fn();
render(<StreamProfile {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Form fields
describe('form fields', () => {
it('renders Name input', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('input-name')).toBeInTheDocument();
});
it('renders Command select', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('select-command')).toBeInTheDocument();
});
it('renders Parameters textarea', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('textarea-parameters')).toBeInTheDocument();
});
it('renders User-Agent select', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('select-user-agent')).toBeInTheDocument();
});
it('renders Is Active checkbox', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('checkbox-is-active')).toBeInTheDocument();
});
it('renders Save button', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('populates Command select with built-in options', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByText('FFmpeg')).toBeInTheDocument();
expect(screen.getByText('Streamlink')).toBeInTheDocument();
expect(screen.getAllByText('Custom…').length).toBeGreaterThan(0);
});
it('populates User-Agent select with user agents', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByText('Chrome')).toBeInTheDocument();
expect(screen.getByText('Firefox')).toBeInTheDocument();
});
});
// Default values
describe('default values', () => {
it('pre-fills name from profile prop', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('input-name')).toHaveValue('My Profile');
});
it('pre-fills parameters from profile prop', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('textarea-parameters')).toHaveValue(
'-i {streamUrl}'
);
});
it('pre-selects command from profile prop', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('select-command')).toHaveValue('ffmpeg');
});
it('pre-selects user-agent from profile prop', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('select-user-agent')).toHaveValue('1');
});
it('checkbox is checked when is_active is true', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
});
it('checkbox is unchecked when is_active is false', () => {
render(
<StreamProfile
{...defaultProps({ profile: makeProfile({ is_active: false }) })}
/>
);
expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
});
it('defaults name to empty string when no profile', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('input-name')).toHaveValue('');
});
it('defaults parameters to empty string when no profile', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('textarea-parameters')).toHaveValue('');
});
});
// Command selection
describe('command selection', () => {
it('does not show Custom Command input when a built-in is selected', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(
screen.queryByTestId('input-custom-command')
).not.toBeInTheDocument();
});
it('shows Custom Command input when Custom… is selected', () => {
render(<StreamProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: '__custom__' },
});
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
});
it('hides Custom Command input when switching from custom back to built-in', () => {
render(<StreamProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: '__custom__' },
});
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: 'ffmpeg' },
});
expect(
screen.queryByTestId('input-custom-command')
).not.toBeInTheDocument();
});
it('shows Custom Command input when profile has a custom command', () => {
vi.mocked(StreamProfileUtils.toCommandSelection).mockReturnValue(
'__custom__'
);
render(
<StreamProfile
{...defaultProps({
profile: makeProfile({ command: '/usr/bin/mycmd' }),
})}
/>
);
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
});
it('sets command form value when built-in is selected', () => {
render(<StreamProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: 'streamlink' },
});
expect(__form.values.command).toBe('streamlink');
});
it('clears command form value when Custom… is selected', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: '__custom__' },
});
expect(__form.values.command).toBe('');
});
it('shows parameters example hint for ffmpeg', () => {
const { container } = render(
<StreamProfile {...defaultProps({ profile: makeProfile() })} />
);
expect(container.textContent).toMatch(/-user_agent \{userAgent\}/);
});
});
// Is Active checkbox
describe('Is Active checkbox', () => {
it('toggles is_active to false when unchecked', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.click(screen.getByTestId('checkbox-is-active'));
expect(__form.values.is_active).toBe(false);
});
it('toggles is_active to true when checked', () => {
render(
<StreamProfile
{...defaultProps({ profile: makeProfile({ is_active: false }) })}
/>
);
fireEvent.click(screen.getByTestId('checkbox-is-active'));
expect(__form.values.is_active).toBe(true);
});
});
// User-Agent select
describe('User-Agent select', () => {
it('updates user_agent form value when changed', () => {
render(<StreamProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('select-user-agent'), {
target: { value: '2' },
});
expect(__form.values.user_agent).toBe('2');
});
it('sets user_agent to empty string when cleared', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.change(screen.getByTestId('select-user-agent'), {
target: { value: '' },
});
expect(__form.values.user_agent).toBe('');
});
});
// Locked profile
describe('locked profile', () => {
it('disables Name input when profile is locked', () => {
render(
<StreamProfile
{...defaultProps({ profile: makeProfile({ locked: true }) })}
/>
);
expect(screen.getByTestId('input-name')).toBeDisabled();
});
it('disables Command select when profile is locked', () => {
render(
<StreamProfile
{...defaultProps({ profile: makeProfile({ locked: true }) })}
/>
);
expect(screen.getByTestId('select-command')).toBeDisabled();
});
it('disables Parameters textarea when profile is locked', () => {
render(
<StreamProfile
{...defaultProps({ profile: makeProfile({ locked: true }) })}
/>
);
expect(screen.getByTestId('textarea-parameters')).toBeDisabled();
});
it('does not disable inputs when profile is not locked', () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(screen.getByTestId('input-name')).not.toBeDisabled();
});
it('does not disable inputs when no profile is provided', () => {
render(<StreamProfile {...defaultProps()} />);
expect(screen.getByTestId('input-name')).not.toBeDisabled();
});
});
// Create profile
describe('create profile (no existing profile)', () => {
it('calls addStreamProfile on submit when no profile id', async () => {
render(<StreamProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-name'), {
target: { value: 'New Profile' },
});
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: 'ffmpeg' },
});
fireEvent.submit(screen.getByText('Save').closest('form'));
await waitFor(() => {
expect(StreamProfileUtils.addStreamProfile).toHaveBeenCalledWith(
expect.objectContaining({ name: 'New Profile' })
);
});
});
it('does not call updateStreamProfile when creating', async () => {
render(<StreamProfile {...defaultProps()} />);
fireEvent.change(screen.getByTestId('input-name'), {
target: { value: 'New Profile' },
});
fireEvent.submit(screen.getByText('Save').closest('form'));
await waitFor(() => {
expect(StreamProfileUtils.updateStreamProfile).not.toHaveBeenCalled();
});
});
it('calls onClose after successful create', async () => {
const onClose = vi.fn();
render(<StreamProfile {...defaultProps({ onClose })} />);
fireEvent.change(screen.getByTestId('input-name'), {
target: { value: 'New Profile' },
});
fireEvent.submit(screen.getByText('Save').closest('form'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
// Update profile
describe('update profile (existing profile)', () => {
it('calls updateStreamProfile with the profile id on submit', async () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.submit(screen.getByText('Save').closest('form'));
await waitFor(() => {
expect(StreamProfileUtils.updateStreamProfile).toHaveBeenCalledWith(
'sp-1',
expect.objectContaining({ name: 'My Profile' })
);
});
});
it('does not call addStreamProfile when updating', async () => {
render(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
fireEvent.submit(screen.getByText('Save').closest('form'));
await waitFor(() => {
expect(StreamProfileUtils.addStreamProfile).not.toHaveBeenCalled();
});
});
it('calls onClose after successful update', async () => {
const onClose = vi.fn();
render(
<StreamProfile {...defaultProps({ profile: makeProfile(), onClose })} />
);
fireEvent.submit(screen.getByText('Save').closest('form'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
// Form reset
describe('form reset', () => {
it('resets the form when profile prop changes', () => {
const { rerender } = render(<StreamProfile {...defaultProps()} />);
rerender(<StreamProfile {...defaultProps({ profile: makeProfile() })} />);
expect(__form.resetSpy).toHaveBeenCalled();
});
it('calls reset after successful submit', async () => {
render(<StreamProfile {...defaultProps()} />);
fireEvent.submit(screen.getByText('Save').closest('form'));
await waitFor(() => {
expect(__form.resetSpy).toHaveBeenCalled();
});
});
});
// getResolver
describe('getResolver', () => {
it('calls getResolver on mount', () => {
render(<StreamProfile {...defaultProps()} />);
expect(StreamProfileUtils.getResolver).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,333 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Asset mock
vi.mock('../../../assets/logo.png', () => ({ default: 'logo.png' }));
// API mock
vi.mock('../../../api', () => ({
default: {
createSuperUser: vi.fn(),
},
}));
// Store mocks
vi.mock('../../../store/auth', () => ({ default: vi.fn() }));
vi.mock('../../../store/settings', () => ({ default: vi.fn() }));
// @mantine/core
vi.mock('@mantine/core', () => ({
Button: ({ children, type, fullWidth }) => (
<button type={type} data-fullwidth={fullWidth}>
{children}
</button>
),
Center: ({ children, style }) => <div style={style}>{children}</div>,
Divider: ({ style }) => <hr style={style} />,
Image: ({ src, alt }) => <img src={src} alt={alt} />,
Paper: ({ children, style }) => <div style={style}>{children}</div>,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, size, color, align }) => (
<span data-size={size} data-color={color} data-align={align}>
{children}
</span>
),
TextInput: ({ label, name, value, onChange, required, type }) => (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
data-testid={`input-${name}`}
type={type ?? 'text'}
value={value}
onChange={onChange}
required={required}
/>
</div>
),
Title: ({ children, order, align }) => {
const Tag = `h${order ?? 1}`;
return <Tag data-align={align}>{children}</Tag>;
},
}));
// Imports after mocks
import SuperuserForm from '../SuperuserForm';
import API from '../../../api';
import useAuthStore from '../../../store/auth';
import useSettingsStore from '../../../store/settings';
// Helpers
const setupMocks = ({
version = {},
fetchVersion = vi.fn(),
setSuperuserExists = vi.fn(),
} = {}) => {
vi.mocked(useAuthStore).mockImplementation((sel) =>
sel({ setSuperuserExists })
);
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ fetchVersion, version })
);
return { fetchVersion, setSuperuserExists };
};
describe('SuperuserForm', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Rendering
describe('rendering', () => {
it('renders the Dispatcharr title', () => {
setupMocks();
render(<SuperuserForm />);
expect(screen.getByText('Dispatcharr')).toBeInTheDocument();
});
it('renders the welcome message', () => {
setupMocks();
render(<SuperuserForm />);
expect(
screen.getByText(
'Welcome! Create your Super User Account to get started.'
)
).toBeInTheDocument();
});
it('renders the logo image', () => {
setupMocks();
render(<SuperuserForm />);
expect(screen.getByAltText('Dispatcharr Logo')).toBeInTheDocument();
});
it('renders Username input', () => {
setupMocks();
render(<SuperuserForm />);
expect(screen.getByTestId('input-username')).toBeInTheDocument();
});
it('renders Password input with type="password"', () => {
setupMocks();
render(<SuperuserForm />);
const input = screen.getByTestId('input-password');
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('type', 'password');
});
it('renders Email input with type="email"', () => {
setupMocks();
render(<SuperuserForm />);
const input = screen.getByTestId('input-email');
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('type', 'email');
});
it('renders the Create Account button', () => {
setupMocks();
render(<SuperuserForm />);
expect(
screen.getByRole('button', { name: 'Create Account' })
).toBeInTheDocument();
});
it('does not render version text when version is not loaded', () => {
setupMocks({ version: {} });
render(<SuperuserForm />);
expect(screen.queryByText(/^v/)).not.toBeInTheDocument();
});
it('renders version text when version is loaded', () => {
setupMocks({ version: { version: '1.2.3' } });
render(<SuperuserForm />);
expect(screen.getByText('v1.2.3')).toBeInTheDocument();
});
});
// useEffect
describe('useEffect', () => {
it('calls fetchVersion on mount', () => {
const { fetchVersion } = setupMocks();
render(<SuperuserForm />);
expect(fetchVersion).toHaveBeenCalledTimes(1);
});
it('does not call fetchVersion again on re-render with same fetchVersion ref', () => {
const { fetchVersion } = setupMocks();
const { rerender } = render(<SuperuserForm />);
rerender(<SuperuserForm />);
// fetchVersion is stable, so useEffect should only fire once
expect(fetchVersion).toHaveBeenCalledTimes(1);
});
});
// Form field interactions
describe('form field interactions', () => {
it('updates username field when typed', () => {
setupMocks();
render(<SuperuserForm />);
fireEvent.change(screen.getByTestId('input-username'), {
target: { name: 'username', value: 'admin' },
});
expect(screen.getByTestId('input-username')).toHaveValue('admin');
});
it('updates password field when typed', () => {
setupMocks();
render(<SuperuserForm />);
fireEvent.change(screen.getByTestId('input-password'), {
target: { name: 'password', value: 'secret123' },
});
expect(screen.getByTestId('input-password')).toHaveValue('secret123');
});
it('updates email field when typed', () => {
setupMocks();
render(<SuperuserForm />);
fireEvent.change(screen.getByTestId('input-email'), {
target: { name: 'email', value: 'admin@example.com' },
});
expect(screen.getByTestId('input-email')).toHaveValue(
'admin@example.com'
);
});
it('initializes all fields as empty strings', () => {
setupMocks();
render(<SuperuserForm />);
expect(screen.getByTestId('input-username')).toHaveValue('');
expect(screen.getByTestId('input-password')).toHaveValue('');
expect(screen.getByTestId('input-email')).toHaveValue('');
});
});
// Form submission
describe('form submission', () => {
it('calls API.createSuperUser with form values on submit', async () => {
setupMocks();
vi.mocked(API.createSuperUser).mockResolvedValue({
superuser_exists: false,
});
render(<SuperuserForm />);
fireEvent.change(screen.getByTestId('input-username'), {
target: { name: 'username', value: 'admin' },
});
fireEvent.change(screen.getByTestId('input-password'), {
target: { name: 'password', value: 'secret' },
});
fireEvent.change(screen.getByTestId('input-email'), {
target: { name: 'email', value: 'admin@test.com' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Create Account' }).closest('form')
);
await waitFor(() => {
expect(API.createSuperUser).toHaveBeenCalledWith({
username: 'admin',
password: 'secret',
email: 'admin@test.com',
});
});
});
it('calls setSuperuserExists(true) when response.superuser_exists is true', async () => {
const { setSuperuserExists } = setupMocks();
vi.mocked(API.createSuperUser).mockResolvedValue({
superuser_exists: true,
});
render(<SuperuserForm />);
fireEvent.change(screen.getByTestId('input-username'), {
target: { name: 'username', value: 'admin' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Create Account' }).closest('form')
);
await waitFor(() => {
expect(setSuperuserExists).toHaveBeenCalledWith(true);
});
});
it('does not call setSuperuserExists when response.superuser_exists is false', async () => {
const { setSuperuserExists } = setupMocks();
vi.mocked(API.createSuperUser).mockResolvedValue({
superuser_exists: false,
});
render(<SuperuserForm />);
fireEvent.submit(
screen.getByRole('button', { name: 'Create Account' }).closest('form')
);
await waitFor(() => {
expect(API.createSuperUser).toHaveBeenCalled();
});
expect(setSuperuserExists).not.toHaveBeenCalled();
});
it('does not throw when API.createSuperUser rejects', async () => {
setupMocks();
vi.mocked(API.createSuperUser).mockRejectedValue(new Error('Network'));
render(<SuperuserForm />);
fireEvent.submit(
screen.getByRole('button', { name: 'Create Account' }).closest('form')
);
await expect(
waitFor(() => expect(API.createSuperUser).toHaveBeenCalled())
).resolves.not.toThrow();
});
it('does not call setSuperuserExists when API throws', async () => {
const { setSuperuserExists } = setupMocks();
vi.mocked(API.createSuperUser).mockRejectedValue(new Error('Network'));
render(<SuperuserForm />);
fireEvent.submit(
screen.getByRole('button', { name: 'Create Account' }).closest('form')
);
await waitFor(() => {
expect(API.createSuperUser).toHaveBeenCalled();
});
expect(setSuperuserExists).not.toHaveBeenCalled();
});
it('submits with empty email when email field is left blank', async () => {
setupMocks();
vi.mocked(API.createSuperUser).mockResolvedValue({
superuser_exists: false,
});
render(<SuperuserForm />);
fireEvent.change(screen.getByTestId('input-username'), {
target: { name: 'username', value: 'admin' },
});
fireEvent.change(screen.getByTestId('input-password'), {
target: { name: 'password', value: 'secret' },
});
fireEvent.submit(
screen.getByRole('button', { name: 'Create Account' }).closest('form')
);
await waitFor(() => {
expect(API.createSuperUser).toHaveBeenCalledWith(
expect.objectContaining({ email: '' })
);
});
});
});
});

View file

@ -0,0 +1,810 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Constants
vi.mock('../../../constants', () => ({
USER_LEVELS: { ADMIN: 1, STREAMER: 2, USER: 3 },
USER_LEVEL_LABELS: { 1: 'Admin', 2: 'Streamer', 3: 'User' },
}));
// Store mocks
vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
vi.mock('../../../store/outputProfiles', () => ({ default: vi.fn() }));
vi.mock('../../../store/auth', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils', () => ({ copyToClipboard: vi.fn() }));
vi.mock('../../../utils/forms/UserUtils.js', () => ({
createUser: vi.fn(),
updateUser: vi.fn(),
generateApiKey: vi.fn(),
revokeApiKey: vi.fn(),
formValuesToPayload: vi.fn(),
getFormInitialValues: vi.fn(() => ({})),
getFormValidators: vi.fn(() => ({})),
userToFormValues: vi.fn(() => ({})),
}));
// Mantine form
const mockForm = {
getInputProps: vi.fn(() => ({})),
key: vi.fn((k) => k),
setValues: vi.fn(),
setFieldValue: vi.fn(),
reset: vi.fn(),
getValues: vi.fn(() => ({ user_level: '3' })),
onSubmit: vi.fn((fn) => (e) => {
e?.preventDefault?.();
return fn();
}),
submitting: false,
};
vi.mock('@mantine/form', () => ({
useForm: vi.fn(() => mockForm),
}));
// lucide-react
vi.mock('lucide-react', () => ({
Copy: () => <svg data-testid="icon-copy" />,
Key: () => <svg data-testid="icon-key" />,
RotateCcwKey: () => <svg data-testid="icon-rotate-ccw-key" />,
X: () => <svg data-testid="icon-x" />,
}));
// Mantine core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, disabled }) => (
<button data-testid="action-icon" onClick={onClick} disabled={disabled}>
{children}
</button>
),
Button: ({ children, onClick, disabled, loading, type, leftSection }) => (
<button
type={type || 'button'}
onClick={onClick}
disabled={disabled || loading}
data-loading={String(loading)}
>
{leftSection}
{children}
</button>
),
Group: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
MultiSelect: ({ label, onChange }) => (
<div>
<label>{label}</label>
<input
data-testid={`multiselect-${label}`}
onChange={(e) =>
onChange?.(e.target.value ? e.target.value.split(',') : [])
}
/>
</div>
),
NumberInput: ({ label }) => (
<div>
<label>{label}</label>
<input />
</div>
),
PasswordInput: ({ label, disabled }) => (
<div>
<label>{label}</label>
<input type="password" disabled={disabled} />
</div>
),
Select: ({ label, disabled }) => (
<div>
<label>{label}</label>
<select disabled={disabled} />
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Switch: ({ label }) => (
<div>
<label>{label}</label>
<input type="checkbox" />
</div>
),
Tabs: ({ children }) => <div>{children}</div>,
TabsList: ({ children }) => <div>{children}</div>,
TabsPanel: ({ children, value }) => (
<div data-testid={`panel-${value}`}>{children}</div>
),
TabsTab: ({ children, value }) => (
<button data-testid={`tab-${value}`}>{children}</button>
),
TagsInput: ({ label }) => (
<div>
<label>{label}</label>
<input />
</div>
),
Text: ({ children }) => <span>{children}</span>,
TextInput: ({ label, value, disabled, rightSection }) => (
<div>
<label>{label}</label>
<input
data-testid={`textinput-${label}`}
defaultValue={value}
disabled={disabled}
/>
{rightSection}
</div>
),
useMantineTheme: () => ({
colors: { red: Array(10).fill('#ff0000') },
}),
}));
// Imports after mocks
import useChannelsStore from '../../../store/channels';
import useOutputProfilesStore from '../../../store/outputProfiles';
import useAuthStore from '../../../store/auth';
import * as UserUtils from '../../../utils/forms/UserUtils.js';
import { copyToClipboard } from '../../../utils';
import User from '../User';
// Factories
const makeAdminUser = (overrides = {}) => ({
id: 1,
username: 'admin',
user_level: 1,
api_key: null,
...overrides,
});
const makeRegularUser = (overrides = {}) => ({
id: 2,
username: 'user1',
user_level: 3,
api_key: null,
...overrides,
});
const setupMocks = ({
authUser = makeAdminUser(),
profiles = {},
outputProfiles = [],
} = {}) => {
const mockSetUser = vi.fn();
vi.mocked(useChannelsStore).mockImplementation((sel) => sel({ profiles }));
vi.mocked(useOutputProfilesStore).mockImplementation((sel) =>
sel({ profiles: outputProfiles })
);
vi.mocked(useAuthStore).mockImplementation((sel) =>
sel({ user: authUser, setUser: mockSetUser })
);
// Reset form state
mockForm.getValues.mockReturnValue({ user_level: '3' });
mockForm.onSubmit.mockImplementation((fn) => (e) => {
e?.preventDefault?.();
return fn();
});
return { mockSetUser };
};
//
describe('User', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(UserUtils.createUser).mockResolvedValue({});
vi.mocked(UserUtils.updateUser).mockResolvedValue({});
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({
key: 'new-api-key',
});
vi.mocked(UserUtils.revokeApiKey).mockResolvedValue({ success: true });
vi.mocked(UserUtils.formValuesToPayload).mockReturnValue({ user_level: 3 });
vi.mocked(UserUtils.getFormInitialValues).mockReturnValue({});
vi.mocked(UserUtils.getFormValidators).mockReturnValue({});
vi.mocked(UserUtils.userToFormValues).mockReturnValue({});
});
// Visibility
describe('visibility', () => {
it('renders nothing when isOpen is false', () => {
setupMocks();
render(<User isOpen={false} onClose={vi.fn()} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('renders the modal when isOpen is true', () => {
setupMocks();
render(<User isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('calls onClose when the modal close button is clicked', () => {
setupMocks();
const onClose = vi.fn();
render(<User isOpen={true} onClose={onClose} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Tabs
describe('tabs', () => {
it('always renders Account, EPG, and API tabs', () => {
setupMocks();
render(<User isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('tab-account')).toBeInTheDocument();
expect(screen.getByTestId('tab-epg')).toBeInTheDocument();
expect(screen.getByTestId('tab-api')).toBeInTheDocument();
});
it('shows Permissions tab when admin edits another user', () => {
setupMocks({ authUser: makeAdminUser() });
render(<User isOpen={true} onClose={vi.fn()} user={makeRegularUser()} />);
expect(screen.getByTestId('tab-permissions')).toBeInTheDocument();
});
it('hides Permissions tab when admin edits themselves', () => {
const admin = makeAdminUser();
setupMocks({ authUser: admin });
render(<User isOpen={true} onClose={vi.fn()} user={admin} />);
expect(screen.queryByTestId('tab-permissions')).not.toBeInTheDocument();
});
it('hides Permissions tab for non-admin user', () => {
setupMocks({ authUser: makeRegularUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 99 })}
/>
);
expect(screen.queryByTestId('tab-permissions')).not.toBeInTheDocument();
});
});
// Admin-only fields
describe('admin-only fields', () => {
it('shows Output Format Override for admin', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.getByText('Output Format Override')).toBeInTheDocument();
});
it('hides Output Format Override for non-admin', () => {
setupMocks({ authUser: makeRegularUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 5 })}
/>
);
expect(
screen.queryByText('Output Format Override')
).not.toBeInTheDocument();
});
it('shows Output Profile Override for admin', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.getByText('Output Profile Override')).toBeInTheDocument();
});
it('shows Allowed IPs for admin', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.getByText('Allowed IPs')).toBeInTheDocument();
});
it('hides Allowed IPs for non-admin', () => {
setupMocks({ authUser: makeRegularUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 5 })}
/>
);
expect(screen.queryByText('Allowed IPs')).not.toBeInTheDocument();
});
});
// API key generation
describe('API key generation', () => {
it('shows "Generate API Key" when no key exists', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
});
it('shows "Regenerate API Key" when user already has an api_key', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2, api_key: 'existing-key' })}
/>
);
expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
});
it('calls generateApiKey and switches button to "Regenerate API Key"', async () => {
setupMocks({ authUser: makeAdminUser() });
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({
key: 'brand-new-key',
});
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
fireEvent.click(screen.getByText('Generate API Key'));
await waitFor(() => {
expect(UserUtils.generateApiKey).toHaveBeenCalledWith({ user_id: 2 });
expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
});
});
it('also sets the key when response contains raw_key instead of key', async () => {
setupMocks({ authUser: makeAdminUser() });
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({
raw_key: 'raw-value',
});
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
fireEvent.click(screen.getByText('Generate API Key'));
await waitFor(() => {
expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
});
});
it('does not call generateApiKey when canGenerateKey is false', async () => {
// non-admin editing someone else
setupMocks({ authUser: makeRegularUser({ id: 5 }) });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
// No button visible since canGenerateKey is false
expect(screen.queryByText('Generate API Key')).not.toBeInTheDocument();
expect(UserUtils.generateApiKey).not.toHaveBeenCalled();
});
it('does not update key when generateApiKey returns a response without key/raw_key', async () => {
setupMocks({ authUser: makeAdminUser() });
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({});
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
fireEvent.click(screen.getByText('Generate API Key'));
await waitFor(() => {
// button text should NOT change since no key was received
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
});
});
it('shows the generated API key in a text input', async () => {
setupMocks({ authUser: makeAdminUser() });
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'show-me' });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
fireEvent.click(screen.getByText('Generate API Key'));
await waitFor(() => {
const keyInput = screen.getByTestId('textinput-API Key');
expect(keyInput).toHaveValue('show-me');
});
});
});
// API key revocation
describe('API key revocation', () => {
it('shows "Revoke API Key" when a key exists', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2, api_key: 'some-key' })}
/>
);
expect(screen.getByText('Revoke API Key')).toBeInTheDocument();
});
it('hides "Revoke API Key" when no key exists', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
expect(screen.queryByText('Revoke API Key')).not.toBeInTheDocument();
});
it('calls revokeApiKey with user_id and clears the key on success', async () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2, api_key: 'some-key' })}
/>
);
fireEvent.click(screen.getByText('Revoke API Key'));
await waitFor(() => {
expect(UserUtils.revokeApiKey).toHaveBeenCalledWith({ user_id: 2 });
expect(screen.queryByText('Revoke API Key')).not.toBeInTheDocument();
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
});
});
it('updates auth store when admin revokes their own key', async () => {
const admin = makeAdminUser({ id: 1, api_key: 'my-key' });
const { mockSetUser } = setupMocks({ authUser: admin });
render(<User isOpen={true} onClose={vi.fn()} user={admin} />);
fireEvent.click(screen.getByText('Revoke API Key'));
await waitFor(() => {
expect(mockSetUser).toHaveBeenCalledWith(
expect.objectContaining({ api_key: null })
);
});
});
it('does not clear key when revokeApiKey returns success: false', async () => {
setupMocks({ authUser: makeAdminUser() });
vi.mocked(UserUtils.revokeApiKey).mockResolvedValue({ success: false });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2, api_key: 'still-here' })}
/>
);
fireEvent.click(screen.getByText('Revoke API Key'));
await waitFor(() => {
expect(screen.getByText('Revoke API Key')).toBeInTheDocument();
});
});
it("does not update auth store when revoking another user's key", async () => {
const admin = makeAdminUser({ id: 1 });
const { mockSetUser } = setupMocks({ authUser: admin });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2, api_key: 'other-key' })}
/>
);
fireEvent.click(screen.getByText('Revoke API Key'));
await waitFor(() => {
expect(mockSetUser).not.toHaveBeenCalled();
});
});
});
// Copy to clipboard
describe('copy API key to clipboard', () => {
it('calls copyToClipboard with the current key', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2, api_key: 'copy-me' })}
/>
);
const copyButton = screen.getByTestId('icon-copy').closest('button');
fireEvent.click(copyButton);
expect(copyToClipboard).toHaveBeenCalledWith(
'copy-me',
expect.objectContaining({ successTitle: 'API Key Copied!' })
);
});
});
// Form submission
describe('form submission', () => {
it('calls createUser when no user prop is given', async () => {
setupMocks({ authUser: makeAdminUser() });
render(<User isOpen={true} onClose={vi.fn()} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(UserUtils.createUser).toHaveBeenCalled();
});
});
it('calls updateUser when editing an existing user', async () => {
const admin = makeAdminUser();
setupMocks({ authUser: admin });
vi.mocked(UserUtils.updateUser).mockResolvedValue({ id: 2 });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(UserUtils.updateUser).toHaveBeenCalledWith(
2,
expect.any(Object),
true, // isAdmin
admin
);
});
});
it('updates auth store when admin saves their own profile', async () => {
const admin = makeAdminUser({ id: 1 });
const updatedAdmin = { ...admin, email: 'new@example.com' };
const { mockSetUser } = setupMocks({ authUser: admin });
vi.mocked(UserUtils.updateUser).mockResolvedValue(updatedAdmin);
render(<User isOpen={true} onClose={vi.fn()} user={admin} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(mockSetUser).toHaveBeenCalledWith(updatedAdmin);
});
});
it('resets form and calls onClose after successful submission', async () => {
setupMocks({ authUser: makeAdminUser() });
const onClose = vi.fn();
render(<User isOpen={true} onClose={onClose} />);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(mockForm.reset).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
});
it('removes password from payload when updating and payload.password is falsy', async () => {
const admin = makeAdminUser();
setupMocks({ authUser: admin });
vi.mocked(UserUtils.formValuesToPayload).mockReturnValue({
user_level: 3,
password: '',
});
vi.mocked(UserUtils.updateUser).mockResolvedValue({ id: 2 });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
const payload = vi.mocked(UserUtils.updateUser).mock.calls[0][1];
expect(payload).not.toHaveProperty('password');
});
});
});
// useEffect
describe('useEffect on user prop', () => {
it('calls userToFormValues and sets form values when user has an id', () => {
const user = makeRegularUser({ id: 2 });
vi.mocked(UserUtils.userToFormValues).mockReturnValue({
username: 'user1',
});
setupMocks({ authUser: makeAdminUser() });
render(<User isOpen={true} onClose={vi.fn()} user={user} />);
expect(UserUtils.userToFormValues).toHaveBeenCalledWith(user);
expect(mockForm.setValues).toHaveBeenCalledWith({ username: 'user1' });
});
it('resets form when no user is provided', () => {
setupMocks({ authUser: makeAdminUser() });
render(<User isOpen={true} onClose={vi.fn()} />);
expect(mockForm.reset).toHaveBeenCalled();
});
});
// XC password generation
describe('XC password generation', () => {
it('calls setValues with a generated xc_password when rotate icon is clicked', () => {
setupMocks({ authUser: makeAdminUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
const rotateButton = screen
.getByTestId('icon-rotate-ccw-key')
.closest('button');
fireEvent.click(rotateButton);
expect(mockForm.setValues).toHaveBeenCalledWith(
expect.objectContaining({ xc_password: expect.any(String) })
);
});
it('disables the XC password rotate button for non-admin', () => {
setupMocks({ authUser: makeRegularUser() });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 5 })}
/>
);
const rotateButton = screen
.getByTestId('icon-rotate-ccw-key')
.closest('button');
expect(rotateButton).toBeDisabled();
});
});
// Channel profiles logic
describe('channel profiles logic', () => {
const profiles = { 1: { id: 1, name: 'HD' }, 2: { id: 2, name: 'SD' } };
it('excludes "All" (0) when other profiles are selected alongside it', () => {
setupMocks({ authUser: makeAdminUser(), profiles });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
fireEvent.change(multiSelect, { target: { value: '1,2' } });
expect(mockForm.setFieldValue).toHaveBeenCalledWith(
'channel_profiles',
expect.not.arrayContaining(['0'])
);
});
it('sets only ["0"] when "All" is newly selected', () => {
setupMocks({ authUser: makeAdminUser(), profiles });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
fireEvent.change(multiSelect, { target: { value: '0' } });
expect(mockForm.setFieldValue).toHaveBeenCalledWith('channel_profiles', [
'0',
]);
});
it('allows multiple non-all profiles together', () => {
setupMocks({ authUser: makeAdminUser(), profiles });
render(
<User
isOpen={true}
onClose={vi.fn()}
user={makeRegularUser({ id: 2 })}
/>
);
const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
fireEvent.change(multiSelect, { target: { value: '1,2' } });
expect(mockForm.setFieldValue).toHaveBeenCalledWith('channel_profiles', [
'1',
'2',
]);
});
});
});

View file

@ -0,0 +1,279 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Utility mocks
vi.mock('../../../utils/forms/UserAgentUtils.js', () => ({
addUserAgent: vi.fn(),
updateUserAgent: vi.fn(),
getResolver: vi.fn(() => undefined),
}));
// Mantine core
vi.mock('@mantine/core', () => ({
Button: ({ children, disabled, type }) => (
<button type={type} disabled={disabled}>
{children}
</button>
),
Checkbox: ({ label, checked, onChange }) => (
<label>
<input
type="checkbox"
data-testid="checkbox-is-active"
checked={checked}
onChange={onChange}
/>
{label}
</label>
),
Flex: ({ children }) => <div>{children}</div>,
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Space: () => <div />,
TextInput: ({ label, error, ...rest }) => (
<div>
<label>{label}</label>
<input data-testid={`input-${label}`} aria-label={label} {...rest} />
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
}));
// Imports after mocks
import * as UserAgentUtils from '../../../utils/forms/UserAgentUtils.js';
import UserAgent from '../UserAgent';
// Helpers
const makeUserAgent = (overrides = {}) => ({
id: 1,
name: 'Chrome',
user_agent: 'Mozilla/5.0',
description: 'Chrome browser',
is_active: true,
...overrides,
});
describe('UserAgent', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(UserAgentUtils.addUserAgent).mockResolvedValue({});
vi.mocked(UserAgentUtils.updateUserAgent).mockResolvedValue({});
vi.mocked(UserAgentUtils.getResolver).mockReturnValue(undefined);
});
// Visibility
describe('visibility', () => {
it('renders nothing when isOpen is false', () => {
render(<UserAgent isOpen={false} onClose={vi.fn()} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('renders the modal when isOpen is true', () => {
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('renders modal title "User-Agent"', () => {
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent('User-Agent');
});
it('calls onClose when modal close button is clicked', () => {
const onClose = vi.fn();
render(<UserAgent isOpen={true} onClose={onClose} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Default values
describe('default values', () => {
it('renders empty fields when no userAgent prop is given', () => {
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('input-Name')).toHaveValue('');
expect(screen.getByTestId('input-User-Agent')).toHaveValue('');
expect(screen.getByTestId('input-Description')).toHaveValue('');
});
it('pre-fills fields from userAgent prop', () => {
render(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent()}
/>
);
expect(screen.getByTestId('input-Name')).toHaveValue('Chrome');
expect(screen.getByTestId('input-User-Agent')).toHaveValue('Mozilla/5.0');
expect(screen.getByTestId('input-Description')).toHaveValue(
'Chrome browser'
);
});
it('checks Is Active checkbox when userAgent.is_active is true', () => {
render(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent({ is_active: true })}
/>
);
expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
});
it('unchecks Is Active checkbox when userAgent.is_active is false', () => {
render(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent({ is_active: false })}
/>
);
expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
});
it('defaults Is Active to checked when no userAgent prop given', () => {
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
});
});
// Field interactions
describe('field interactions', () => {
it('toggles Is Active checkbox', () => {
render(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent({ is_active: true })}
/>
);
const checkbox = screen.getByTestId('checkbox-is-active');
fireEvent.click(checkbox);
expect(checkbox).not.toBeChecked();
});
});
// Form submission
describe('form submission', () => {
it('calls addUserAgent when no userAgent id is given', async () => {
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(UserAgentUtils.addUserAgent).toHaveBeenCalled();
});
});
it('calls updateUserAgent with the id when editing an existing userAgent', async () => {
render(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent()}
/>
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(UserAgentUtils.updateUserAgent).toHaveBeenCalledWith(
1,
expect.objectContaining({ name: 'Chrome' })
);
});
});
it('does not call addUserAgent when updating', async () => {
render(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent()}
/>
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(UserAgentUtils.addUserAgent).not.toHaveBeenCalled();
});
});
it('does not call updateUserAgent when creating', async () => {
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(UserAgentUtils.updateUserAgent).not.toHaveBeenCalled();
});
});
it('calls onClose after successful submission', async () => {
const onClose = vi.fn();
render(<UserAgent isOpen={true} onClose={onClose} />);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('passes is_active value in the submitted payload', async () => {
render(<UserAgent isOpen={true} onClose={vi.fn()} />);
const checkbox = screen.getByTestId('checkbox-is-active');
fireEvent.click(checkbox); // uncheck it
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
expect(UserAgentUtils.addUserAgent).toHaveBeenCalledWith(
expect.objectContaining({ is_active: false })
);
});
});
});
// useEffect reset
describe('form reset on userAgent change', () => {
it('resets form values when userAgent prop changes', () => {
const { rerender } = render(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent()}
/>
);
expect(screen.getByTestId('input-Name')).toHaveValue('Chrome');
rerender(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent({
id: 2,
name: 'Firefox',
user_agent: 'Gecko/20100101',
})}
/>
);
expect(screen.getByTestId('input-Name')).toHaveValue('Firefox');
});
it('resets to empty when userAgent prop is removed', () => {
const { rerender } = render(
<UserAgent
isOpen={true}
onClose={vi.fn()}
userAgent={makeUserAgent()}
/>
);
rerender(<UserAgent isOpen={true} onClose={vi.fn()} userAgent={null} />);
expect(screen.getByTestId('input-Name')).toHaveValue('');
});
});
});

View file

@ -0,0 +1,477 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Store mock
vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() }));
// lucide-react
vi.mock('lucide-react', () => ({
CircleCheck: () => <svg data-testid="icon-circle-check" />,
CircleX: () => <svg data-testid="icon-circle-x" />,
}));
// Mantine core
vi.mock('@mantine/core', () => ({
Box: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, disabled, color, variant }) => (
<button
onClick={onClick}
disabled={disabled}
data-color={color}
data-variant={variant}
>
{children}
</button>
),
Checkbox: ({ label, checked, onChange, disabled }) => (
<label>
<input
type="checkbox"
aria-label={label}
checked={checked}
onChange={(e) =>
onChange?.({ currentTarget: { checked: e.target.checked } })
}
disabled={disabled}
/>
{label}
</label>
),
Flex: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
SimpleGrid: ({ children }) => <div data-testid="simple-grid">{children}</div>,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
TextInput: ({ label, value, onChange, placeholder }) => (
<input
aria-label={label ?? placeholder}
value={value}
onChange={onChange}
placeholder={placeholder}
/>
),
SegmentedControl: ({ value, onChange, data }) => (
<div data-testid="segmented-control">
{data.map((item) => (
<button
key={item.value ?? item}
data-testid={`segment-${item.value ?? item}`}
onClick={() => onChange(item.value ?? item)}
data-active={value === (item.value ?? item) ? 'true' : 'false'}
>
{item.label ?? item}
</button>
))}
</div>
),
}));
// Imports after mocks
import useVODStore from '../../../store/useVODStore';
import VODCategoryFilter from '../VODCategoryFilter';
// Helpers
const makeCategories = () => [
{
id: 1,
name: 'Action',
m3u_accounts: [{ m3u_account: 10, enabled: true }],
category_type: 'movies',
},
{
id: 2,
name: 'Comedy',
m3u_accounts: [{ m3u_account: 10, enabled: false }],
category_type: 'movies',
},
{
id: 3,
name: 'Drama',
m3u_accounts: [{ m3u_account: 10, enabled: true }],
category_type: 'movies',
},
{
id: 4,
name: 'News',
m3u_accounts: [{ m3u_account: 10, enabled: true }],
category_type: 'series',
},
];
const makePlaylist = (overrides = {}) => ({
id: 10,
name: 'My Playlist',
...overrides,
});
const categoriesToDict = (arr) =>
arr.reduce((acc, cat) => ({ ...acc, [cat.id]: cat }), {});
const setupMocks = ({ categories = makeCategories() } = {}) => {
const dict = Array.isArray(categories)
? categoriesToDict(categories)
: categories;
vi.mocked(useVODStore).mockImplementation((sel) => sel({ categories: dict }));
};
const defaultProps = (overrides = {}) => {
return {
playlist: makePlaylist(),
categoryStates: [
{ id: 1, name: 'Action', enabled: true },
{ id: 2, name: 'Comedy', enabled: false },
{ id: 3, name: 'Drama', enabled: true },
],
setCategoryStates: vi.fn(),
type: 'movies',
autoEnableNewGroups: true,
setAutoEnableNewGroups: vi.fn(),
...overrides,
};
};
//
describe('VODCategoryFilter', () => {
beforeEach(() => {
vi.clearAllMocks();
setupMocks();
});
// Rendering
describe('rendering', () => {
it('renders without crashing', () => {
render(<VODCategoryFilter {...defaultProps()} />);
expect(screen.getByTestId('simple-grid')).toBeInTheDocument();
});
it('renders a button for each category matching the type and playlist', () => {
render(<VODCategoryFilter {...defaultProps()} />);
expect(
screen.getByRole('button', { name: 'Action' })
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Comedy' })
).toBeInTheDocument();
});
it('does not render categories of a different type', () => {
render(<VODCategoryFilter {...defaultProps()} />);
expect(
screen.queryByRole('checkbox', { name: 'News' })
).not.toBeInTheDocument();
});
it('renders the text filter input', () => {
render(<VODCategoryFilter {...defaultProps()} />);
expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument();
});
it('renders the segmented status control', () => {
render(<VODCategoryFilter {...defaultProps()} />);
expect(screen.getByTestId('segmented-control')).toBeInTheDocument();
});
it('renders Enable All and Disable All buttons', () => {
render(<VODCategoryFilter {...defaultProps()} />);
expect(screen.getByText('Select Visible')).toBeInTheDocument();
expect(screen.getByText('Deselect Visible')).toBeInTheDocument();
});
it('renders the Auto-enable new groups checkbox', () => {
render(<VODCategoryFilter {...defaultProps()} />);
expect(
screen.getByLabelText(/automatically enable new/i)
).toBeInTheDocument();
});
});
// Text filter
describe('text filter', () => {
it('hides categories that do not match the filter', () => {
render(<VODCategoryFilter {...defaultProps()} />);
const input = screen.getByPlaceholderText(/filter/i);
fireEvent.change(input, { target: { value: 'act' } });
expect(
screen.getByRole('button', { name: 'Action' })
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Comedy' })
).not.toBeInTheDocument();
});
it('is case-insensitive', () => {
render(<VODCategoryFilter {...defaultProps()} />);
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
target: { value: 'COMEDY' },
});
expect(
screen.getByRole('button', { name: 'Comedy' })
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Action' })
).not.toBeInTheDocument();
});
it('shows all categories when filter is cleared', () => {
render(<VODCategoryFilter {...defaultProps()} />);
const input = screen.getByPlaceholderText(/filter/i);
fireEvent.change(input, { target: { value: 'act' } });
fireEvent.change(input, { target: { value: '' } });
expect(
screen.getByRole('button', { name: 'Action' })
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Comedy' })
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Drama' })).toBeInTheDocument();
});
it('shows no categories when filter matches nothing', () => {
render(<VODCategoryFilter {...defaultProps()} />);
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
target: { value: 'zzznomatch' },
});
expect(
screen.queryByRole('button', { name: 'Action' })
).not.toBeInTheDocument();
});
});
// Status filter
describe('status filter', () => {
it('shows only enabled categories when "Enabled" segment is selected', () => {
const props = defaultProps({
categoryStates: [
{ id: 1, name: 'Action', enabled: true },
{ id: 2, name: 'Comedy', enabled: false },
{ id: 3, name: 'Drama', enabled: true },
],
});
render(<VODCategoryFilter {...props} />);
fireEvent.click(screen.getByTestId('segment-enabled'));
expect(
screen.getByRole('button', { name: 'Action' })
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Comedy' })
).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Drama' })).toBeInTheDocument();
});
it('shows only disabled categories when "Disabled" segment is selected', () => {
const props = defaultProps({
categoryStates: [
{ id: 1, name: 'Action', enabled: true },
{ id: 2, name: 'Comedy', enabled: false },
{ id: 3, name: 'Drama', enabled: true },
],
});
render(<VODCategoryFilter {...props} />);
fireEvent.click(screen.getByTestId('segment-disabled'));
expect(
screen.queryByRole('button', { name: 'Action' })
).not.toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Comedy' })
).toBeInTheDocument();
});
it('shows all categories when "All" segment is active', () => {
const props = defaultProps({
categoryStates: [
{ id: 1, name: 'Action', enabled: true },
{ id: 2, name: 'Comedy', enabled: false },
{ id: 3, name: 'Drama', enabled: true },
],
});
render(<VODCategoryFilter {...props} />);
fireEvent.click(screen.getByTestId('segment-disabled'));
fireEvent.click(screen.getByTestId('segment-all'));
expect(
screen.getByRole('button', { name: 'Action' })
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Comedy' })
).toBeInTheDocument();
});
});
// Combined filters
describe('combined text + status filters', () => {
it('applies both text and status filters simultaneously', () => {
const props = defaultProps();
render(<VODCategoryFilter {...props} />);
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
target: { value: 'o' },
});
fireEvent.click(screen.getByTestId('segment-disabled'));
// "Comedy" matches "o" AND is disabled; "Action" matches "o" but is enabled
expect(
screen.getByRole('button', { name: 'Comedy' })
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Action' })
).not.toBeInTheDocument();
});
});
// Enable All / Disable All
describe('Enable All button', () => {
it('calls setCategoryStates with all visible categories set to true', () => {
const setCategoryStates = vi.fn();
render(
<VODCategoryFilter
{...defaultProps({
setCategoryStates,
categoryStates: [
{ id: 1, name: 'Action', enabled: false },
{ id: 2, name: 'Comedy', enabled: false },
{ id: 3, name: 'Drama', enabled: false },
],
})}
/>
);
fireEvent.click(screen.getByText('Select Visible'));
const called = setCategoryStates.mock.calls.at(-1)[0];
expect(called.find((s) => s.id === 1).enabled).toBe(true);
expect(called.find((s) => s.id === 2).enabled).toBe(true);
expect(called.find((s) => s.id === 3).enabled).toBe(true);
});
it('only enables filtered categories when a text filter is active', () => {
const setCategoryStates = vi.fn();
render(
<VODCategoryFilter
{...defaultProps({
setCategoryStates,
categoryStates: [
{ id: 1, name: 'Action', enabled: false },
{ id: 2, name: 'Comedy', enabled: false },
{ id: 3, name: 'Drama', enabled: false },
],
})}
/>
);
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
target: { value: 'act' },
});
fireEvent.click(screen.getByText('Select Visible'));
const called = setCategoryStates.mock.calls.at(-1)[0];
expect(called.find((s) => s.id === 1).enabled).toBe(true);
// Comedy and Drama were filtered out their state should be unchanged
expect(called.find((s) => s.id === 2).enabled).toBe(false);
expect(called.find((s) => s.id === 3).enabled).toBe(false);
});
});
describe('Disable All button', () => {
it('calls setCategoryStates with all visible categories set to false', () => {
const setCategoryStates = vi.fn();
render(<VODCategoryFilter {...defaultProps({ setCategoryStates })} />);
fireEvent.click(screen.getByText('Deselect Visible'));
const called = setCategoryStates.mock.calls.at(-1)[0];
expect(called.find((s) => s.id === 1).enabled).toBe(false);
expect(called.find((s) => s.id === 2).enabled).toBe(false);
expect(called.find((s) => s.id === 3).enabled).toBe(false);
});
it('only disables filtered categories when a text filter is active', () => {
const setCategoryStates = vi.fn();
render(
<VODCategoryFilter
{...defaultProps({
setCategoryStates,
categoryStates: [
{ id: 1, name: 'Action', enabled: true },
{ id: 2, name: 'Comedy', enabled: true },
{ id: 3, name: 'Drama', enabled: true },
],
})}
/>
);
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
target: { value: 'comedy' },
});
fireEvent.click(screen.getByText('Deselect Visible'));
const called = setCategoryStates.mock.calls.at(-1)[0];
expect(called.find((s) => s.id === 2).enabled).toBe(false);
expect(called.find((s) => s.id === 1).enabled).toBe(true);
expect(called.find((s) => s.id === 3).enabled).toBe(true);
});
});
// Auto-enable new groups
describe('autoEnableNewGroups checkbox', () => {
it('calls setAutoEnableNewGroups(true) when checked', () => {
const setAutoEnableNewGroups = vi.fn();
render(
<VODCategoryFilter
{...defaultProps({
autoEnableNewGroups: false,
setAutoEnableNewGroups,
})}
/>
);
fireEvent.click(screen.getByLabelText(/automatically enable new/i));
expect(setAutoEnableNewGroups).toHaveBeenCalledWith(true);
});
it('calls setAutoEnableNewGroups(false) when unchecked', () => {
const setAutoEnableNewGroups = vi.fn();
render(
<VODCategoryFilter
{...defaultProps({
autoEnableNewGroups: true,
setAutoEnableNewGroups,
})}
/>
);
fireEvent.click(screen.getByLabelText(/automatically enable new/i));
expect(setAutoEnableNewGroups).toHaveBeenCalledWith(false);
});
});
// No playlist / empty categories
describe('edge cases', () => {
it('renders no category buttons when categories list is empty', () => {
setupMocks({ categories: [] });
render(<VODCategoryFilter {...defaultProps({ categoryStates: [] })} />);
expect(
screen.queryByRole('button', { name: 'Action' })
).not.toBeInTheDocument();
});
it('renders no category buttons when categoryStates is empty', () => {
render(<VODCategoryFilter {...defaultProps({ categoryStates: [] })} />);
expect(
screen.queryByRole('button', { name: 'Action' })
).not.toBeInTheDocument();
});
it('renders categories only for the matching playlist id', () => {
setupMocks({
categories: [
...makeCategories(),
{
id: 99,
name: 'Foreign',
m3u_accounts: [{ m3u_account: 99, enabled: true }],
category_type: 'movies',
},
],
});
render(<VODCategoryFilter {...defaultProps()} />);
expect(
screen.queryByRole('button', { name: 'Foreign' })
).not.toBeInTheDocument();
});
});
});

View file

@ -33,12 +33,10 @@ import { useElementSize } from '@mantine/hooks';
import { VariableSizeList } from 'react-window';
import {
buildChannelIdMap,
calculateDesiredScrollPosition,
calculateEarliestProgramStart,
calculateEnd,
calculateHourTimeline,
calculateLatestProgramEnd,
calculateLeftScrollPosition,
calculateNowPosition,
calculateScrollPosition,
calculateScrollPositionByTimeClick,
@ -47,7 +45,6 @@ import {
computeRowHeights,
createRecording,
createSeriesRule,
evaluateSeriesRule,
fetchPrograms,
fetchRules,
filterGuideChannels,
@ -66,6 +63,7 @@ import {
PX_PER_MS,
calcProgressPct,
sortChannels,
evaluateSeriesRulesByTvgId,
} from '../utils/guideUtils';
import API from '../api';
import { getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js';
@ -739,13 +737,22 @@ export default function TVChannelGuide({ startDate, endDate }) {
return;
}
await createRecording(channel, program);
await createRecording({
channel: `${channel.id}`,
start_time: program.start_time,
end_time: program.end_time,
custom_properties: { program },
});
showNotification({ title: 'Recording scheduled' });
}, []);
const saveSeriesRule = useCallback(async (program, mode) => {
await createSeriesRule(program, mode);
await evaluateSeriesRule(program);
await createSeriesRule({
tvg_id: program.tvg_id,
mode,
title: program.title,
});
await evaluateSeriesRulesByTvgId(program.tvg_id);
// recordings_refreshed WS event triggers the debounced fetchRecordings()
showNotification({
title: mode === 'new' ? 'Record new episodes' : 'Record all episodes',

View file

@ -9,20 +9,36 @@ import useEPGsStore from '../../store/epgs';
import useSettingsStore from '../../store/settings';
import useVideoStore from '../../store/useVideoStore';
import useLocalStorage from '../../hooks/useLocalStorage';
import { showNotification } from '../../utils/notificationUtils.js';
import * as guideUtils from '../../utils/guideUtils';
import * as recordingCardUtils from '../../utils/cards/RecordingCardUtils.js';
import * as dateTimeUtils from '../../utils/dateTimeUtils.js';
import userEvent from '@testing-library/user-event';
// Mock dependencies
vi.mock('../../store/channels');
vi.mock('../../store/logos');
vi.mock('../../store/epgs');
vi.mock('../../store/settings');
vi.mock('../../store/useVideoStore');
vi.mock('../../hooks/useLocalStorage');
vi.mock('../../api');
vi.mock('../../store/channels', () => ({
default: vi.fn(),
}));
vi.mock('../../store/logos', () => ({
default: vi.fn(),
}));
vi.mock('../../store/epgs', () => ({
default: vi.fn(),
}));
vi.mock('../../store/settings', () => ({
default: vi.fn(),
}));
vi.mock('../../store/useVideoStore', () => ({
default: vi.fn(),
}));
vi.mock('../../hooks/useLocalStorage', () => ({
default: vi.fn(),
}));
vi.mock('../../api', () => ({
default: {
getAllChannelIds: vi.fn(),
getChannelsSummary: vi.fn(),
},
}));
vi.mock('@mantine/hooks', () => ({
useElementSize: () => ({
@ -235,7 +251,7 @@ vi.mock('../../components/forms/SeriesRecordingModal', () => ({
}));
vi.mock('../../components/ProgramDetailModal', () => ({
__esModule: true,
default: ({ program, channel, opened, onClose, onRecord }) =>
default: ({ program, opened, onClose, onRecord }) =>
opened ? (
<div data-testid="program-detail-modal">
<div>{program?.title}</div>
@ -252,11 +268,11 @@ vi.mock('../../utils/guideUtils', async () => {
fetchPrograms: vi.fn(),
createRecording: vi.fn(),
createSeriesRule: vi.fn(),
evaluateSeriesRule: vi.fn(),
fetchRules: vi.fn(),
filterGuideChannels: vi.fn(),
getGroupOptions: vi.fn(),
getProfileOptions: vi.fn(),
sortChannels: vi.fn((ch) => ch),
};
});
vi.mock('../../utils/cards/RecordingCardUtils.js', async () => {
@ -403,7 +419,6 @@ describe('Guide', () => {
);
guideUtils.createRecording.mockResolvedValue(undefined);
guideUtils.createSeriesRule.mockResolvedValue(undefined);
guideUtils.evaluateSeriesRule.mockResolvedValue(undefined);
guideUtils.getGroupOptions.mockReturnValue([
{ value: 'all', label: 'All Groups' },
{ value: 'group-1', label: 'News' },
@ -666,8 +681,7 @@ describe('Guide', () => {
await waitFor(() => {
expect(guideUtils.createRecording).toHaveBeenCalledWith(
expect.objectContaining({ id: 'channel-1' }),
expect.objectContaining({ id: 'prog-1' })
expect.objectContaining({ channel: 'channel-1' }),
);
});
});

File diff suppressed because it is too large Load diff

View file

@ -62,6 +62,24 @@ describe('dateTimeUtils', () => {
const result = dateTimeUtils.initializeTime(date);
expect(result.format()).toBe(dayjs(date).format());
});
it('should handle custom format and locale', () => {
const result = dateTimeUtils.initializeTime('15/01/2024', 'DD/MM/YYYY');
expect(result.isValid()).toBe(true);
expect(result.date()).toBe(15);
expect(result.month()).toBe(0); // January = 0
expect(result.year()).toBe(2024);
});
it('should handle strict parsing', () => {
// With strict=true, a date that doesn't match the format should be invalid
const invalid = dateTimeUtils.initializeTime('15-01-2024', 'YYYY-MM-DD', null, true);
expect(invalid.isValid()).toBe(false);
// With strict=true and a matching format, it should be valid
const valid = dateTimeUtils.initializeTime('2024-01-15', 'YYYY-MM-DD', null, true);
expect(valid.isValid()).toBe(true);
});
});
describe('startOfDay', () => {
@ -433,7 +451,7 @@ describe('dateTimeUtils', () => {
});
it('should return original string for unparseable format', () => {
expect(dateTimeUtils.toTimeString('2:30 PM')).toBe('2:30 PM');
expect(dateTimeUtils.toTimeString('not-a-time')).toBe('not-a-time');
});
it('should return original string for invalid format', () => {
@ -712,4 +730,61 @@ describe('dateTimeUtils', () => {
});
});
});
describe('isValid', () => {
it('should return true for a valid date string', () => {
expect(dateTimeUtils.isValid('2024-01-15T10:00:00Z')).toBe(true);
});
it('should return false for an invalid date string', () => {
expect(dateTimeUtils.isValid('not-a-date')).toBe(false);
});
it('should return true for a Date object', () => {
expect(dateTimeUtils.isValid(new Date())).toBe(true);
});
it('should return false for an invalid Date object', () => {
expect(dateTimeUtils.isValid(new Date('invalid'))).toBe(false);
});
});
describe('toDate', () => {
it('should convert a date string to a Date object', () => {
const result = dateTimeUtils.toDate('2024-01-15T10:00:00Z');
expect(result).toBeInstanceOf(Date);
});
it('should convert a dayjs object to a Date object', () => {
const djs = dayjs('2024-01-15T10:00:00Z');
const result = dateTimeUtils.toDate(djs);
expect(result).toBeInstanceOf(Date);
});
});
describe('setMillisecond', () => {
it('should set the millisecond on a date', () => {
const date = dayjs.utc('2024-01-15T10:00:00.000Z');
const result = dateTimeUtils.setMillisecond(date, 500);
expect(result.millisecond()).toBe(500);
});
it('should return 0 milliseconds when set to 0', () => {
const date = dayjs.utc('2024-01-15T10:00:00.999Z');
const result = dateTimeUtils.setMillisecond(date, 0);
expect(result.millisecond()).toBe(0);
});
});
describe('getMillisecond', () => {
it('should return the millisecond from a date', () => {
const date = dayjs.utc('2024-01-15T14:00:00.123Z');
expect(dateTimeUtils.getMillisecond(date)).toBe(123);
});
it('should return 0 for a date with no milliseconds', () => {
const date = dayjs.utc('2024-01-15T14:00:00.000Z');
expect(dateTimeUtils.getMillisecond(date)).toBe(0);
});
});
});

View file

@ -4,6 +4,7 @@ import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import useSettingsStore from '../store/settings';
import useLocalStorage from '../hooks/useLocalStorage';
@ -11,12 +12,21 @@ dayjs.extend(duration);
dayjs.extend(relativeTime);
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(customParseFormat);
export const convertToMs = (dateTime) => dayjs(dateTime).valueOf();
export const convertToSec = (dateTime) => dayjs(dateTime).unix();
export const initializeTime = (dateTime) => dayjs(dateTime);
export const initializeTime = (dateTime, format = null, locale = null, strict = false) => {
if (format && locale) {
return dayjs(dateTime, format, locale, strict);
} else if (format) {
return dayjs(dateTime, format, strict);
} else {
return dayjs(dateTime);
}
}
export const startOfDay = (dateTime) => dayjs(dateTime).startOf('day');
@ -43,6 +53,10 @@ export const getNow = () => dayjs();
export const toFriendlyDuration = (dateTime, unit) =>
dayjs.duration(dateTime, unit).humanize();
export const isValid = (dateTime) => dayjs(dateTime).isValid();
export const toDate = (dateTime) => dayjs(dateTime).toDate();
export const formatExactDuration = (seconds) => {
if (seconds < 60) return `${seconds.toFixed(1)} seconds`;
if (seconds < 3600) {
@ -76,6 +90,8 @@ export const setMinute = (dateTime, value) => dayjs(dateTime).minute(value);
export const setSecond = (dateTime, value) => dayjs(dateTime).second(value);
export const setMillisecond = (dateTime, value) => dayjs(dateTime).millisecond(value);
export const getMonth = (dateTime) => dayjs(dateTime).month();
export const getYear = (dateTime) => dayjs(dateTime).year();
@ -88,6 +104,8 @@ export const getMinute = (dateTime) => dayjs(dateTime).minute();
export const getSecond = (dateTime) => dayjs(dateTime).second();
export const getMillisecond = (dateTime) => dayjs(dateTime).millisecond();
export const getNowMs = () => Date.now();
export const roundToNearest = (dateTime, minutes) => {

View file

@ -1,3 +1,5 @@
import API from '../../api.js';
export const getStatRows = (stats) => {
return [
['Video Codec', stats.video_codec],
@ -86,3 +88,22 @@ export const getUpcomingEpisodes = (
(a, b) => toUserTime(a.start_time) - toUserTime(b.start_time)
);
};
export const getChannel = (id) => {
return API.getChannel(id);
};
export const updateRecordingMetadata = (
recording,
editTitle,
editDescription
) => {
return API.updateRecordingMetadata(recording.id, {
title: editTitle || 'Custom Recording',
description: editDescription,
});
};
export const refreshArtwork = (id) => {
return API.refreshArtwork(id);
};

View file

@ -0,0 +1,181 @@
import API from '../../api.js';
import {
add,
diff,
format,
getNow,
initializeTime,
isValid,
roundToNearest,
setMillisecond,
setSecond,
toDate,
toTimeString,
} from '../dateTimeUtils.js';
import { isNotEmpty } from '@mantine/form';
export const asDate = (value) => {
if (!value) return null;
if (value instanceof Date) return value;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
export const toIsoIfDate = (value) => {
const dt = asDate(value);
return dt ? dt.toISOString() : value;
};
export const toDateString = (value) => {
const dt = asDate(value);
return dt ? format(dt, 'YYYY-MM-DD') : null;
};
export const createRoundedDate = (minutesAhead = 0) => {
let rounded = roundToNearest(getNow(), 30);
rounded = setSecond(rounded, 0);
rounded = setMillisecond(rounded, 0);
return toDate(minutesAhead ? add(rounded, minutesAhead, 'minute') : rounded);
};
// robust onChange for TimeInput (string or event)
export const timeChange = (setter) => (valOrEvent) => {
if (typeof valOrEvent === 'string') setter(valOrEvent);
else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value);
};
export const getChannelsSummary = () => {
return API.getChannelsSummary();
};
export const updateRecording = (id, data) => {
return API.updateRecording(id, data);
};
export const createRecording = (data) => {
return API.createRecording(data);
};
export const createRecurringRule = (data) => {
return API.createRecurringRule(data);
};
/**
* Sorts a channels array or object by channel_number (then name) and maps
* each entry to a { value, label } select option.
*
* @param {Array|Object} channels
* @param {(item: object) => string} [labelFn] defaults to `name || "Channel id"`
*/
export const sortedChannelOptions = (channels, labelFn) => {
const list = Array.isArray(channels)
? channels
: Object.values(channels || {});
const defaultLabel = (item) => item.name || `Channel ${item.id}`;
return [...list]
.sort((a, b) => {
const aNum = Number(a.channel_number) || 0;
const bNum = Number(b.channel_number) || 0;
if (aNum !== bNum) return aNum - bNum;
return (a.name || '').localeCompare(b.name || '');
})
.map((item) => ({
value: String(item.id),
label: (labelFn ?? defaultLabel)(item),
}));
};
/** Label that includes the channel number prefix used in Recording/SeriesRule UIs. */
export const numberedChannelLabel = (item) =>
item.channel_number
? `${item.channel_number} - ${item.name || `Channel ${item.id}`}`
: item.name || `Channel ${item.id}`;
export const getSingleFormDefaults = (recording = null, channel = null) => {
const defaultStart = createRoundedDate();
const defaultEnd = createRoundedDate(60);
return {
channel_id: recording
? `${recording.channel}`
: channel
? `${channel.id}`
: '',
start_time: recording
? asDate(recording.start_time) || defaultStart
: defaultStart,
end_time: recording ? asDate(recording.end_time) || defaultEnd : defaultEnd,
};
};
export const getRecurringFormDefaults = (channel = null) => {
const defaultStart = createRoundedDate();
const defaultEnd = createRoundedDate(60);
const defaultDate = new Date();
return {
channel_id: channel ? `${channel.id}` : '',
days_of_week: [],
start_time: format(defaultStart, 'HH:mm'),
end_time: format(defaultEnd, 'HH:mm'),
rule_name: channel?.name || '',
start_date: defaultDate,
end_date: defaultDate,
};
};
export const buildSinglePayload = (values) => ({
channel: values.channel_id,
start_time: toIsoIfDate(values.start_time),
end_time: toIsoIfDate(values.end_time),
});
export const buildRecurringPayload = (values) => ({
channel: values.channel_id,
days_of_week: (values.days_of_week || []).map(Number),
start_time: toTimeString(values.start_time),
end_time: toTimeString(values.end_time),
start_date: toDateString(values.start_date),
end_date: toDateString(values.end_date),
name: values.rule_name?.trim() || '',
});
export const singleFormValidators = {
channel_id: isNotEmpty('Select a channel'),
start_time: isNotEmpty('Select a start time'),
end_time: (value, values) => {
const start = asDate(values.start_time);
const end = asDate(value);
if (!end) return 'Select an end time';
if (start && end <= start) return 'End time must be after start time';
return null;
},
};
export const recurringFormValidators = {
channel_id: isNotEmpty('Select a channel'),
days_of_week: (value) => (value?.length ? null : 'Pick at least one day'),
start_time: (value) => (value ? null : 'Select a start time'),
end_time: (value, values) => {
if (!value) return 'Select an end time';
const start = initializeTime(
values.start_time,
['HH:mm', 'hh:mm A', 'h:mm A'],
null,
true
);
const end = initializeTime(
value,
['HH:mm', 'hh:mm A', 'h:mm A'],
null,
true
);
if (isValid(start) && isValid(end) && diff(start, end, 'minute') === 0)
return 'End time must differ from start time';
return null;
},
end_date: (value, values) => {
const end = asDate(value);
const start = asDate(values.start_date);
if (!end) return 'Select an end date';
if (start && end < start) return 'End date cannot be before start date';
return null;
},
};

View file

@ -1,25 +1,12 @@
import API from '../../api.js';
import { toTimeString } from '../dateTimeUtils.js';
import dayjs from 'dayjs';
export const getChannelOptions = (channels) => {
const list = Array.isArray(channels)
? channels
: Object.values(channels || {});
return list
.sort((a, b) => {
const aNum = Number(a.channel_number) || 0;
const bNum = Number(b.channel_number) || 0;
if (aNum === bNum) {
return (a.name || '').localeCompare(b.name || '');
}
return aNum - bNum;
})
.map((item) => ({
value: `${item.id}`,
label: item.name || `Channel ${item.id}`,
}));
};
import {
getNow,
isAfter,
parseDate,
toDate,
toTimeString,
} from '../dateTimeUtils.js';
import { buildRecurringPayload } from './RecordingUtils.js';
export const getUpcomingOccurrences = (
recordings,
@ -35,7 +22,7 @@ export const getUpcomingOccurrences = (
.filter(
(rec) =>
rec?.custom_properties?.rule?.id === ruleId &&
toUserTime(rec.start_time).isAfter(now)
isAfter(toUserTime(rec.start_time), now)
)
.sort(
(a, b) =>
@ -45,17 +32,7 @@ export const getUpcomingOccurrences = (
export const updateRecurringRule = async (ruleId, values) => {
await API.updateRecurringRule(ruleId, {
channel: values.channel_id,
days_of_week: (values.days_of_week || []).map((d) => Number(d)),
start_time: toTimeString(values.start_time),
end_time: toTimeString(values.end_time),
start_date: values.start_date
? dayjs(values.start_date).format('YYYY-MM-DD')
: null,
end_date: values.end_date
? dayjs(values.end_date).format('YYYY-MM-DD')
: null,
name: values.rule_name?.trim() || '',
...buildRecurringPayload(values),
enabled: Boolean(values.enabled),
});
};
@ -67,3 +44,16 @@ export const deleteRecurringRuleById = async (ruleId) => {
export const updateRecurringRuleEnabled = async (ruleId, checked) => {
await API.updateRecurringRule(ruleId, { enabled: checked });
};
export const getFormDefaults = (rule) => {
return {
channel_id: `${rule.channel}`,
days_of_week: (rule.days_of_week || []).map((d) => String(d)),
rule_name: rule.name || '',
start_time: toTimeString(rule.start_time),
end_time: toTimeString(rule.end_time),
start_date: parseDate(rule.start_date) || toDate(getNow()),
end_date: parseDate(rule.end_date),
enabled: Boolean(rule.enabled),
};
};

View file

@ -0,0 +1,84 @@
import API from '../../api.js';
import {
numberedChannelLabel,
sortedChannelOptions,
} from './RecordingUtils.js';
export const TITLE_MODES = [
{ label: 'Exact', value: 'exact' },
{ label: 'Contains', value: 'contains' },
{ label: 'Whole word', value: 'search' },
{ label: 'Regex', value: 'regex' },
];
export const DESCRIPTION_MODES = [
{ label: 'Contains', value: 'contains' },
{ label: 'Whole word', value: 'search' },
{ label: 'Regex', value: 'regex' },
];
export const EPISODE_MODES = [
{ label: 'All episodes', value: 'all' },
{ label: 'New only', value: 'new' },
];
export function formatRange(start, end) {
try {
const s = new Date(start);
const e = new Date(end);
if (isNaN(s) || isNaN(e)) throw new Error('Invalid date');
const sameDay = s.toDateString() === e.toDateString();
const dateStr = s.toLocaleDateString();
const startStr = s.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
const endStr = e.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
return sameDay
? `${dateStr} ${startStr} - ${endStr}`
: `${dateStr} ${startStr} -> ${e.toLocaleString()}`;
} catch {
return `${start} - ${end}`;
}
}
export const previewSeriesRule = (debouncedPreviewKey, controller) => {
return API.previewSeriesRule(debouncedPreviewKey, {
signal: controller.signal,
});
};
export const getTvgOptions = (tvgs) => {
const seen = new Set();
const options = [];
for (const t of tvgs || []) {
if (!t.tvg_id || seen.has(t.tvg_id)) continue;
seen.add(t.tvg_id);
options.push({
value: t.tvg_id,
label: t.name ? `${t.name} (${t.tvg_id})` : t.tvg_id,
});
}
return options.sort((a, b) => a.label.localeCompare(b.label));
};
export const getChannelOptions = (allChannels, tvgsById, tvgId) => {
const sorted = sortedChannelOptions(allChannels, numberedChannelLabel);
const matching = [];
const others = [];
for (const item of sorted) {
const channel = allChannels.find((c) => String(c.id) === item.value);
const cTvg = channel?.epg_data_id
? tvgsById?.[channel.epg_data_id]?.tvg_id
: null;
if (tvgId && cTvg && String(cTvg) === String(tvgId)) {
matching.push(item);
} else {
others.push(item);
}
}
return [...matching, ...others];
};

View file

@ -0,0 +1,44 @@
// Built-in commands supported by Dispatcharr out of the box.
import { yupResolver } from '@hookform/resolvers/yup';
import API from '../../api.js';
import * as Yup from 'yup';
export const BUILT_IN_COMMANDS = [
{ value: 'ffmpeg', label: 'FFmpeg' },
{ value: 'streamlink', label: 'Streamlink' },
{ value: 'cvlc', label: 'VLC' },
{ value: 'yt-dlp', label: 'yt-dlp' },
{ value: '__custom__', label: 'Custom…' },
];
// Default parameter examples for each built-in command.
export const COMMAND_EXAMPLES = {
ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1',
streamlink: '{streamUrl} --http-header User-Agent={userAgent} best --stdout',
cvlc: '-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}',
'yt-dlp': '--hls-use-mpegts -f best -o - {streamUrl}',
};
// Returns '__custom__' when the command isn't one of the built-ins,
// otherwise returns the command value itself.
export const toCommandSelection = (command) =>
BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__')
? command
: '__custom__';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
command: Yup.string().required('Command is required'),
parameters: Yup.string(),
});
export const getResolver = () => {
return yupResolver(schema);
};
export const updateStreamProfile = (profileId, values) => {
return API.updateStreamProfile({ id: profileId, ...values });
};
export const addStreamProfile = (values) => {
return API.addStreamProfile(values);
};

View file

@ -0,0 +1,19 @@
import * as Yup from 'yup';
import { yupResolver } from '@hookform/resolvers/yup';
import API from '../../api.js';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
url: Yup.string().required('URL is required').min(0),
});
export const getResolver = () => {
return yupResolver(schema);
};
export const updateStream = (streamId, payload) => {
return API.updateStream({ id: streamId, ...payload });
};
export const addStream = (payload) => {
return API.addStream(payload);
};

View file

@ -0,0 +1,17 @@
import { yupResolver } from '@hookform/resolvers/yup';
import API from '../../api.js';
import * as Yup from 'yup';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
user_agent: Yup.string().required('User-Agent is required'),
});
export const getResolver = () => {
return yupResolver(schema);
};
export const updateUserAgent = (userAgentId, values) => {
return API.updateUserAgent({ id: userAgentId, ...values });
};
export const addUserAgent = (values) => {
return API.addUserAgent(values);
};

View file

@ -0,0 +1,148 @@
import { NETWORK_ACCESS_OPTIONS, USER_LEVELS } from '../../constants.js';
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../networkUtils.js';
import API from '../../api.js';
const isValidNetworkEntry = (entry) =>
entry.match(IPV4_CIDR_REGEX) ||
entry.match(IPV6_CIDR_REGEX) ||
(entry + '/32').match(IPV4_CIDR_REGEX) ||
(entry + '/128').match(IPV6_CIDR_REGEX);
const NETWORK_KEYS = Object.keys(NETWORK_ACCESS_OPTIONS);
export const createUser = (values) => {
return API.createUser(values);
};
export const updateUser = (userId, values, isAdmin, authUser) => {
return API.updateUser(
userId,
values,
isAdmin ? false : authUser.id === userId
);
};
export const generateApiKey = (payload) => {
return API.generateApiKey(payload);
};
export const revokeApiKey = (payload) => {
return API.revokeApiKey(payload);
};
export const userToFormValues = (user) => {
const customProps = user.custom_properties || {};
const networks = customProps.allowed_networks || {};
return {
username: user.username,
first_name: user.first_name || '',
last_name: user.last_name || '',
email: user.email,
user_level: `${user.user_level}`,
stream_limit: user.stream_limit || 0,
channel_profiles:
user.channel_profiles.length > 0
? user.channel_profiles.map((id) => `${id}`)
: ['0'],
xc_password: customProps.xc_password || '',
output_format: customProps.output_format || '',
output_profile: customProps.output_profile
? `${customProps.output_profile}`
: '',
hide_adult_content: customProps.hide_adult_content || false,
epg_days: customProps.epg_days || 0,
epg_prev_days: customProps.epg_prev_days || 0,
allowed_ips: [
...new Set(
NETWORK_KEYS.flatMap((key) =>
networks[key] ? networks[key].split(',').filter(Boolean) : []
)
),
],
};
};
export const formValuesToPayload = (values, existingUser) => {
const customProps = { ...(existingUser?.custom_properties || {}) };
const payload = { ...values };
customProps.xc_password = payload.xc_password || '';
delete payload.xc_password;
customProps.output_format = payload.output_format || null;
delete payload.output_format;
customProps.output_profile = payload.output_profile
? parseInt(payload.output_profile, 10)
: null;
delete payload.output_profile;
customProps.hide_adult_content = payload.hide_adult_content || false;
delete payload.hide_adult_content;
customProps.epg_days = payload.epg_days || 0;
delete payload.epg_days;
customProps.epg_prev_days = payload.epg_prev_days || 0;
delete payload.epg_prev_days;
const joined = (payload.allowed_ips || []).join(',');
delete payload.allowed_ips;
const allowed_networks = {};
if (joined) {
NETWORK_KEYS.forEach((key) => {
allowed_networks[key] = joined;
});
}
customProps.allowed_networks = allowed_networks;
payload.custom_properties = customProps;
if (payload.channel_profiles?.includes('0')) {
payload.channel_profiles = [];
}
return payload;
};
export const getFormInitialValues = () => {
return {
username: '',
first_name: '',
last_name: '',
email: '',
user_level: '0',
stream_limit: 0,
password: '',
xc_password: '',
output_format: '',
output_profile: '',
channel_profiles: [],
hide_adult_content: false,
epg_days: 0,
epg_prev_days: 0,
allowed_ips: [],
};
};
export const getFormValidators = (user) => {
return (values) => ({
username: !values.username
? 'Username is required'
: values.user_level == USER_LEVELS.STREAMER &&
!values.username.match(/^[a-z0-9]+$/i)
? 'Streamer username must be alphanumeric'
: null,
password:
!user && !values.password && values.user_level != USER_LEVELS.STREAMER
? 'Password is required'
: null,
xc_password:
values.xc_password && !values.xc_password.match(/^[a-z0-9]+$/i)
? 'XC password must be alphanumeric'
: null,
allowed_ips: (values.allowed_ips || []).some((t) => !isValidNetworkEntry(t))
? 'Invalid IP address or CIDR range'
: null,
});
};

View file

@ -1,8 +1,21 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as RecordingDetailsModalUtils from '../RecordingDetailsModalUtils';
import dayjs from 'dayjs';
import API from '../../../api.js';
vi.mock('../../../api.js', () => ({
default: {
getChannel: vi.fn(),
updateRecordingMetadata: vi.fn(),
refreshArtwork: vi.fn(),
},
}));
describe('RecordingDetailsModalUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getStatRows', () => {
it('should return all stats when all values are present', () => {
const stats = {
@ -624,4 +637,108 @@ describe('RecordingDetailsModalUtils', () => {
expect(result).toEqual([]);
});
});
describe('getChannel', () => {
it('should call API.getChannel with the given id', async () => {
const mockChannel = { id: 42, name: 'Channel 1' };
API.getChannel.mockResolvedValue(mockChannel);
const result = await RecordingDetailsModalUtils.getChannel(42);
expect(API.getChannel).toHaveBeenCalledWith(42);
expect(result).toEqual(mockChannel);
});
it('should propagate API errors', async () => {
API.getChannel.mockRejectedValue(new Error('Not found'));
await expect(RecordingDetailsModalUtils.getChannel(99)).rejects.toThrow(
'Not found'
);
});
});
describe('updateRecordingMetadata', () => {
it('should call API with title and description', async () => {
const mockResponse = { id: 1, title: 'Updated Title' };
API.updateRecordingMetadata.mockResolvedValue(mockResponse);
const recording = { id: 1 };
const result = await RecordingDetailsModalUtils.updateRecordingMetadata(
recording,
'Updated Title',
'Updated description'
);
expect(API.updateRecordingMetadata).toHaveBeenCalledWith(1, {
title: 'Updated Title',
description: 'Updated description',
});
expect(result).toEqual(mockResponse);
});
it('should default title to "Custom Recording" when editTitle is empty', async () => {
API.updateRecordingMetadata.mockResolvedValue({});
const recording = { id: 5 };
await RecordingDetailsModalUtils.updateRecordingMetadata(
recording,
'',
'Some description'
);
expect(API.updateRecordingMetadata).toHaveBeenCalledWith(5, {
title: 'Custom Recording',
description: 'Some description',
});
});
it('should default title to "Custom Recording" when editTitle is null', async () => {
API.updateRecordingMetadata.mockResolvedValue({});
const recording = { id: 5 };
await RecordingDetailsModalUtils.updateRecordingMetadata(
recording,
null,
'desc'
);
expect(API.updateRecordingMetadata).toHaveBeenCalledWith(5, {
title: 'Custom Recording',
description: 'desc',
});
});
it('should propagate API errors', async () => {
API.updateRecordingMetadata.mockRejectedValue(new Error('Server error'));
await expect(
RecordingDetailsModalUtils.updateRecordingMetadata(
{ id: 1 },
'Title',
'Desc'
)
).rejects.toThrow('Server error');
});
});
describe('refreshArtwork', () => {
it('should call API.refreshArtwork with the given id', async () => {
const mockResponse = { success: true };
API.refreshArtwork.mockResolvedValue(mockResponse);
const result = await RecordingDetailsModalUtils.refreshArtwork(7);
expect(API.refreshArtwork).toHaveBeenCalledWith(7);
expect(result).toEqual(mockResponse);
});
it('should propagate API errors', async () => {
API.refreshArtwork.mockRejectedValue(new Error('Artwork fetch failed'));
await expect(
RecordingDetailsModalUtils.refreshArtwork(7)
).rejects.toThrow('Artwork fetch failed');
});
});
});

View file

@ -0,0 +1,514 @@
import { describe, it, expect, vi } from 'vitest';
import dayjs from 'dayjs';
vi.mock('../../../api.js', () => ({
default: {
getChannelsSummary: vi.fn(),
updateRecording: vi.fn(),
createRecording: vi.fn(),
createRecurringRule: vi.fn(),
},
}));
vi.mock('../../dateTimeUtils.js', () => ({
add: vi.fn((d, amount, unit) => dayjs(d).add(amount, unit)),
diff: vi.fn((a, b, unit) => dayjs(a).diff(dayjs(b), unit)),
format: vi.fn((d, fmt) => (d ? dayjs(d).format(fmt) : null)),
getNow: vi.fn(() => dayjs('2024-06-15T10:00:00')),
initializeTime: vi.fn((val, fmts, ref, strict) => {
for (const fmt of fmts) {
const p = dayjs(val, fmt, strict);
if (p.isValid()) return p;
}
return dayjs('invalid');
}),
isValid: vi.fn((d) => dayjs(d).isValid()),
roundToNearest: vi.fn((d) => d),
setMillisecond: vi.fn((d, ms) => dayjs(d).millisecond(ms)),
setSecond: vi.fn((d, s) => dayjs(d).second(s)),
toDate: vi.fn((d) => (d ? dayjs(d).toDate() : new Date())),
toTimeString: vi.fn((t) => {
if (!t) return '00:00';
const match = String(t).match(/(\d{2}:\d{2})/);
return match ? match[1] : t;
}),
}));
vi.mock('@mantine/form', () => ({
isNotEmpty: vi.fn((msg) => (value) => (value ? null : msg)),
}));
import API from '../../../api.js';
import {
asDate,
toIsoIfDate,
toDateString,
createRoundedDate,
timeChange,
getChannelsSummary,
updateRecording,
createRecording,
createRecurringRule,
sortedChannelOptions,
numberedChannelLabel,
getSingleFormDefaults,
getRecurringFormDefaults,
buildSinglePayload,
buildRecurringPayload,
singleFormValidators,
recurringFormValidators,
} from '../RecordingUtils.js';
describe('RecordingUtils', () => {
// ─── asDate ───────────────────────────────────────────────────────────────────
describe('asDate', () => {
it('returns null for falsy input', () => {
expect(asDate(null)).toBeNull();
expect(asDate(undefined)).toBeNull();
expect(asDate('')).toBeNull();
});
it('returns a Date instance unchanged', () => {
const d = new Date('2024-06-15');
expect(asDate(d)).toBe(d);
});
it('parses a valid ISO string to a Date', () => {
const result = asDate('2024-06-15T10:00:00Z');
expect(result).toBeInstanceOf(Date);
expect(result.toISOString()).toContain('2024-06-15');
});
it('returns null for an invalid date string', () => {
expect(asDate('not-a-date')).toBeNull();
});
});
// ─── toIsoIfDate ──────────────────────────────────────────────────────────────
describe('toIsoIfDate', () => {
it('returns ISO string for a valid Date', () => {
const d = new Date('2024-06-15T00:00:00Z');
expect(toIsoIfDate(d)).toBe(d.toISOString());
});
it('returns ISO string for a valid date string', () => {
const result = toIsoIfDate('2024-06-15T10:00:00Z');
expect(result).toMatch(/2024-06-15/);
});
it('returns the original value when not a valid date', () => {
expect(toIsoIfDate('some-string')).toBe('some-string');
});
it('returns null for null input', () => {
expect(toIsoIfDate(null)).toBeNull();
});
});
// ─── toDateString ─────────────────────────────────────────────────────────────
describe('toDateString', () => {
it('formats a Date to YYYY-MM-DD', () => {
const d = new Date('2024-06-15T12:00:00Z');
const result = toDateString(d);
expect(result).toBe('2024-06-15');
});
it('formats a valid ISO string to YYYY-MM-DD', () => {
expect(toDateString('2024-06-15T12:00:00')).toBe('2024-06-15');
});
it('returns null for null input', () => {
expect(toDateString(null)).toBeNull();
});
it('returns null for an invalid string', () => {
expect(toDateString('not-a-date')).toBeNull();
});
});
// ─── createRoundedDate ────────────────────────────────────────────────────────
describe('createRoundedDate', () => {
it('returns a Date', () => {
expect(createRoundedDate()).toBeInstanceOf(Date);
});
it('returns a Date when minutesAhead is provided', () => {
expect(createRoundedDate(60)).toBeInstanceOf(Date);
});
});
// ─── timeChange ───────────────────────────────────────────────────────────────
describe('timeChange', () => {
it('calls setter with string directly', () => {
const setter = vi.fn();
timeChange(setter)('10:30');
expect(setter).toHaveBeenCalledWith('10:30');
});
it('calls setter with currentTarget.value from event', () => {
const setter = vi.fn();
timeChange(setter)({ currentTarget: { value: '09:00' } });
expect(setter).toHaveBeenCalledWith('09:00');
});
it('does not call setter for unrecognized input', () => {
const setter = vi.fn();
timeChange(setter)(42);
expect(setter).not.toHaveBeenCalled();
});
});
// ─── API wrappers ─────────────────────────────────────────────────────────────
describe('getChannelsSummary', () => {
it('calls API.getChannelsSummary', () => {
API.getChannelsSummary.mockResolvedValueOnce([]);
getChannelsSummary();
expect(API.getChannelsSummary).toHaveBeenCalled();
});
});
describe('updateRecording', () => {
it('calls API.updateRecording with id and data', () => {
API.updateRecording.mockResolvedValueOnce({});
updateRecording(1, { name: 'test' });
expect(API.updateRecording).toHaveBeenCalledWith(1, { name: 'test' });
});
});
describe('createRecording', () => {
it('calls API.createRecording with data', () => {
API.createRecording.mockResolvedValueOnce({});
createRecording({ channel: 1 });
expect(API.createRecording).toHaveBeenCalledWith({ channel: 1 });
});
});
describe('createRecurringRule', () => {
it('calls API.createRecurringRule with data', () => {
API.createRecurringRule.mockResolvedValueOnce({});
createRecurringRule({ channel: 1 });
expect(API.createRecurringRule).toHaveBeenCalledWith({ channel: 1 });
});
});
// ─── sortedChannelOptions ─────────────────────────────────────────────────────
describe('sortedChannelOptions', () => {
const channels = [
{ id: 3, name: 'CNN', channel_number: 20 },
{ id: 1, name: 'ESPN', channel_number: 5 },
{ id: 2, name: 'HBO', channel_number: 10 },
];
it('sorts channels by channel_number ascending', () => {
const result = sortedChannelOptions(channels);
// ESPN(5) < HBO(10) < CNN(20) → ids 1, 2, 3
expect(result.map((o) => o.value)).toEqual(['1', '2', '3']);
});
it('maps each channel to { value, label }', () => {
const result = sortedChannelOptions([
{ id: 7, name: 'FOX', channel_number: 3 },
]);
expect(result[0]).toEqual({ value: '7', label: 'FOX' });
});
it('uses custom labelFn when provided', () => {
const result = sortedChannelOptions(
[{ id: 1, name: 'ESPN', channel_number: 5 }],
(item) => `CH${item.channel_number} ${item.name}`
);
expect(result[0].label).toBe('CH5 ESPN');
});
it('falls back to "Channel id" when name is missing', () => {
const result = sortedChannelOptions([{ id: 9, channel_number: 1 }]);
expect(result[0].label).toBe('Channel 9');
});
it('accepts an object (dict) of channels', () => {
const dict = {
a: { id: 1, name: 'A', channel_number: 2 },
b: { id: 2, name: 'B', channel_number: 1 },
};
const result = sortedChannelOptions(dict);
expect(result[0].value).toBe('2');
expect(result[1].value).toBe('1');
});
it('returns empty array for null input', () => {
expect(sortedChannelOptions(null)).toEqual([]);
});
it('returns empty array for empty array input', () => {
expect(sortedChannelOptions([])).toEqual([]);
});
it('sorts by name when channel_numbers are equal', () => {
const input = [
{ id: 2, name: 'Zebra', channel_number: 5 },
{ id: 1, name: 'Apple', channel_number: 5 },
];
const result = sortedChannelOptions(input);
expect(result[0].label).toBe('Apple');
expect(result[1].label).toBe('Zebra');
});
it('puts channels with no channel_number (0) first in numeric order', () => {
const input = [
{ id: 1, name: 'A', channel_number: 5 },
{ id: 2, name: 'B', channel_number: null },
];
const result = sortedChannelOptions(input);
// null → 0, so it sorts before 5
expect(result[0].value).toBe('2');
});
});
// ─── numberedChannelLabel ─────────────────────────────────────────────────────
describe('numberedChannelLabel', () => {
it('returns "number - name" when both are present', () => {
expect(
numberedChannelLabel({ id: 1, name: 'HBO', channel_number: 501 })
).toBe('501 - HBO');
});
it('returns name only when channel_number is falsy', () => {
expect(
numberedChannelLabel({ id: 1, name: 'HBO', channel_number: null })
).toBe('HBO');
});
it('falls back to "Channel id" when name is missing and number present', () => {
expect(numberedChannelLabel({ id: 5, channel_number: 10 })).toBe(
'10 - Channel 5'
);
});
it('falls back to "Channel id" when both name and number are missing', () => {
expect(numberedChannelLabel({ id: 7 })).toBe('Channel 7');
});
});
// ─── getSingleFormDefaults ────────────────────────────────────────────────────
describe('getSingleFormDefaults', () => {
it('returns defaults with no arguments', () => {
const result = getSingleFormDefaults();
expect(result.channel_id).toBe('');
expect(result.start_time).toBeInstanceOf(Date);
expect(result.end_time).toBeInstanceOf(Date);
});
it('uses recording values when provided', () => {
const recording = {
channel: 3,
start_time: '2024-06-15T08:00:00Z',
end_time: '2024-06-15T09:00:00Z',
};
const result = getSingleFormDefaults(recording);
expect(result.channel_id).toBe('3');
expect(result.start_time).toBeInstanceOf(Date);
expect(result.end_time).toBeInstanceOf(Date);
});
it('uses channel id when channel is provided and recording is null', () => {
const result = getSingleFormDefaults(null, { id: 7 });
expect(result.channel_id).toBe('7');
});
});
// ─── getRecurringFormDefaults ─────────────────────────────────────────────────
describe('getRecurringFormDefaults', () => {
it('returns defaults with no arguments', () => {
const result = getRecurringFormDefaults();
expect(result.channel_id).toBe('');
expect(result.days_of_week).toEqual([]);
expect(result.rule_name).toBe('');
expect(typeof result.start_time).toBe('string');
expect(typeof result.end_time).toBe('string');
expect(result.start_date).toBeInstanceOf(Date);
expect(result.end_date).toBeInstanceOf(Date);
});
it('uses channel name and id when channel is provided', () => {
const result = getRecurringFormDefaults({ id: 5, name: 'ESPN' });
expect(result.channel_id).toBe('5');
expect(result.rule_name).toBe('ESPN');
});
});
// ─── buildSinglePayload ───────────────────────────────────────────────────────
describe('buildSinglePayload', () => {
it('builds payload from values', () => {
const values = {
channel_id: '2',
start_time: new Date('2024-06-15T08:00:00Z'),
end_time: new Date('2024-06-15T09:00:00Z'),
};
const result = buildSinglePayload(values);
expect(result.channel).toBe('2');
expect(result.start_time).toContain('2024-06-15');
expect(result.end_time).toContain('2024-06-15');
});
});
// ─── buildRecurringPayload ────────────────────────────────────────────────────
describe('buildRecurringPayload', () => {
it('builds payload from values', () => {
const values = {
channel_id: '3',
days_of_week: ['1', '3', '5'],
start_time: '08:00',
end_time: '09:00',
start_date: new Date('2024-06-15T12:00:00'),
end_date: new Date('2024-12-25T12:00:00'),
rule_name: ' Morning News ',
};
const result = buildRecurringPayload(values);
expect(result.channel).toBe('3');
expect(result.days_of_week).toEqual([1, 3, 5]);
expect(result.start_time).toBe('08:00');
expect(result.end_time).toBe('09:00');
expect(result.start_date).toBe('2024-06-15');
expect(result.end_date).toBe('2024-12-25');
expect(result.name).toBe('Morning News');
});
it('handles empty days_of_week', () => {
const values = {
channel_id: '1',
days_of_week: [],
start_time: '10:00',
end_time: '11:00',
start_date: new Date('2024-06-15T12:00:00'),
end_date: new Date('2024-06-15T12:00:00'),
rule_name: '',
};
const result = buildRecurringPayload(values);
expect(result.days_of_week).toEqual([]);
expect(result.name).toBe('');
});
});
// ─── singleFormValidators ─────────────────────────────────────────────────────
describe('singleFormValidators', () => {
describe('end_time', () => {
it('returns error when end time is missing', () => {
expect(singleFormValidators.end_time(null, { start_time: null })).toBe(
'Select an end time'
);
});
it('returns error when end time is not after start time', () => {
const start = new Date('2024-06-15T10:00:00Z');
const end = new Date('2024-06-15T09:00:00Z');
expect(singleFormValidators.end_time(end, { start_time: start })).toBe(
'End time must be after start time'
);
});
it('returns error when end equals start', () => {
const t = new Date('2024-06-15T10:00:00Z');
expect(singleFormValidators.end_time(t, { start_time: t })).toBe(
'End time must be after start time'
);
});
it('returns null when end is after start', () => {
const start = new Date('2024-06-15T09:00:00Z');
const end = new Date('2024-06-15T10:00:00Z');
expect(
singleFormValidators.end_time(end, { start_time: start })
).toBeNull();
});
});
});
// ─── recurringFormValidators ──────────────────────────────────────────────────
describe('recurringFormValidators', () => {
describe('days_of_week', () => {
it('returns error for empty array', () => {
expect(recurringFormValidators.days_of_week([])).toBe(
'Pick at least one day'
);
});
it('returns null for non-empty array', () => {
expect(recurringFormValidators.days_of_week([1])).toBeNull();
});
});
describe('start_time', () => {
it('returns error for falsy value', () => {
expect(recurringFormValidators.start_time(null)).toBe(
'Select a start time'
);
});
it('returns null for a valid time string', () => {
expect(recurringFormValidators.start_time('10:00')).toBeNull();
});
});
describe('end_time', () => {
it('returns error when value is falsy', () => {
expect(
recurringFormValidators.end_time(null, {
start_time: '2024-06-15T10:00:00',
})
).toBe('Select an end time');
});
it('returns error when end equals start (diff === 0)', () => {
const result = recurringFormValidators.end_time('2024-06-15T10:00:00', {
start_time: '2024-06-15T10:00:00',
});
expect(result).toBe('End time must differ from start time');
});
it('returns null when end differs from start', () => {
const result = recurringFormValidators.end_time('2024-06-15T11:00:00', {
start_time: '2024-06-15T10:00:00',
});
expect(result).toBeNull();
});
});
describe('end_date', () => {
it('returns error when end_date is missing', () => {
expect(
recurringFormValidators.end_date(null, { start_date: new Date() })
).toBe('Select an end date');
});
it('returns error when end_date is before start_date', () => {
const start = new Date('2024-06-15');
const end = new Date('2024-06-01');
expect(
recurringFormValidators.end_date(end, { start_date: start })
).toBe('End date cannot be before start date');
});
it('returns null when end_date is on or after start_date', () => {
const start = new Date('2024-06-15');
const end = new Date('2024-06-15');
expect(
recurringFormValidators.end_date(end, { start_date: start })
).toBeNull();
});
});
});
});

View file

@ -10,113 +10,24 @@ vi.mock('../../../api.js', () => ({
},
}));
vi.mock('../../dateTimeUtils.js', () => ({
toTimeString: vi.fn((t) => {
if (!t) return '00:00';
const match = String(t).match(/T(\d{2}:\d{2})/);
return match ? match[1] : t;
}),
parseDate: vi.fn((d) => (d ? dayjs(d).toDate() : null)),
toDate: vi.fn((d) => (d ? dayjs(d).toDate() : new Date())),
getNow: vi.fn(() => dayjs()),
isAfter: vi.fn((a, b) => dayjs(a).isAfter(dayjs(b))),
format: vi.fn((d, fmt) => (d ? dayjs(d).format(fmt) : null)),
}));
describe('RecurringRuleModalUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getChannelOptions', () => {
it('should return sorted channel options by channel number', () => {
const channels = {
ch1: { id: 1, channel_number: '10', name: 'ABC' },
ch2: { id: 2, channel_number: '5', name: 'NBC' },
ch3: { id: 3, channel_number: '15', name: 'CBS' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
expect(result).toEqual([
{ value: '2', label: 'NBC' },
{ value: '1', label: 'ABC' },
{ value: '3', label: 'CBS' },
]);
});
it('should sort alphabetically by name when channel numbers are equal', () => {
const channels = {
ch1: { id: 1, channel_number: '10', name: 'ZBC' },
ch2: { id: 2, channel_number: '10', name: 'ABC' },
ch3: { id: 3, channel_number: '10', name: 'MBC' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
expect(result).toEqual([
{ value: '2', label: 'ABC' },
{ value: '3', label: 'MBC' },
{ value: '1', label: 'ZBC' },
]);
});
it('should handle missing channel numbers', () => {
const channels = {
ch1: { id: 1, name: 'ABC' },
ch2: { id: 2, channel_number: '5', name: 'NBC' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
expect(result).toEqual([
{ value: '1', label: 'ABC' },
{ value: '2', label: 'NBC' },
]);
});
it('should use fallback label when name is missing', () => {
const channels = {
ch1: { id: 1, channel_number: '10' },
ch2: { id: 2, channel_number: '5', name: '' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
expect(result).toEqual([
{ value: '2', label: 'Channel 2' },
{ value: '1', label: 'Channel 1' },
]);
});
it('should handle empty channels object', () => {
const result = RecurringRuleModalUtils.getChannelOptions({});
expect(result).toEqual([]);
});
it('should handle null channels', () => {
const result = RecurringRuleModalUtils.getChannelOptions(null);
expect(result).toEqual([]);
});
it('should handle undefined channels', () => {
const result = RecurringRuleModalUtils.getChannelOptions(undefined);
expect(result).toEqual([]);
});
it('should convert channel id to string value', () => {
const channels = {
ch1: { id: 123, channel_number: '10', name: 'ABC' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
expect(result[0].value).toBe('123');
expect(typeof result[0].value).toBe('string');
});
it('should handle non-numeric channel numbers', () => {
const channels = {
ch1: { id: 1, channel_number: 'HD1', name: 'ABC' },
ch2: { id: 2, channel_number: '5', name: 'NBC' },
};
const result = RecurringRuleModalUtils.getChannelOptions(channels);
expect(result).toHaveLength(2);
});
});
describe('getUpcomingOccurrences', () => {
let toUserTime;
let userNow;
@ -310,11 +221,11 @@ describe('RecurringRuleModalUtils', () => {
const values = {
channel_id: '5',
days_of_week: ['1', '3', '5'],
start_time: '14:30',
end_time: '16:00',
start_date: '2024-01-01',
end_date: '2024-12-31',
rule_name: 'My Rule',
start_time: '10:00',
end_time: '11:00',
start_date: dayjs('2024-06-15'),
end_date: dayjs('2024-12-25'),
rule_name: ' Test Rule ',
enabled: true,
};
@ -323,11 +234,11 @@ describe('RecurringRuleModalUtils', () => {
expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
channel: '5',
days_of_week: [1, 3, 5],
start_time: '14:30',
end_time: '16:00',
start_date: '2024-01-01',
end_date: '2024-12-31',
name: 'My Rule',
start_time: '10:00',
end_time: '11:00',
start_date: '2024-06-15',
end_date: '2024-12-25',
name: 'Test Rule',
enabled: true,
});
});
@ -530,4 +441,32 @@ describe('RecurringRuleModalUtils', () => {
});
});
});
describe('getFormDefaults', () => {
it('should return formatted defaults from rule', () => {
const rule = {
channel: 5,
days_of_week: [1, 3, 5],
name: 'Test Rule',
start_time: '2024-06-15T10:00:00',
end_time: '2024-06-15T11:00:00',
start_date: '2024-06-15',
end_date: '2024-12-25',
enabled: true,
};
const result = RecurringRuleModalUtils.getFormDefaults(rule);
expect(result).toEqual({
channel_id: '5',
days_of_week: ['1', '3', '5'],
rule_name: 'Test Rule',
start_time: '10:00',
end_time: '11:00',
start_date: dayjs('2024-06-15').toDate(),
end_date: dayjs('2024-12-25').toDate(),
enabled: true,
});
});
});
});

View file

@ -0,0 +1,441 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Dependency mocks ───────────────────────────────────────────────────────────
vi.mock('../../../api.js', () => ({
default: {
previewSeriesRule: vi.fn(),
},
}));
vi.mock('../RecordingUtils.js', () => ({
numberedChannelLabel: vi.fn((item) =>
item.channel_number
? `${item.channel_number} - ${item.name || `Channel ${item.id}`}`
: item.name || `Channel ${item.id}`
),
sortedChannelOptions: vi.fn((channels) =>
(Array.isArray(channels) ? channels : []).map((c) => ({
value: String(c.id),
label: c.name || `Channel ${c.id}`,
}))
),
}));
// ── Imports after mocks ────────────────────────────────────────────────────────
import API from '../../../api.js';
import {
TITLE_MODES,
DESCRIPTION_MODES,
EPISODE_MODES,
formatRange,
getTvgOptions,
getChannelOptions,
previewSeriesRule,
} from '../SeriesRuleEditorModalUtils.js';
import {
sortedChannelOptions,
numberedChannelLabel,
} from '../RecordingUtils.js';
// ─────────────────────────────────────────────────────────────────────────────
describe('SeriesRuleEditorModalUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── Constants ──────────────────────────────────────────────────────────────
describe('TITLE_MODES', () => {
it('exports an array of mode objects', () => {
expect(Array.isArray(TITLE_MODES)).toBe(true);
expect(TITLE_MODES.length).toBeGreaterThan(0);
});
it('every entry has a label and value', () => {
TITLE_MODES.forEach((m) => {
expect(m).toHaveProperty('label');
expect(m).toHaveProperty('value');
});
});
it('contains exact, contains, search and regex modes', () => {
const values = TITLE_MODES.map((m) => m.value);
expect(values).toContain('exact');
expect(values).toContain('contains');
expect(values).toContain('search');
expect(values).toContain('regex');
});
});
describe('DESCRIPTION_MODES', () => {
it('exports an array of mode objects', () => {
expect(Array.isArray(DESCRIPTION_MODES)).toBe(true);
expect(DESCRIPTION_MODES.length).toBeGreaterThan(0);
});
it('every entry has a label and value', () => {
DESCRIPTION_MODES.forEach((m) => {
expect(m).toHaveProperty('label');
expect(m).toHaveProperty('value');
});
});
it('contains contains, search and regex modes', () => {
const values = DESCRIPTION_MODES.map((m) => m.value);
expect(values).toContain('contains');
expect(values).toContain('search');
expect(values).toContain('regex');
});
it('does not contain exact mode', () => {
const values = DESCRIPTION_MODES.map((m) => m.value);
expect(values).not.toContain('exact');
});
});
describe('EPISODE_MODES', () => {
it('exports an array with all and new modes', () => {
const values = EPISODE_MODES.map((m) => m.value);
expect(values).toContain('all');
expect(values).toContain('new');
});
it('every entry has a label and value', () => {
EPISODE_MODES.forEach((m) => {
expect(m).toHaveProperty('label');
expect(m).toHaveProperty('value');
});
});
});
// ── formatRange ────────────────────────────────────────────────────────────
describe('formatRange', () => {
it('formats same-day range as "date startTime - endTime"', () => {
const start = '2024-06-01T10:00:00';
const end = '2024-06-01T11:30:00';
const result = formatRange(start, end);
// Must include a dash separating start and end times (not "->")
expect(result).toMatch(/-(?!>)/);
expect(result).not.toMatch(/->/);
});
it('formats cross-day range with "->" separator', () => {
const start = '2024-06-01T23:00:00';
const end = '2024-06-02T01:00:00';
const result = formatRange(start, end);
expect(result).toMatch(/->/);
});
it('returns fallback string when dates are invalid', () => {
const result = formatRange('not-a-date', 'also-not-a-date');
expect(result).toBe('not-a-date - also-not-a-date');
});
it('includes a date string component in the output', () => {
const start = '2024-06-01T10:00:00';
const end = '2024-06-01T11:00:00';
const result = formatRange(start, end);
// toLocaleDateString produces something non-empty
expect(result.length).toBeGreaterThan(5);
});
it('handles ISO strings with timezone offsets', () => {
const start = '2024-06-01T10:00:00Z';
const end = '2024-06-01T11:00:00Z';
// Should not throw
expect(() => formatRange(start, end)).not.toThrow();
});
});
// ── getTvgOptions ──────────────────────────────────────────────────────────
describe('getTvgOptions', () => {
it('returns empty array for null input', () => {
expect(getTvgOptions(null)).toEqual([]);
});
it('returns empty array for empty array input', () => {
expect(getTvgOptions([])).toEqual([]);
});
it('maps tvgs to { value, label } options', () => {
const tvgs = [{ tvg_id: 'tvg-1', name: 'Channel One' }];
const result = getTvgOptions(tvgs);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
value: 'tvg-1',
label: 'Channel One (tvg-1)',
});
});
it('uses tvg_id as label when name is missing', () => {
const tvgs = [{ tvg_id: 'tvg-no-name' }];
const result = getTvgOptions(tvgs);
expect(result[0]).toEqual({
value: 'tvg-no-name',
label: 'tvg-no-name',
});
});
it('deduplicates by tvg_id', () => {
const tvgs = [
{ tvg_id: 'tvg-1', name: 'A' },
{ tvg_id: 'tvg-1', name: 'B' },
{ tvg_id: 'tvg-2', name: 'C' },
];
const result = getTvgOptions(tvgs);
expect(result).toHaveLength(2);
const values = result.map((o) => o.value);
expect(values).toContain('tvg-1');
expect(values).toContain('tvg-2');
});
it('skips entries with no tvg_id', () => {
const tvgs = [
{ tvg_id: null, name: 'No ID' },
{ tvg_id: '', name: 'Empty ID' },
{ tvg_id: 'tvg-1', name: 'Valid' },
];
const result = getTvgOptions(tvgs);
expect(result).toHaveLength(1);
expect(result[0].value).toBe('tvg-1');
});
it('sorts options alphabetically by label', () => {
const tvgs = [
{ tvg_id: 'tvg-z', name: 'Zebra' },
{ tvg_id: 'tvg-a', name: 'Apple' },
{ tvg_id: 'tvg-m', name: 'Mango' },
];
const result = getTvgOptions(tvgs);
const labels = result.map((o) => o.label);
expect(labels).toEqual([...labels].sort());
});
it('sorts by label text including the tvg_id suffix', () => {
const tvgs = [
{ tvg_id: 'z-id', name: 'Same' },
{ tvg_id: 'a-id', name: 'Same' },
];
const result = getTvgOptions(tvgs);
// "Same (a-id)" < "Same (z-id)"
expect(result[0].value).toBe('a-id');
});
});
// ── getChannelOptions ──────────────────────────────────────────────────────
describe('getChannelOptions', () => {
const makeChannels = () => [
{ id: 1, name: 'ESPN', channel_number: 5, epg_data_id: 'epg-1' },
{ id: 2, name: 'HBO', channel_number: 10, epg_data_id: 'epg-2' },
{ id: 3, name: 'CNN', channel_number: 20, epg_data_id: 'epg-3' },
];
const makeTvgsById = () => ({
'epg-1': { tvg_id: 'tvg-espn' },
'epg-2': { tvg_id: 'tvg-hbo' },
'epg-3': { tvg_id: 'tvg-cnn' },
});
it('calls sortedChannelOptions with allChannels and numberedChannelLabel', () => {
const channels = makeChannels();
getChannelOptions(channels, {}, null);
expect(sortedChannelOptions).toHaveBeenCalledWith(
channels,
numberedChannelLabel
);
});
it('returns all channels when tvgId is null', () => {
const channels = makeChannels();
const result = getChannelOptions(channels, makeTvgsById(), null);
expect(result).toHaveLength(3);
});
it('returns all channels when tvgId is empty string', () => {
const channels = makeChannels();
const result = getChannelOptions(channels, makeTvgsById(), '');
expect(result).toHaveLength(3);
});
it('places matching channels before non-matching when tvgId set', () => {
// Override sortedChannelOptions to return deterministic output
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
{ value: '1', label: 'ESPN' },
{ value: '2', label: 'HBO' },
{ value: '3', label: 'CNN' },
]);
const channels = makeChannels();
const tvgsById = makeTvgsById();
// tvgId matches ESPN (epg-1 → tvg-espn)
const result = getChannelOptions(channels, tvgsById, 'tvg-espn');
expect(result[0].value).toBe('1'); // ESPN first (matching)
expect(result[1].value).toBe('2'); // HBO second (non-matching)
expect(result[2].value).toBe('3'); // CNN third (non-matching)
});
it('returns all in sorted order when no channel matches tvgId', () => {
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
{ value: '1', label: 'ESPN' },
{ value: '2', label: 'HBO' },
]);
const channels = makeChannels();
const result = getChannelOptions(channels, makeTvgsById(), 'tvg-unknown');
// No matches → all in others, order preserved from sortedChannelOptions
expect(result.map((r) => r.value)).toEqual(['1', '2']);
});
it('handles channel with no epg_data_id (cTvg is null)', () => {
const channels = [
{ id: 9, name: 'NoEPG', channel_number: 1, epg_data_id: null },
];
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
{ value: '9', label: 'NoEPG' },
]);
const result = getChannelOptions(channels, {}, 'tvg-1');
// No epg match → goes to others
expect(result).toHaveLength(1);
expect(result[0].value).toBe('9');
});
it('handles missing epg_data_id entry in tvgsById', () => {
const channels = [
{
id: 5,
name: 'Unknown',
channel_number: 1,
epg_data_id: 'epg-missing',
},
];
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
{ value: '5', label: 'Unknown' },
]);
const result = getChannelOptions(channels, {}, 'tvg-1');
expect(result).toHaveLength(1);
expect(result[0].value).toBe('5');
});
it('handles null tvgsById gracefully', () => {
const channels = makeChannels();
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
{ value: '1', label: 'ESPN' },
]);
expect(() => getChannelOptions(channels, null, 'tvg-espn')).not.toThrow();
});
it('returns empty array for empty channels', () => {
vi.mocked(sortedChannelOptions).mockReturnValueOnce([]);
const result = getChannelOptions([], {}, 'tvg-1');
expect(result).toEqual([]);
});
it('places multiple matching channels before non-matching', () => {
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
{ value: '1', label: 'ESPN' },
{ value: '2', label: 'ESPN2' },
{ value: '3', label: 'CNN' },
]);
const channels = [
{ id: 1, name: 'ESPN', channel_number: 5, epg_data_id: 'epg-1' },
{ id: 2, name: 'ESPN2', channel_number: 6, epg_data_id: 'epg-4' },
{ id: 3, name: 'CNN', channel_number: 20, epg_data_id: 'epg-3' },
];
const tvgsById = {
'epg-1': { tvg_id: 'tvg-espn' },
'epg-4': { tvg_id: 'tvg-espn' }, // both ESPN channels share tvg_id
'epg-3': { tvg_id: 'tvg-cnn' },
};
const result = getChannelOptions(channels, tvgsById, 'tvg-espn');
expect(result[0].value).toBe('1');
expect(result[1].value).toBe('2');
expect(result[2].value).toBe('3');
});
});
// ── previewSeriesRule ──────────────────────────────────────────────────────
describe('previewSeriesRule', () => {
it('calls API.previewSeriesRule with the key and abort signal', async () => {
const mockResult = { matches: [], total: 0 };
vi.mocked(API.previewSeriesRule).mockResolvedValue(mockResult);
const controller = new AbortController();
const key = { title: 'Test Show', mode: 'all' };
const result = await previewSeriesRule(key, controller);
expect(API.previewSeriesRule).toHaveBeenCalledWith(key, {
signal: controller.signal,
});
expect(result).toBe(mockResult);
});
it('passes the abort signal from the controller', async () => {
vi.mocked(API.previewSeriesRule).mockResolvedValue({});
const controller = new AbortController();
await previewSeriesRule({ title: 'X' }, controller);
const callArgs = vi.mocked(API.previewSeriesRule).mock.calls[0];
expect(callArgs[1].signal).toBe(controller.signal);
});
it('propagates rejection from API.previewSeriesRule', async () => {
vi.mocked(API.previewSeriesRule).mockRejectedValue(
new Error('Network error')
);
const controller = new AbortController();
await expect(previewSeriesRule({}, controller)).rejects.toThrow(
'Network error'
);
});
it('propagates AbortError when signal is aborted', async () => {
const abortError = new DOMException('Aborted', 'AbortError');
vi.mocked(API.previewSeriesRule).mockRejectedValue(abortError);
const controller = new AbortController();
controller.abort();
await expect(previewSeriesRule({}, controller)).rejects.toThrow(
'Aborted'
);
});
it('passes through all fields of the preview key', async () => {
vi.mocked(API.previewSeriesRule).mockResolvedValue({});
const controller = new AbortController();
const key = {
title: 'My Show',
title_mode: 'exact',
description: 'drama',
description_mode: 'contains',
mode: 'new',
tvg_id: 'tvg-1',
channel_id: 5,
};
await previewSeriesRule(key, controller);
expect(API.previewSeriesRule).toHaveBeenCalledWith(
key,
expect.objectContaining({ signal: controller.signal })
);
});
});
});

View file

@ -0,0 +1,199 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../../api.js', () => ({
default: {
updateStreamProfile: vi.fn(),
addStreamProfile: vi.fn(),
},
}));
vi.mock('@hookform/resolvers/yup', () => ({
yupResolver: vi.fn((schema) => ({ __schema: schema })),
}));
import API from '../../../api.js';
import { yupResolver } from '@hookform/resolvers/yup';
import {
BUILT_IN_COMMANDS,
COMMAND_EXAMPLES,
toCommandSelection,
getResolver,
updateStreamProfile,
addStreamProfile,
} from '../StreamProfileUtils.js';
describe('StreamProfileUtils', () => {
// ─── BUILT_IN_COMMANDS ────────────────────────────────────────────────────────
describe('BUILT_IN_COMMANDS', () => {
it('includes ffmpeg, streamlink, cvlc, yt-dlp, and __custom__', () => {
const values = BUILT_IN_COMMANDS.map((c) => c.value);
expect(values).toContain('ffmpeg');
expect(values).toContain('streamlink');
expect(values).toContain('cvlc');
expect(values).toContain('yt-dlp');
expect(values).toContain('__custom__');
});
it('each entry has a value and a label', () => {
for (const cmd of BUILT_IN_COMMANDS) {
expect(cmd).toHaveProperty('value');
expect(cmd).toHaveProperty('label');
expect(typeof cmd.value).toBe('string');
expect(typeof cmd.label).toBe('string');
}
});
it('labels the custom entry "Custom…"', () => {
const custom = BUILT_IN_COMMANDS.find((c) => c.value === '__custom__');
expect(custom?.label).toBe('Custom…');
});
});
// ─── COMMAND_EXAMPLES ─────────────────────────────────────────────────────────
describe('COMMAND_EXAMPLES', () => {
it('has an entry for each non-custom built-in command', () => {
const nonCustom = BUILT_IN_COMMANDS.filter(
(c) => c.value !== '__custom__'
);
for (const cmd of nonCustom) {
expect(COMMAND_EXAMPLES).toHaveProperty(cmd.value);
expect(typeof COMMAND_EXAMPLES[cmd.value]).toBe('string');
expect(COMMAND_EXAMPLES[cmd.value].length).toBeGreaterThan(0);
}
});
it('does not have an entry for __custom__', () => {
expect(COMMAND_EXAMPLES).not.toHaveProperty('__custom__');
});
it('ffmpeg example contains {streamUrl}', () => {
expect(COMMAND_EXAMPLES.ffmpeg).toContain('{streamUrl}');
});
it('streamlink example contains {streamUrl}', () => {
expect(COMMAND_EXAMPLES.streamlink).toContain('{streamUrl}');
});
it('cvlc example contains {streamUrl}', () => {
expect(COMMAND_EXAMPLES.cvlc).toContain('{streamUrl}');
});
it('yt-dlp example contains {streamUrl}', () => {
expect(COMMAND_EXAMPLES['yt-dlp']).toContain('{streamUrl}');
});
});
// ─── toCommandSelection ───────────────────────────────────────────────────────
describe('toCommandSelection', () => {
it('returns "ffmpeg" for "ffmpeg"', () => {
expect(toCommandSelection('ffmpeg')).toBe('ffmpeg');
});
it('returns "streamlink" for "streamlink"', () => {
expect(toCommandSelection('streamlink')).toBe('streamlink');
});
it('returns "cvlc" for "cvlc"', () => {
expect(toCommandSelection('cvlc')).toBe('cvlc');
});
it('returns "yt-dlp" for "yt-dlp"', () => {
expect(toCommandSelection('yt-dlp')).toBe('yt-dlp');
});
it('returns "__custom__" for "__custom__"', () => {
expect(toCommandSelection('__custom__')).toBe('__custom__');
});
it('returns "__custom__" for an unrecognized command', () => {
expect(toCommandSelection('myspecialtool')).toBe('__custom__');
});
it('returns "__custom__" for an empty string', () => {
expect(toCommandSelection('')).toBe('__custom__');
});
it('returns "__custom__" for null', () => {
expect(toCommandSelection(null)).toBe('__custom__');
});
it('returns "__custom__" for undefined', () => {
expect(toCommandSelection(undefined)).toBe('__custom__');
});
});
// ─── getResolver ──────────────────────────────────────────────────────────────
describe('getResolver', () => {
it('calls yupResolver and returns its result', () => {
const result = getResolver();
expect(yupResolver).toHaveBeenCalledTimes(1);
expect(result).toBeDefined();
});
it('returns the same resolver on subsequent calls', () => {
const r1 = getResolver();
const r2 = getResolver();
expect(r1).toEqual(r2);
});
});
// ─── updateStreamProfile ──────────────────────────────────────────────────────
describe('updateStreamProfile', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('calls API.updateStreamProfile with id merged into values', () => {
API.updateStreamProfile.mockResolvedValueOnce({ id: 3, name: 'HD' });
updateStreamProfile(3, { name: 'HD', command: 'ffmpeg' });
expect(API.updateStreamProfile).toHaveBeenCalledWith({
id: 3,
name: 'HD',
command: 'ffmpeg',
});
});
it('returns the promise from API.updateStreamProfile', () => {
const resolved = Promise.resolve({ id: 1 });
API.updateStreamProfile.mockReturnValueOnce(resolved);
const result = updateStreamProfile(1, {});
expect(result).toBe(resolved);
});
});
// ─── addStreamProfile ─────────────────────────────────────────────────────────
describe('addStreamProfile', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('calls API.addStreamProfile with the provided values', () => {
API.addStreamProfile.mockResolvedValueOnce({ id: 5 });
addStreamProfile({ name: 'New', command: 'streamlink', parameters: '' });
expect(API.addStreamProfile).toHaveBeenCalledWith({
name: 'New',
command: 'streamlink',
parameters: '',
});
});
it('returns the promise from API.addStreamProfile', () => {
const resolved = Promise.resolve({ id: 5 });
API.addStreamProfile.mockReturnValueOnce(resolved);
const result = addStreamProfile({});
expect(result).toBe(resolved);
});
it('passes through an empty object without error', () => {
API.addStreamProfile.mockResolvedValueOnce({});
expect(() => addStreamProfile({})).not.toThrow();
expect(API.addStreamProfile).toHaveBeenCalledWith({});
});
});
});

View file

@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../../api.js', () => ({
default: {
updateStream: vi.fn(),
addStream: vi.fn(),
},
}));
vi.mock('@hookform/resolvers/yup', () => ({
yupResolver: vi.fn((schema) => ({ __schema: schema })),
}));
import API from '../../../api.js';
import { yupResolver } from '@hookform/resolvers/yup';
import { getResolver, updateStream, addStream } from '../StreamUtils.js';
describe('StreamUtils', () => {
// ─── getResolver ──────────────────────────────────────────────────────────────
describe('getResolver', () => {
it('calls yupResolver and returns its result', () => {
const result = getResolver();
expect(yupResolver).toHaveBeenCalledTimes(1);
expect(result).toBeDefined();
});
it('returns the same resolver on subsequent calls', () => {
const r1 = getResolver();
const r2 = getResolver();
expect(r1).toEqual(r2);
});
});
// ─── updateStream ─────────────────────────────────────────────────────────────
describe('updateStream', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('calls API.updateStream with id merged into payload', () => {
API.updateStream.mockResolvedValueOnce({ id: 1, name: 'Test Stream' });
updateStream(1, { name: 'Test Stream', url: 'http://example.com' });
expect(API.updateStream).toHaveBeenCalledWith({
id: 1,
name: 'Test Stream',
url: 'http://example.com',
});
});
it('returns the promise from API.updateStream', () => {
const resolved = Promise.resolve({ id: 1 });
API.updateStream.mockReturnValueOnce(resolved);
const result = updateStream(1, {});
expect(result).toBe(resolved);
});
it('handles an empty payload without throwing', () => {
API.updateStream.mockResolvedValueOnce({});
expect(() => updateStream(5, {})).not.toThrow();
expect(API.updateStream).toHaveBeenCalledWith({ id: 5 });
});
});
// ─── addStream ────────────────────────────────────────────────────────────────
describe('addStream', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('calls API.addStream with the provided payload', () => {
API.addStream.mockResolvedValueOnce({ id: 2 });
addStream({ name: 'New Stream', url: 'http://example.com/stream' });
expect(API.addStream).toHaveBeenCalledWith({
name: 'New Stream',
url: 'http://example.com/stream',
});
});
it('returns the promise from API.addStream', () => {
const resolved = Promise.resolve({ id: 2 });
API.addStream.mockReturnValueOnce(resolved);
const result = addStream({ name: 'test' });
expect(result).toBe(resolved);
});
it('passes through an empty object without error', () => {
API.addStream.mockResolvedValueOnce({});
expect(() => addStream({})).not.toThrow();
expect(API.addStream).toHaveBeenCalledWith({});
});
});
});

View file

@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../../api.js', () => ({
default: {
updateUserAgent: vi.fn(),
addUserAgent: vi.fn(),
},
}));
vi.mock('@hookform/resolvers/yup', () => ({
yupResolver: vi.fn((schema) => ({ __schema: schema })),
}));
import API from '../../../api.js';
import { yupResolver } from '@hookform/resolvers/yup';
import {
getResolver,
updateUserAgent,
addUserAgent,
} from '../UserAgentUtils.js';
describe('UserAgentUtils', () => {
// ─── getResolver ──────────────────────────────────────────────────────────────
describe('getResolver', () => {
it('calls yupResolver and returns its result', () => {
const result = getResolver();
expect(yupResolver).toHaveBeenCalledTimes(1);
expect(result).toBeDefined();
});
it('returns the same resolver on subsequent calls', () => {
const r1 = getResolver();
const r2 = getResolver();
expect(r1).toEqual(r2);
});
});
// ─── updateUserAgent ──────────────────────────────────────────────────────────
describe('updateUserAgent', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('calls API.updateUserAgent with id merged into values', () => {
API.updateUserAgent.mockResolvedValueOnce({ id: 1, name: 'Chrome' });
updateUserAgent(1, { name: 'Chrome', user_agent: 'Mozilla/5.0' });
expect(API.updateUserAgent).toHaveBeenCalledWith({
id: 1,
name: 'Chrome',
user_agent: 'Mozilla/5.0',
});
});
it('returns the promise from API.updateUserAgent', () => {
const resolved = Promise.resolve({ id: 1 });
API.updateUserAgent.mockReturnValueOnce(resolved);
const result = updateUserAgent(1, {});
expect(result).toBe(resolved);
});
it('handles an empty values object without throwing', () => {
API.updateUserAgent.mockResolvedValueOnce({});
expect(() => updateUserAgent(5, {})).not.toThrow();
expect(API.updateUserAgent).toHaveBeenCalledWith({ id: 5 });
});
});
// ─── addUserAgent ─────────────────────────────────────────────────────────────
describe('addUserAgent', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('calls API.addUserAgent with the provided values', () => {
API.addUserAgent.mockResolvedValueOnce({ id: 2 });
addUserAgent({ name: 'Firefox', user_agent: 'Mozilla/5.0 (Firefox)' });
expect(API.addUserAgent).toHaveBeenCalledWith({
name: 'Firefox',
user_agent: 'Mozilla/5.0 (Firefox)',
});
});
it('returns the promise from API.addUserAgent', () => {
const resolved = Promise.resolve({ id: 2 });
API.addUserAgent.mockReturnValueOnce(resolved);
const result = addUserAgent({ name: 'test' });
expect(result).toBe(resolved);
});
it('passes through an empty object without error', () => {
API.addUserAgent.mockResolvedValueOnce({});
expect(() => addUserAgent({})).not.toThrow();
expect(API.addUserAgent).toHaveBeenCalledWith({});
});
});
});

View file

@ -0,0 +1,525 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../../api.js', () => ({
default: {
createUser: vi.fn(),
updateUser: vi.fn(),
generateApiKey: vi.fn(),
revokeApiKey: vi.fn(),
},
}));
vi.mock('../../../constants.js', () => ({
USER_LEVELS: {
ADMIN: 0,
STREAMER: 2,
},
NETWORK_ACCESS_OPTIONS: {
m3u: 'M3U',
xc: 'XC',
mpegts: 'MPEG-TS',
},
}));
vi.mock('../../networkUtils.js', () => ({
IPV4_CIDR_REGEX: /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/,
IPV6_CIDR_REGEX: /^([0-9a-fA-F:]+)\/\d{1,3}$/,
}));
import API from '../../../api.js';
import {
createUser,
updateUser,
generateApiKey,
revokeApiKey,
userToFormValues,
formValuesToPayload,
getFormInitialValues,
getFormValidators,
} from '../UserUtils.js';
describe('UserUtils', () => {
// ─── createUser ───────────────────────────────────────────────────────────────
describe('createUser', () => {
beforeEach(() => vi.clearAllMocks());
it('calls API.createUser with the provided values', () => {
API.createUser.mockResolvedValueOnce({ id: 1 });
createUser({ username: 'alice', password: 'pass' });
expect(API.createUser).toHaveBeenCalledWith({
username: 'alice',
password: 'pass',
});
});
it('returns the promise from API.createUser', () => {
const resolved = Promise.resolve({ id: 1 });
API.createUser.mockReturnValueOnce(resolved);
expect(createUser({})).toBe(resolved);
});
});
// ─── updateUser ───────────────────────────────────────────────────────────────
describe('updateUser', () => {
beforeEach(() => vi.clearAllMocks());
it('calls API.updateUser with selfEdit=false when isAdmin is true', () => {
API.updateUser.mockResolvedValueOnce({});
updateUser(1, { username: 'bob' }, true, { id: 1 });
expect(API.updateUser).toHaveBeenCalledWith(
1,
{ username: 'bob' },
false
);
});
it('calls API.updateUser with selfEdit=true when not admin and userId matches authUser', () => {
API.updateUser.mockResolvedValueOnce({});
updateUser(5, { username: 'carol' }, false, { id: 5 });
expect(API.updateUser).toHaveBeenCalledWith(
5,
{ username: 'carol' },
true
);
});
it('calls API.updateUser with selfEdit=false when not admin and userId does not match authUser', () => {
API.updateUser.mockResolvedValueOnce({});
updateUser(5, { username: 'dave' }, false, { id: 99 });
expect(API.updateUser).toHaveBeenCalledWith(
5,
{ username: 'dave' },
false
);
});
it('returns the promise from API.updateUser', () => {
const resolved = Promise.resolve({});
API.updateUser.mockReturnValueOnce(resolved);
expect(updateUser(1, {}, true, { id: 1 })).toBe(resolved);
});
});
// ─── generateApiKey ───────────────────────────────────────────────────────────
describe('generateApiKey', () => {
beforeEach(() => vi.clearAllMocks());
it('calls API.generateApiKey with the payload', () => {
API.generateApiKey.mockResolvedValueOnce({ key: 'abc123' });
generateApiKey({ user_id: 1 });
expect(API.generateApiKey).toHaveBeenCalledWith({ user_id: 1 });
});
it('returns the promise from API.generateApiKey', () => {
const resolved = Promise.resolve({ key: 'abc' });
API.generateApiKey.mockReturnValueOnce(resolved);
expect(generateApiKey({})).toBe(resolved);
});
});
// ─── revokeApiKey ─────────────────────────────────────────────────────────────
describe('revokeApiKey', () => {
beforeEach(() => vi.clearAllMocks());
it('calls API.revokeApiKey with the payload', () => {
API.revokeApiKey.mockResolvedValueOnce({});
revokeApiKey({ key: 'abc123' });
expect(API.revokeApiKey).toHaveBeenCalledWith({ key: 'abc123' });
});
it('returns the promise from API.revokeApiKey', () => {
const resolved = Promise.resolve({});
API.revokeApiKey.mockReturnValueOnce(resolved);
expect(revokeApiKey({})).toBe(resolved);
});
});
// ─── userToFormValues ─────────────────────────────────────────────────────────
describe('userToFormValues', () => {
const makeUser = (overrides = {}) => ({
username: 'alice',
first_name: 'Alice',
last_name: 'Smith',
email: 'alice@example.com',
user_level: 1,
stream_limit: 2,
channel_profiles: ['1', '2'],
custom_properties: {},
...overrides,
});
it('maps basic user fields to form values', () => {
const result = userToFormValues(makeUser());
expect(result.username).toBe('alice');
expect(result.first_name).toBe('Alice');
expect(result.last_name).toBe('Smith');
expect(result.email).toBe('alice@example.com');
expect(result.user_level).toBe('1');
expect(result.stream_limit).toBe(2);
});
it('defaults first_name and last_name to empty string when absent', () => {
const result = userToFormValues(
makeUser({ first_name: null, last_name: null })
);
expect(result.first_name).toBe('');
expect(result.last_name).toBe('');
});
it('defaults stream_limit to 0 when absent', () => {
const result = userToFormValues(makeUser({ stream_limit: null }));
expect(result.stream_limit).toBe(0);
});
it('uses channel_profiles when non-empty', () => {
const result = userToFormValues(
makeUser({ channel_profiles: ['3', '4'] })
);
expect(result.channel_profiles).toEqual(['3', '4']);
});
it('maps custom_properties fields with defaults', () => {
const result = userToFormValues(makeUser({ custom_properties: {} }));
expect(result.xc_password).toBe('');
expect(result.output_format).toBe('');
expect(result.output_profile).toBe('');
expect(result.hide_adult_content).toBe(false);
expect(result.epg_days).toBe(0);
expect(result.epg_prev_days).toBe(0);
});
it('maps custom_properties when present', () => {
const user = makeUser({
custom_properties: {
xc_password: 'xpass',
output_format: 'ts',
output_profile: 5,
hide_adult_content: true,
epg_days: 7,
epg_prev_days: 2,
allowed_networks: {
m3u: '192.168.1.0/24,10.0.0.1/32',
xc: '192.168.1.0/24,10.0.0.1/32',
},
},
});
const result = userToFormValues(user);
expect(result.xc_password).toBe('xpass');
expect(result.output_format).toBe('ts');
expect(result.output_profile).toBe('5');
expect(result.hide_adult_content).toBe(true);
expect(result.epg_days).toBe(7);
expect(result.epg_prev_days).toBe(2);
});
it('deduplicates allowed_ips from allowed_networks', () => {
const user = makeUser({
custom_properties: {
allowed_networks: {
m3u: '192.168.1.0/24,10.0.0.1/32',
xc: '192.168.1.0/24,10.0.0.1/32',
},
},
});
const result = userToFormValues(user);
expect(result.allowed_ips).toEqual(['192.168.1.0/24', '10.0.0.1/32']);
});
it('returns empty allowed_ips when no allowed_networks', () => {
const result = userToFormValues(makeUser({ custom_properties: {} }));
expect(result.allowed_ips).toEqual([]);
});
});
// ─── formValuesToPayload ──────────────────────────────────────────────────────
describe('formValuesToPayload', () => {
const makeValues = (overrides = {}) => ({
username: 'alice',
email: 'alice@example.com',
xc_password: 'mypass',
output_format: 'ts',
output_profile: '3',
hide_adult_content: true,
epg_days: 7,
epg_prev_days: 2,
allowed_ips: ['192.168.1.0/24'],
channel_profiles: ['1', '2'],
...overrides,
});
it('moves xc_password into custom_properties and removes it from payload', () => {
const result = formValuesToPayload(makeValues(), null);
expect(result.xc_password).toBeUndefined();
expect(result.custom_properties.xc_password).toBe('mypass');
});
it('moves output_format into custom_properties', () => {
const result = formValuesToPayload(makeValues(), null);
expect(result.output_format).toBeUndefined();
expect(result.custom_properties.output_format).toBe('ts');
});
it('parses output_profile as int in custom_properties', () => {
const result = formValuesToPayload(makeValues(), null);
expect(result.output_profile).toBeUndefined();
expect(result.custom_properties.output_profile).toBe(3);
});
it('sets output_profile to null when empty string', () => {
const result = formValuesToPayload(
makeValues({ output_profile: '' }),
null
);
expect(result.custom_properties.output_profile).toBeNull();
});
it('moves hide_adult_content into custom_properties', () => {
const result = formValuesToPayload(makeValues(), null);
expect(result.hide_adult_content).toBeUndefined();
expect(result.custom_properties.hide_adult_content).toBe(true);
});
it('moves epg_days and epg_prev_days into custom_properties', () => {
const result = formValuesToPayload(makeValues(), null);
expect(result.epg_days).toBeUndefined();
expect(result.epg_prev_days).toBeUndefined();
expect(result.custom_properties.epg_days).toBe(7);
expect(result.custom_properties.epg_prev_days).toBe(2);
});
it('populates allowed_networks for all NETWORK_KEYS when allowed_ips present', () => {
const result = formValuesToPayload(
makeValues({ allowed_ips: ['192.168.1.0/24', '10.0.0.1/32'] }),
null
);
expect(result.allowed_ips).toBeUndefined();
expect(result.custom_properties.allowed_networks.m3u).toBe(
'192.168.1.0/24,10.0.0.1/32'
);
expect(result.custom_properties.allowed_networks.xc).toBe(
'192.168.1.0/24,10.0.0.1/32'
);
expect(result.custom_properties.allowed_networks.mpegts).toBe(
'192.168.1.0/24,10.0.0.1/32'
);
});
it('sets empty allowed_networks when allowed_ips is empty', () => {
const result = formValuesToPayload(makeValues({ allowed_ips: [] }), null);
expect(result.custom_properties.allowed_networks).toEqual({});
});
it('sets channel_profiles to empty array when it includes "0"', () => {
const result = formValuesToPayload(
makeValues({ channel_profiles: ['0'] }),
null
);
expect(result.channel_profiles).toEqual([]);
});
it('preserves channel_profiles when it does not include "0"', () => {
const result = formValuesToPayload(
makeValues({ channel_profiles: ['1', '2'] }),
null
);
expect(result.channel_profiles).toEqual(['1', '2']);
});
it('merges existing custom_properties from existingUser', () => {
const existingUser = {
custom_properties: { some_other_prop: 'keep_me' },
};
const result = formValuesToPayload(makeValues(), existingUser);
expect(result.custom_properties.some_other_prop).toBe('keep_me');
});
it('handles null existingUser gracefully', () => {
expect(() => formValuesToPayload(makeValues(), null)).not.toThrow();
});
});
// ─── getFormInitialValues ─────────────────────────────────────────────────────
describe('getFormInitialValues', () => {
it('returns the expected default structure', () => {
const result = getFormInitialValues();
expect(result).toEqual({
username: '',
first_name: '',
last_name: '',
email: '',
user_level: '0',
stream_limit: 0,
password: '',
xc_password: '',
output_format: '',
output_profile: '',
channel_profiles: [],
hide_adult_content: false,
epg_days: 0,
epg_prev_days: 0,
allowed_ips: [],
});
});
it('returns a new object on each call', () => {
const a = getFormInitialValues();
const b = getFormInitialValues();
expect(a).not.toBe(b);
});
});
// ─── getFormValidators ────────────────────────────────────────────────────────
describe('getFormValidators', () => {
const validate = (user, values) => getFormValidators(user)(values);
describe('username', () => {
it('returns error when username is empty', () => {
const result = validate(null, {
...getFormInitialValues(),
username: '',
});
expect(result.username).toBe('Username is required');
});
it('returns null for a valid non-streamer username', () => {
const result = validate(null, {
...getFormInitialValues(),
username: 'alice',
user_level: '0',
});
expect(result.username).toBeNull();
});
it('returns null for a valid alphanumeric streamer username', () => {
const result = validate(null, {
...getFormInitialValues(),
username: 'alice123',
user_level: '2',
});
expect(result.username).toBeNull();
});
it('returns error for streamer username with non-alphanumeric characters', () => {
const result = validate(null, {
...getFormInitialValues(),
username: 'alice_123',
user_level: '2',
});
expect(result.username).toBe('Streamer username must be alphanumeric');
});
});
describe('password', () => {
it('returns error when creating a non-streamer user without a password', () => {
const result = validate(null, {
...getFormInitialValues(),
user_level: '0',
password: '',
});
expect(result.password).toBe('Password is required');
});
it('returns null when creating a streamer user without a password', () => {
const result = validate(null, {
...getFormInitialValues(),
user_level: '2',
password: '',
});
expect(result.password).toBeNull();
});
it('returns null when editing an existing user without a password', () => {
const result = validate(
{ id: 1 },
{ ...getFormInitialValues(), user_level: '0', password: '' }
);
expect(result.password).toBeNull();
});
it('returns null when password is provided for new user', () => {
const result = validate(null, {
...getFormInitialValues(),
user_level: '0',
password: 'secret',
});
expect(result.password).toBeNull();
});
});
describe('xc_password', () => {
it('returns null when xc_password is empty', () => {
const result = validate(null, {
...getFormInitialValues(),
xc_password: '',
});
expect(result.xc_password).toBeNull();
});
it('returns null for a valid alphanumeric xc_password', () => {
const result = validate(null, {
...getFormInitialValues(),
xc_password: 'abc123',
});
expect(result.xc_password).toBeNull();
});
it('returns error for xc_password with non-alphanumeric characters', () => {
const result = validate(null, {
...getFormInitialValues(),
xc_password: 'abc!@#',
});
expect(result.xc_password).toBe('XC password must be alphanumeric');
});
});
describe('allowed_ips', () => {
it('returns null for an empty allowed_ips array', () => {
const result = validate(null, {
...getFormInitialValues(),
allowed_ips: [],
});
expect(result.allowed_ips).toBeNull();
});
it('returns null for a valid IPv4 CIDR', () => {
const result = validate(null, {
...getFormInitialValues(),
allowed_ips: ['192.168.1.0/24'],
});
expect(result.allowed_ips).toBeNull();
});
it('returns null for a bare IPv4 address (auto-appends /32)', () => {
const result = validate(null, {
...getFormInitialValues(),
allowed_ips: ['10.0.0.1'],
});
expect(result.allowed_ips).toBeNull();
});
it('returns error for an invalid IP entry', () => {
const result = validate(null, {
...getFormInitialValues(),
allowed_ips: ['not-an-ip'],
});
expect(result.allowed_ips).toBe('Invalid IP address or CIDR range');
});
it('returns error when any entry in the list is invalid', () => {
const result = validate(null, {
...getFormInitialValues(),
allowed_ips: ['192.168.1.0/24', 'bad-entry'],
});
expect(result.allowed_ips).toBe('Invalid IP address or CIDR range');
});
});
});
});

View file

@ -11,7 +11,7 @@ import {
getNow,
getNowMs,
roundToNearest,
} from '../utils/dateTimeUtils.js';
} from './dateTimeUtils.js';
import API from '../api.js';
export const PROGRAM_HEIGHT = 90;
@ -309,25 +309,12 @@ export const getRuleByProgram = (rules, program) => {
);
};
export const createRecording = async (channel, program) => {
await API.createRecording({
channel: `${channel.id}`,
start_time: program.start_time,
end_time: program.end_time,
custom_properties: { program },
});
export const createRecording = async (values) => {
await API.createRecording(values);
};
export const createSeriesRule = async (program, mode) => {
await API.createSeriesRule({
tvg_id: program.tvg_id,
mode,
title: program.title,
});
};
export const evaluateSeriesRule = async (program) => {
await API.evaluateSeriesRules(program.tvg_id);
export const createSeriesRule = async (values) => {
await API.createSeriesRule(values);
};
export const calculateLeftScrollPosition = (program, start) => {