Merge pull request #1219 from nick4810/tests/frontend-unit-tests

Tests/frontend unit tests
This commit is contained in:
SergeantPanda 2026-05-12 10:33:50 -05:00 committed by GitHub
commit 21b32f302c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 13262 additions and 2288 deletions

View file

@ -0,0 +1,756 @@
// Shared preview box for include and exclude filters. The marker and
// color reflect whether matched names are kept (teal check) or dropped
// (red x); empty/loading/error states mirror the find preview.
import {
Box,
Button,
Center,
Checkbox,
Divider,
Flex,
Group,
MultiSelect,
Popover,
PopoverDropdown,
PopoverTarget,
ScrollArea,
Select,
Stack,
Switch,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import { getRegexOptions } from '../../utils/forms/LiveGroupFilterUtils.js';
import React from 'react';
import useChannelsStore from '../../store/channels.jsx';
import { FixedSizeList as List } from 'react-window';
import logo from '../../images/logo.png';
import LazyLogo from '../LazyLogo.jsx';
import { RefreshCw } from 'lucide-react';
import { showNotification } from '../../utils/notificationUtils.js';
import {
formatPreviewSummary,
getEpgSourceData,
getEpgSourceValue,
repackGroupChannels,
} from '../../utils/forms/AutoSyncAdvancedUtils.js';
const RegexPreviewBox = ({ group, kind, regexPreviewState }) => {
const pattern =
kind === 'exclude'
? group.custom_properties?.name_match_exclude_regex || ''
: kind === 'include'
? group.custom_properties?.name_match_regex || ''
: group.custom_properties?.name_regex_pattern || '';
if (!pattern) return null;
const state = regexPreviewState[group.channel_group] || {};
const result =
kind === 'exclude'
? state.excludeResult
: kind === 'include'
? state.filterResult
: state.findResult;
const loading = state.loading;
const summaryLabel =
kind === 'exclude' ? 'exclude' : kind === 'include' ? 'filter' : 'rename';
const placeholderLabel =
kind === 'exclude'
? 'Exclude preview'
: kind === 'include'
? 'Filter preview'
: 'Preview';
const markerChar = kind === 'exclude' ? '✗' : '✓';
const markerColor = kind === 'exclude' ? 'red.4' : 'teal.4';
const emptyText =
kind === 'exclude'
? 'No streams matched this pattern (nothing would be excluded).'
: 'No streams matched this pattern.';
return (
<Box
style={{
border: '1px solid #3F3F46',
borderRadius: 6,
padding: 8,
backgroundColor: '#1E1E22',
}}
>
<Text size="xs" fw={600} mb={4}>
{result ? formatPreviewSummary(summaryLabel, result) : placeholderLabel}
</Text>
{result?.error && (
<Text size="xs" c="red.5">
Invalid regex: {result.error}
</Text>
)}
{loading && !result && (
<Text size="xs" c="dimmed">
Scanning streams...
</Text>
)}
{result && !result.error && result.matches?.length === 0 && (
<Text size="xs" c="dimmed">
{result.total_in_group === 0
? 'No streams in this group yet. Run an M3U refresh first to populate streams.'
: emptyText}
</Text>
)}
{result?.matches?.map((row, idx) =>
kind === 'find' ? (
<Flex
key={`${row.before}-${idx}`}
gap="xs"
align="center"
style={{ fontFamily: 'monospace' }}
>
<Text size="xs" c="dimmed" style={{ flex: 1 }} truncate>
{row.before}
</Text>
<Text size="xs" c="gray.5">
{' -> '}
</Text>
<Text size="xs" c="teal.4" style={{ flex: 1 }} truncate>
{row.after}
</Text>
</Flex>
) : (
<Flex
key={`${row.name}-${idx}`}
gap="xs"
align="center"
style={{ fontFamily: 'monospace' }}
>
<Text size="xs" c={markerColor} style={{ width: 18 }}>
{markerChar}
</Text>
<Text size="xs" c="gray.2" style={{ flex: 1 }} truncate>
{row.name}
</Text>
</Flex>
)
)}
</Box>
);
};
// Advanced Options form rendered inside the gear modal. A field's
// presence in custom_properties activates it; blanking returns the
// group to default behavior.
const AutoSyncAdvanced = ({
group,
epgSources,
channelGroups,
streamProfiles,
regexPreviewState,
onApplyGroupChange,
onScheduleRegexPreview,
onOpenLogoUpload,
channelLogos,
playlist,
logosLoading,
ensureLogosLoaded,
}) => {
const cp = group.custom_properties || {};
const profiles = useChannelsStore((s) => s.profiles);
const setCp = (patch, clears = []) => {
const next = { ...cp, ...patch };
clears.forEach((k) => delete next[k]);
onApplyGroupChange({ ...group, custom_properties: next });
};
// --- Name Transforms ---
const findValue = cp.name_regex_pattern ?? '';
const replaceValue = cp.name_replace_pattern ?? '';
const filterValue = cp.name_match_regex ?? '';
const excludeValue = cp.name_match_exclude_regex ?? '';
const updateFind = (val) => {
if (!val && !replaceValue) {
setCp({}, ['name_regex_pattern', 'name_replace_pattern']);
} else {
setCp({
name_regex_pattern: val,
name_replace_pattern: replaceValue,
});
}
onScheduleRegexPreview(
group,
getRegexOptions(val, replaceValue, filterValue, excludeValue)
);
};
const updateReplace = (val) => {
if (!val && !findValue) {
setCp({}, ['name_regex_pattern', 'name_replace_pattern']);
} else {
setCp({
name_regex_pattern: findValue,
name_replace_pattern: val,
});
}
onScheduleRegexPreview(
group,
getRegexOptions(findValue, val, filterValue, excludeValue)
);
};
const updateFilter = (val) => {
if (!val) setCp({}, ['name_match_regex']);
else setCp({ name_match_regex: val });
onScheduleRegexPreview(
group,
getRegexOptions(findValue, replaceValue, val, excludeValue)
);
};
const updateExclude = (val) => {
if (!val) setCp({}, ['name_match_exclude_regex']);
else setCp({ name_match_exclude_regex: val });
onScheduleRegexPreview(
group,
getRegexOptions(findValue, replaceValue, filterValue, val)
);
};
// --- EPG ---
const epgValue = getEpgSourceValue(cp);
const updateEpg = (value) => {
const next = { ...cp };
delete next.custom_epg_id;
delete next.force_dummy_epg;
delete next.force_epg_selected;
if (value === '0') {
next.force_dummy_epg = true;
} else if (value) {
next.custom_epg_id = parseInt(value);
}
onApplyGroupChange({ ...group, custom_properties: next });
};
// --- Channel Assignment ---
const groupOverrideValue = cp.group_override
? cp.group_override.toString()
: '';
const updateGroupOverride = (value) => {
if (!value) setCp({}, ['group_override']);
else setCp({ group_override: parseInt(value) });
};
const profileValue = cp.channel_profile_ids ?? [];
const updateProfiles = (value) => {
if (!value || value.length === 0) {
setCp({}, ['channel_profile_ids']);
} else {
setCp({ channel_profile_ids: value });
}
};
const streamProfileValue = cp.stream_profile_id
? cp.stream_profile_id.toString()
: '';
const updateStreamProfile = (value) => {
if (!value) setCp({}, ['stream_profile_id']);
else setCp({ stream_profile_id: parseInt(value) });
};
const sortOrderValue = cp.channel_sort_order ?? '__default__';
const sortReverseEnabled = cp.channel_sort_order !== undefined;
const updateSortOrder = (value) => {
if (!value || value === '__default__') {
setCp({}, ['channel_sort_order', 'channel_sort_reverse']);
} else {
setCp({
channel_sort_order: value,
channel_sort_reverse: cp.channel_sort_reverse ?? false,
});
}
};
const updateSortReverse = (checked) => {
setCp({ channel_sort_reverse: checked });
};
// --- Custom Logo ---
const logoValue = cp.custom_logo_id;
return (
<Stack gap="lg">
<Stack gap="sm">
<Divider
label={
<Text size="sm" fw={600} c="gray.3">
Name Transforms
</Text>
}
labelPosition="left"
size="sm"
color="gray.6"
/>
<Tooltip
label="Apply a regex find/replace to channel names during sync. Leave both empty to skip."
withArrow
multiline
w={280}
openDelay={500}
>
<Box>
<Flex gap="xs">
<TextInput
label="Find (Regex)"
placeholder="e.g. ^.*? - PPV\\d+ - (.+)$"
value={findValue}
onChange={(e) => updateFind(e.currentTarget.value)}
size="xs"
style={{ flex: 1 }}
/>
<TextInput
label="Replace"
placeholder="e.g. $1"
value={replaceValue}
onChange={(e) => updateReplace(e.currentTarget.value)}
size="xs"
style={{ flex: 1 }}
/>
</Flex>
{findValue && (
<RegexPreviewBox
group={group}
kind="find"
regexPreviewState={regexPreviewState}
/>
)}
</Box>
</Tooltip>
<Flex gap="xs" align="flex-start">
<Tooltip
label="Only include channels whose names match the pattern. Leave empty to include all."
withArrow
multiline
w={280}
openDelay={500}
>
<Box style={{ flex: 1 }}>
<TextInput
label="Include if name matches (Regex)"
placeholder="e.g. ^Sports.*"
value={filterValue}
onChange={(e) => updateFilter(e.currentTarget.value)}
size="xs"
/>
{filterValue && (
<RegexPreviewBox
group={group}
kind="include"
regexPreviewState={regexPreviewState}
/>
)}
</Box>
</Tooltip>
<Tooltip
label="Drop channels whose names match the pattern. Applied after the include filter; useful for removing specific bad streams without rewriting the include pattern."
withArrow
multiline
w={280}
openDelay={500}
>
<Box style={{ flex: 1 }}>
<TextInput
label="Exclude if name matches (Regex)"
placeholder="e.g. TEST|BACKUP"
value={excludeValue}
onChange={(e) => updateExclude(e.currentTarget.value)}
size="xs"
/>
{excludeValue && (
<RegexPreviewBox
group={group}
kind="exclude"
regexPreviewState={regexPreviewState}
/>
)}
</Box>
</Tooltip>
</Flex>
</Stack>
<Stack gap="sm">
<Divider
label={
<Text size="sm" fw={600} c="gray.3">
EPG & Logo
</Text>
}
labelPosition="left"
size="sm"
color="gray.6"
/>
<Flex gap="xs" align="flex-start">
<Tooltip
label="Force a specific EPG source. Defaults to auto-matching by tvg_id when blank."
withArrow
multiline
w={280}
openDelay={500}
>
<Select
label="EPG Source"
placeholder="Auto-match (default)"
value={epgValue || null}
onChange={updateEpg}
data={getEpgSourceData(epgSources)}
clearable
searchable
size="xs"
style={{ flex: 1 }}
/>
</Tooltip>
<Box style={{ flex: 1 }}>
<Group justify="space-between">
<Popover
opened={group.logoPopoverOpened || false}
onChange={(opened) => {
onApplyGroupChange({ ...group, logoPopoverOpened: opened });
if (opened) ensureLogosLoaded();
}}
withArrow
>
<PopoverTarget>
<TextInput
label="Custom Logo"
placeholder="Stream logo (default)"
readOnly
value={logoValue ? channelLogos[logoValue]?.name || '' : ''}
onClick={() =>
onApplyGroupChange({
...group,
logoPopoverOpened: true,
})
}
size="xs"
style={{ flex: 1 }}
/>
</PopoverTarget>
<PopoverDropdown onMouseDown={(e) => e.stopPropagation()}>
<Group>
<TextInput
placeholder="Filter logos..."
size="xs"
value={group.logoFilter || ''}
onChange={(e) =>
onApplyGroupChange({
...group,
logoFilter: e.currentTarget.value,
})
}
/>
{logosLoading && (
<Text size="xs" c="dimmed">
Loading...
</Text>
)}
</Group>
<ScrollArea style={{ height: 200 }}>
{(() => {
const logoOptions = [
{ id: '0', name: 'Default' },
...Object.values(channelLogos),
];
const filteredLogos = logoOptions.filter((logoItem) =>
logoItem.name
.toLowerCase()
.includes((group.logoFilter || '').toLowerCase())
);
if (filteredLogos.length === 0) {
return (
<Center style={{ height: 200 }}>
<Text size="sm" c="dimmed">
{group.logoFilter
? 'No logos match your filter'
: 'No logos available'}
</Text>
</Center>
);
}
return (
<List
height={200}
itemCount={filteredLogos.length}
itemSize={55}
style={{ width: '100%' }}
>
{({ index, style }) => {
const logoItem = filteredLogos[index];
return (
<div
style={{
...style,
cursor: 'pointer',
padding: '5px',
borderRadius: '4px',
}}
onClick={() => {
const next = { ...cp };
if (logoItem.id === '0' || !logoItem.id) {
delete next.custom_logo_id;
} else {
next.custom_logo_id = logoItem.id;
}
onApplyGroupChange({
...group,
custom_properties: next,
logoPopoverOpened: false,
});
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
'rgb(68, 68, 68)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor =
'transparent';
}}
>
<Center
style={{
flexDirection: 'column',
gap: '2px',
}}
>
<img
src={logoItem.cache_url || logo}
height="30"
style={{
maxWidth: 80,
objectFit: 'contain',
}}
alt={logoItem.name || 'Logo'}
onError={(e) => {
if (e.target.src !== logo) {
e.target.src = logo;
}
}}
/>
<Text
size="xs"
c="dimmed"
ta="center"
style={{
maxWidth: 80,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{logoItem.name || 'Default'}
</Text>
</Center>
</div>
);
}}
</List>
);
})()}
</ScrollArea>
</PopoverDropdown>
</Popover>
{logoValue && (
<Stack gap="xs" align="center">
<LazyLogo
logoId={logoValue}
alt="custom logo"
style={{ height: 40 }}
/>
</Stack>
)}
</Group>
<Button
onClick={() => onOpenLogoUpload(group.channel_group)}
variant="subtle"
size="compact-xs"
mt={4}
>
+ Upload new logo
</Button>
</Box>
</Flex>
</Stack>
<Stack gap="sm">
<Divider
label={
<Text size="sm" fw={600} c="gray.3">
Channel Assignment
</Text>
}
labelPosition="left"
size="sm"
color="gray.6"
/>
<Flex gap="xs">
<Tooltip
label="Send auto-created channels into a different group than their source."
withArrow
multiline
w={280}
openDelay={500}
>
<Select
label="Override Channel Group"
placeholder="Source group (default)"
value={groupOverrideValue || null}
onChange={updateGroupOverride}
data={Object.values(channelGroups).map((g) => ({
value: g.id.toString(),
label: g.name,
}))}
clearable
searchable
size="xs"
style={{ flex: 1 }}
/>
</Tooltip>
<Tooltip
label="Limit auto-created channels to specific channel profiles. Defaults to all profiles when blank."
withArrow
multiline
w={280}
openDelay={500}
>
<MultiSelect
label="Channel Profiles"
placeholder="All profiles (default)"
value={profileValue}
onChange={updateProfiles}
data={Object.values(profiles).map((profile) => ({
value: profile.id.toString(),
label: profile.name,
}))}
clearable
searchable
size="xs"
style={{ flex: 1 }}
/>
</Tooltip>
</Flex>
<Flex gap="xs" align="flex-start">
<Tooltip
label="Apply a specific stream profile to channels created by this group."
withArrow
multiline
w={280}
openDelay={500}
>
<Select
label="Stream Profile"
placeholder="Account default"
value={streamProfileValue || null}
onChange={updateStreamProfile}
data={streamProfiles.map((profile) => ({
value: profile.id.toString(),
label: profile.name,
}))}
clearable
searchable
size="xs"
style={{ flex: 1 }}
/>
</Tooltip>
<Tooltip
label="Order channels within the group before assigning numbers."
withArrow
multiline
w={280}
openDelay={500}
>
<Box style={{ flex: 1 }}>
<Select
label="Channel Sort Order"
value={sortOrderValue}
onChange={updateSortOrder}
data={[
{ value: '__default__', label: 'Provider Order (Default)' },
{ value: 'name', label: 'Name' },
{ value: 'tvg_id', label: 'TVG ID' },
{ value: 'updated_at', label: 'Updated At' },
]}
searchable
size="xs"
/>
{sortReverseEnabled && (
<Checkbox
label="Reverse sort order"
checked={cp.channel_sort_reverse || false}
onChange={(event) =>
updateSortReverse(event.currentTarget.checked)
}
size="xs"
mt="xs"
/>
)}
</Box>
</Tooltip>
</Flex>
<Flex align="center" justify="space-between" gap="md" mt="xs">
<Tooltip
label="Visible channels get sequential numbers; hidden channels release theirs. Set a channel number override to preserve channel numbers over time."
withArrow
multiline
w={320}
openDelay={500}
>
<Box style={{ flex: 1, minWidth: 0 }}>
<Switch
label="Compact numbering"
description="Numbers shift on hide/unhide. Pin a number with a channel number override."
checked={!!cp.compact_numbering}
onChange={(event) => {
if (event.currentTarget.checked) {
setCp({ compact_numbering: true });
} else {
setCp({}, ['compact_numbering']);
}
}}
size="xs"
/>
</Box>
</Tooltip>
<Tooltip
label="Re-assign visible channels into the group's current range. Overrides are kept as reservations and not modified."
withArrow
multiline
w={280}
openDelay={500}
>
<Button
variant="subtle"
size="compact-xs"
color="gray"
leftSection={<RefreshCw size={12} />}
onClick={async () => {
const result = await repackGroupChannels(playlist, group);
if (result) {
showNotification({
title: 'Channels renumbered',
message: `Assigned ${result.assigned}, released ${result.released}${
result.failed
? `, ${result.failed} could not fit in the configured range`
: ''
}.`,
color: result.failed ? 'yellow' : 'green',
autoClose: 5000,
});
}
}}
style={{ flexShrink: 0 }}
>
Renumber now
</Button>
</Tooltip>
</Flex>
</Stack>
</Stack>
);
};
export default AutoSyncAdvanced;

View file

@ -0,0 +1,181 @@
// Inline Start/End range inputs for Fixed and Provider modes. Start
// writes to auto_sync_channel_start (Fixed) or
// custom_properties.channel_numbering_fallback (Provider); End always
// writes to auto_sync_channel_end. Next Available shows a one-line
// explanation because a range contradicts pack-anywhere semantics.
import { Flex, NumberInput, Stack, Text, Tooltip } from '@mantine/core';
import { AlertTriangle } from 'lucide-react';
import React from 'react';
import {
clampChannelNumber,
computeRangeOverlapsFor,
} from '../../utils/forms/AutoSyncBasicUtils.js';
const AutoSyncBasic = ({
group,
groupStates,
groupConflicts,
onApplyGroupChange,
}) => {
const mode = group.custom_properties?.channel_numbering_mode || 'fixed';
if (mode === 'next_available') {
return (
<Text size="xs" c="dimmed">
Channels receive the lowest available numbers starting at 1.
</Text>
);
}
const startValue =
mode === 'provider'
? (group.custom_properties?.channel_numbering_fallback ?? 1)
: (group.auto_sync_channel_start ?? 1);
const endValue = group.auto_sync_channel_end;
// Caps pathological pasted values like "1e308" at the input layer.
const updateStart = (value) => {
const normalized =
value === '' || value === null || value === undefined
? 1
: clampChannelNumber(value);
if (mode === 'provider') {
onApplyGroupChange({
...group,
custom_properties: {
...(group.custom_properties || {}),
channel_numbering_fallback: normalized,
},
});
} else {
// If End is set and the new Start exceeds it, drop End so the user
// is not left holding an invalid range silently.
const next = { ...group, auto_sync_channel_start: normalized };
if (
endValue !== null &&
endValue !== undefined &&
normalized > endValue
) {
next.auto_sync_channel_end = null;
}
onApplyGroupChange(next);
}
// Sweep effect picks up the state change and dispatches the scan.
};
const updateEnd = (value) => {
const normalized =
value === '' || value === null || value === undefined
? null
: clampChannelNumber(value);
onApplyGroupChange({
...group,
auto_sync_channel_end: normalized,
});
};
const streamCount =
typeof group.stream_count === 'number' ? group.stream_count : null;
const metaParts = [];
if (endValue) {
metaParts.push(`Range: ${Math.max(endValue - startValue + 1, 0)}`);
}
if (streamCount !== null) {
metaParts.push(`Streams: ${streamCount}`);
}
const metaText = metaParts.join(' · ');
const conflict = groupConflicts[group.channel_group];
const hasChannelConflict = !!conflict?.hasChannelConflict;
const overlaps = computeRangeOverlapsFor(group, groupStates);
// Channel-level conflicts get a generic Channels-page pointer (count
// can be large); range-level overlaps stay specific to the modal.
const tooltipSections = [];
if (hasChannelConflict) {
tooltipSections.push(
'Range conflicts with configured channels.\nView the Channels page to inspect.'
);
}
if (overlaps.length > 0) {
const overlapLines = overlaps
.map((o) => `${o.name} (${o.start}-${o.end})`)
.join('\n ');
tooltipSections.push(
overlaps.length === 1
? `Range overlaps with: ${overlaps[0].name} (${overlaps[0].start}-${overlaps[0].end})`
: `Range overlaps with:\n ${overlapLines}`
);
}
const showWarning = tooltipSections.length > 0;
const tooltipBody = tooltipSections.join('\n\n');
return (
<Stack gap={4}>
<Flex gap="xs" align="flex-start">
<Tooltip
label={
mode === 'provider'
? 'Fallback channel number used when a stream has no provider-supplied number.'
: 'First channel number assigned to this group.'
}
withArrow
multiline
w={260}
>
<NumberInput
label="Start #"
value={startValue}
onChange={updateStart}
min={1}
step={1}
size="xs"
precision={0}
style={{ flex: 1 }}
/>
</Tooltip>
<Tooltip
label="Optional upper bound. Streams exceeding the range are skipped and reported after sync."
withArrow
multiline
w={260}
>
<NumberInput
label="End # (optional)"
placeholder="Unlimited"
value={endValue ?? ''}
onChange={updateEnd}
min={startValue || 1}
step={1}
size="xs"
precision={0}
style={{ flex: 1 }}
/>
</Tooltip>
</Flex>
<Flex
gap="xs"
align="center"
justify="space-between"
style={{ minHeight: 18 }}
>
<Text size="xs" c="dimmed">
{metaText}
</Text>
{showWarning && (
<Tooltip
label={tooltipBody}
withArrow
multiline
w={280}
styles={{ tooltip: { whiteSpace: 'pre-line' } }}
>
<AlertTriangle
size={14}
color="var(--mantine-color-yellow-6)"
aria-label="Range conflict warning"
/>
</Tooltip>
)}
</Flex>
</Stack>
);
};
export default AutoSyncBasic;

View file

@ -0,0 +1,77 @@
import React, { useEffect, useState } from 'react';
import { updatePlaylist } from '../../utils/forms/M3uUtils.js';
import { showNotification } from '../../utils/notificationUtils.js';
import { Box, Group, SegmentedControl, Text, Tooltip } from '@mantine/core';
const OrphanCleanupControl = ({ playlist }) => {
// Local state mirrors the persisted mode so the SegmentedControl
// reflects clicks immediately even when the parent's playlist prop is
// a stale snapshot from before the PATCH lands.
const [orphanCleanupMode, setOrphanCleanupMode] = useState(
(playlist?.custom_properties || {}).orphan_channel_cleanup || 'always'
);
useEffect(() => {
setOrphanCleanupMode(
(playlist?.custom_properties || {}).orphan_channel_cleanup || 'always'
);
}, [playlist?.id, playlist?.custom_properties?.orphan_channel_cleanup]);
const handleOrphanCleanupChange = async (mode) => {
if (!playlist?.id) return;
const previousMode = orphanCleanupMode;
setOrphanCleanupMode(mode);
const nextProps = {
...(playlist.custom_properties || {}),
orphan_channel_cleanup: mode,
};
try {
await updatePlaylist(playlist, { custom_properties: nextProps });
} catch (err) {
setOrphanCleanupMode(previousMode);
showNotification({
title: 'Failed to update cleanup mode',
message: err?.body?.detail || err?.message || 'Please try again.',
color: 'red',
});
}
};
return (
<Box>
<Group gap="sm" align="center" wrap="nowrap">
<Tooltip
label="Controls what sync does with auto-synced channels whose source streams have been removed from this provider. Manual channels and hidden channels are never affected by this setting."
withArrow
multiline
w={320}
openDelay={400}
>
<Text size="sm" fw={500}>
Auto-sync orphan cleanup
</Text>
</Tooltip>
<SegmentedControl
size="xs"
value={orphanCleanupMode}
onChange={handleOrphanCleanupChange}
data={[
{ label: 'Always remove', value: 'always' },
{ label: 'Preserve customized', value: 'preserve_customized' },
{ label: 'Never remove', value: 'never' },
]}
/>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{orphanCleanupMode === 'always' &&
'Removes any auto-synced channel whose source stream is gone from this provider.'}
{orphanCleanupMode === 'preserve_customized' &&
'Removes orphaned auto-synced channels except those with active overrides.'}
{orphanCleanupMode === 'never' &&
'Keeps all orphaned auto-synced channels. You can clean up manually from the channels page.'}
</Text>
</Box>
);
};
export default OrphanCleanupControl;

View file

@ -931,7 +931,10 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
label="Hidden"
checked={watch('hidden_from_output')}
onChange={(event) =>
setValue('hidden_from_output', event.currentTarget.checked)
setValue(
'hidden_from_output',
event.currentTarget.checked
)
}
size="md"
/>

View file

@ -1,20 +1,13 @@
// Modal.js
import React from 'react';
import API from '../../api';
import { Flex, TextInput, Button, Modal, Alert } from '@mantine/core';
import { Alert, Button, Flex, Modal, TextInput } from '@mantine/core';
import { isNotEmpty, useForm } from '@mantine/form';
import useChannelsStore from '../../store/channels';
import { showNotification } from '../../utils/notificationUtils.js';
const updateChannelGroup = (channelGroup, values) => {
return API.updateChannelGroup({
id: channelGroup.id,
...values,
});
};
const addChannelGroup = (values) => {
return API.addChannelGroup(values);
};
import {
addChannelGroup,
updateChannelGroup,
} from '../../utils/forms/ChannelGroupUtils.js';
const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);

View file

@ -1,44 +1,49 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Modal,
Stack,
ActionIcon,
Alert,
Badge,
Box,
Button,
Chip,
Divider,
Flex,
Group,
Modal,
ScrollArea,
Stack,
Text,
TextInput,
Button,
ActionIcon,
Flex,
Badge,
Alert,
Divider,
ScrollArea,
useMantineTheme,
Chip,
Box,
} from '@mantine/core';
import {
SquarePlus,
SquarePen,
SquareMinus,
Check,
X,
AlertCircle,
Check,
Database,
Tv,
Trash,
Filter,
SquareMinus,
SquarePen,
SquarePlus,
Trash,
Tv,
X,
} from 'lucide-react';
import { notifications } from '@mantine/notifications';
import useChannelsStore from '../../store/channels';
import useWarningsStore from '../../store/warnings';
import ConfirmationDialog from '../ConfirmationDialog';
import API from '../../api';
import { showNotification } from '../../utils/notificationUtils.js';
import {
addChannelGroup,
cleanupUnusedChannelGroups,
deleteChannelGroup,
updateChannelGroup,
} from '../../utils/forms/ChannelGroupUtils.js';
// Move GroupItem outside to prevent recreation on every render
const GroupItem = React.memo(
({
group,
editingGroup,
editingGroupId,
editName,
onEditNameChange,
onSaveEdit,
@ -90,7 +95,7 @@ const GroupItem = React.memo(
border: '1px solid #444',
borderRadius: '8px',
backgroundColor:
editingGroup === group.id
editingGroupId === group.id
? '#3f3f46'
: group.enabled
? '#2A2A2E'
@ -101,7 +106,7 @@ const GroupItem = React.memo(
>
<Group justify="space-between" gap="xs" align="flex-start">
<Stack gap={4} style={{ flex: 1 }}>
{editingGroup === group.id ? (
{editingGroupId === group.id ? (
<TextInput
value={editName}
onChange={onEditNameChange}
@ -120,7 +125,7 @@ const GroupItem = React.memo(
</Stack>
<Group gap="xs">
{editingGroup === group.id ? (
{editingGroupId === group.id ? (
<>
<ActionIcon color="green" size="sm" onClick={onSaveEdit}>
<Check size={14} />
@ -167,7 +172,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const [editingGroup, setEditingGroup] = useState(null);
const [editingGroupId, setEditingGroupId] = useState(null);
const [editName, setEditName] = useState('');
const [newGroupName, setNewGroupName] = useState('');
const [isCreating, setIsCreating] = useState(false);
@ -197,83 +202,44 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
[channelGroupsArray]
);
// Filter groups based on search term and chip filters
const filteredGroups = useMemo(() => {
let filtered = sortedGroups;
const { filteredGroups, filterCounts } = useMemo(() => {
const counts = { channels: 0, m3u: 0, unused: 0 };
const filtered = [];
// Apply search filter
if (searchTerm.trim()) {
filtered = filtered.filter((group) =>
group.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Apply chip filters
filtered = filtered.filter((group) => {
for (const group of sortedGroups) {
const usage = groupUsage[group.id];
if (!usage) return false;
if (!usage) continue;
const hasChannels = usage.hasChannels;
const hasM3U = usage.hasM3UAccounts;
const { hasChannels, hasM3UAccounts: hasM3U } = usage;
const isUnused = !hasChannels && !hasM3U;
// If group is unused, only show if unused groups are enabled
if (isUnused) {
return showUnusedGroups;
if (hasChannels) counts.channels++;
if (hasM3U) counts.m3u++;
if (isUnused) counts.unused++;
const matchesSearch =
!searchTerm.trim() ||
group.name.toLowerCase().includes(searchTerm.toLowerCase());
const matchesFilter = isUnused
? showUnusedGroups
: (hasChannels && showChannelGroups) || (hasM3U && showM3UGroups);
if (matchesSearch && matchesFilter) {
filtered.push(group);
}
}
// For groups with channels and/or M3U, show if either filter is enabled
let shouldShow = false;
if (hasChannels && showChannelGroups) shouldShow = true;
if (hasM3U && showM3UGroups) shouldShow = true;
return shouldShow;
});
return filtered;
return { filteredGroups: filtered, filterCounts: counts };
}, [
sortedGroups,
groupUsage,
searchTerm,
showChannelGroups,
showM3UGroups,
showUnusedGroups,
groupUsage,
]);
// Calculate filter counts
const filterCounts = useMemo(() => {
const counts = {
channels: 0,
m3u: 0,
unused: 0,
};
sortedGroups.forEach((group) => {
const usage = groupUsage[group.id];
if (usage) {
const hasChannels = usage.hasChannels;
const hasM3U = usage.hasM3UAccounts;
// Count groups with channels (including those with both)
if (hasChannels) {
counts.channels++;
}
// Count groups with M3U (including those with both)
if (hasM3U) {
counts.m3u++;
}
// Count truly unused groups
if (!hasChannels && !hasM3U) {
counts.unused++;
}
}
});
return counts;
}, [sortedGroups, groupUsage]);
const fetchGroupUsage = useCallback(async () => {
setLoading(true);
try {
@ -305,13 +271,13 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
}, [isOpen, fetchGroupUsage]);
const handleEdit = useCallback((group) => {
setEditingGroup(group.id);
setEditingGroupId(group.id);
setEditName(group.name);
}, []);
const handleSaveEdit = useCallback(async () => {
if (!editName.trim()) {
notifications.show({
showNotification({
title: 'Error',
message: 'Group name cannot be empty',
color: 'red',
@ -320,37 +286,36 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
}
try {
await API.updateChannelGroup({
id: editingGroup,
await updateChannelGroup(channelGroups[editingGroupId], {
name: editName.trim(),
});
notifications.show({
showNotification({
title: 'Success',
message: 'Group updated successfully',
color: 'green',
});
setEditingGroup(null);
setEditingGroupId(null);
setEditName('');
await fetchGroupUsage(); // Refresh usage data
} catch (error) {
notifications.show({
showNotification({
title: 'Error',
message: 'Failed to update group',
color: 'red',
});
}
}, [editName, editingGroup, fetchGroupUsage]);
}, [editName, editingGroupId, fetchGroupUsage]);
const handleCancelEdit = useCallback(() => {
setEditingGroup(null);
setEditingGroupId(null);
setEditName('');
}, []);
const handleCreate = useCallback(async () => {
if (!newGroupName.trim()) {
notifications.show({
showNotification({
title: 'Error',
message: 'Group name cannot be empty',
color: 'red',
@ -359,11 +324,11 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
}
try {
await API.addChannelGroup({
await addChannelGroup({
name: newGroupName.trim(),
});
notifications.show({
showNotification({
title: 'Success',
message: 'Group created successfully',
color: 'green',
@ -373,7 +338,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
setIsCreating(false);
await fetchGroupUsage(); // Refresh usage data
} catch (error) {
notifications.show({
showNotification({
title: 'Error',
message: 'Failed to create group',
color: 'red',
@ -385,9 +350,9 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
async (group) => {
setDeletingGroup(true);
try {
await API.deleteChannelGroup(group.id);
await deleteChannelGroup(group);
notifications.show({
showNotification({
title: 'Success',
message: 'Group deleted successfully',
color: 'green',
@ -395,7 +360,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
await fetchGroupUsage(); // Refresh usage data
} catch (error) {
notifications.show({
showNotification({
title: 'Error',
message: 'Failed to delete group',
color: 'red',
@ -416,7 +381,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
usage &&
(!usage.canDelete || usage.hasChannels || usage.hasM3UAccounts)
) {
notifications.show({
showNotification({
title: 'Cannot Delete',
message:
'This group is associated with channels or M3U accounts and cannot be deleted',
@ -441,9 +406,9 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
const executeCleanup = useCallback(async () => {
setIsCleaningUp(true);
try {
const result = await API.cleanupUnusedChannelGroups();
const result = await cleanupUnusedChannelGroups();
notifications.show({
showNotification({
title: 'Cleanup Complete',
message: `Successfully deleted ${result.deleted_count} unused groups`,
color: 'green',
@ -452,7 +417,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
await fetchGroupUsage(); // Refresh usage data
setConfirmCleanupOpen(false);
} catch (error) {
notifications.show({
showNotification({
title: 'Cleanup Failed',
message: 'Failed to cleanup unused groups',
color: 'red',
@ -653,7 +618,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => {
<GroupItem
key={group.id}
group={group}
editingGroup={editingGroup}
editingGroupId={editingGroupId}
editName={editName}
onEditNameChange={handleEditNameChange}
onSaveEdit={handleSaveEdit}

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../../store/auth';
import useSettingsStore from '../../store/settings';
import { notifications } from '@mantine/notifications';
import {
Paper,
Title,
@ -20,6 +19,7 @@ import {
Checkbox,
} from '@mantine/core';
import logo from '../../assets/logo.png';
import { showNotification } from '../../utils/notificationUtils.js';
const LoginForm = () => {
const login = useAuthStore((s) => s.login);
@ -132,7 +132,7 @@ const LoginForm = () => {
} catch (e) {
console.log(`Failed to login: ${e}`);
if (e?.message === 'Unauthorized') {
notifications.show({
showNotification({
title: 'Web UI Access Denied',
message:
'This account is a Streamer account and cannot log into the web UI. ' +
@ -142,6 +142,7 @@ const LoginForm = () => {
});
}
await logout();
} finally {
setIsLoading(false);
}
};

View file

@ -1,45 +1,36 @@
import React, { useState, useEffect, useMemo } from 'react';
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 {
Modal,
TextInput,
Button,
Group,
Stack,
Image,
Text,
Center,
Box,
Button,
Center,
Divider,
Group,
Image,
Modal,
Stack,
Text,
TextInput,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { Upload, FileImage, X } from 'lucide-react';
import { notifications } from '@mantine/notifications';
import API from '../../api';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
url: Yup.string()
.required('URL is required')
.test(
'valid-url-or-path',
'Must be a valid URL or local file path',
(value) => {
if (!value) return false;
// Allow local file paths starting with /data/logos/
if (value.startsWith('/data/logos/')) return true;
// Allow valid URLs
try {
new URL(value);
return true;
} catch {
return false;
}
}
),
});
import {
Dropzone,
DropzoneAccept,
DropzoneIdle,
DropzoneReject,
} from '@mantine/dropzone';
import { FileImage, Upload, X } from 'lucide-react';
import { showNotification } from '../../utils/notificationUtils.js';
import {
createLogo,
getFilenameWithoutExtension,
getResolver,
getUpdateLogoErrorMessage,
getUploadErrorMessage,
releaseUrl,
updateLogo,
uploadLogo,
validateFileSize,
} from '../../utils/forms/LogoUtils.js';
const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
const [logoPreview, setLogoPreview] = useState(null);
@ -63,7 +54,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
watch,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
resolver: getResolver(),
});
const onSubmit = async (values) => {
@ -74,27 +65,14 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
// If we have a selected file, upload it first
if (selectedFile) {
try {
uploadResponse = await API.uploadLogo(selectedFile, values.name);
uploadResponse = await uploadLogo(selectedFile, values);
// Use the uploaded file data instead of form values
values.name = uploadResponse.name;
values.url = uploadResponse.url;
} catch (uploadError) {
let errorMessage = 'Failed to upload logo file';
if (
uploadError.code === 'NETWORK_ERROR' ||
uploadError.message?.includes('timeout')
) {
errorMessage = 'Upload timed out. Please try again.';
} else if (uploadError.status === 413) {
errorMessage = 'File too large. Please choose a smaller file.';
} else if (uploadError.body?.error) {
errorMessage = uploadError.body.error;
}
notifications.show({
showNotification({
title: 'Upload Error',
message: errorMessage,
message: getUploadErrorMessage(uploadError),
color: 'red',
});
return; // Don't proceed with creation if upload fails
@ -104,8 +82,8 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
// Now create or update the logo with the final values
// Only proceed if we don't already have a logo from file upload
if (logo) {
const updatedLogo = await API.updateLogo(logo.id, values);
notifications.show({
const updatedLogo = await updateLogo(logo, values);
showNotification({
title: 'Success',
message: 'Logo updated successfully',
color: 'green',
@ -114,8 +92,8 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
} else if (!selectedFile) {
// Only create a new logo entry if we're not uploading a file
// (file upload already created the logo entry)
const newLogo = await API.createLogo(values);
notifications.show({
const newLogo = await createLogo(values);
showNotification({
title: 'Success',
message: 'Logo created successfully',
color: 'green',
@ -123,7 +101,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
onSuccess?.({ type: 'create', logo: newLogo }); // Call onSuccess for creates
} else {
// File was uploaded and logo was already created
notifications.show({
showNotification({
title: 'Success',
message: 'Logo uploaded successfully',
color: 'green',
@ -132,23 +110,9 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
}
onClose();
} catch (error) {
let errorMessage = logo
? 'Failed to update logo'
: 'Failed to create logo';
// Handle specific timeout errors
if (
error.code === 'NETWORK_ERROR' ||
error.message?.includes('timeout')
) {
errorMessage = 'Request timed out. Please try again.';
} else if (error.response?.data?.error) {
errorMessage = error.response.data.error;
}
notifications.show({
showNotification({
title: 'Error',
message: errorMessage,
message: getUpdateLogoErrorMessage(logo, error),
color: 'red',
});
} finally {
@ -163,14 +127,17 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
}, [defaultValues, logo, reset]);
const handleFileSelect = (files) => {
if (files.length === 0) return;
if (files.length === 0) {
console.log('No files selected');
return;
}
const file = files[0];
// Validate file size on frontend first
if (file.size > 5 * 1024 * 1024) {
if (!validateFileSize(file)) {
// 5MB
notifications.show({
showNotification({
title: 'Error',
message: 'File too large. Maximum size is 5MB.',
color: 'red',
@ -188,8 +155,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
// Auto-fill the name field if empty
const currentName = watch('name');
if (!currentName) {
const nameWithoutExtension = file.name.replace(/\.[^/.]+$/, '');
setValue('name', nameWithoutExtension);
setValue('name', getFilenameWithoutExtension(file.name));
}
// Set a placeholder URL (will be replaced after upload)
@ -204,9 +170,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
if (selectedFile) {
setSelectedFile(null);
// Revoke the object URL to free memory
if (logoPreview && logoPreview.startsWith('blob:')) {
URL.revokeObjectURL(logoPreview);
}
releaseUrl(logoPreview);
}
// Update preview for remote URLs
@ -224,7 +188,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
const url = new URL(urlValue);
const pathname = url.pathname;
const filename = pathname.substring(pathname.lastIndexOf('/') + 1);
const nameWithoutExtension = filename.replace(/\.[^/.]+$/, '');
const nameWithoutExtension = getFilenameWithoutExtension(filename);
if (nameWithoutExtension) {
setValue('name', nameWithoutExtension);
}
@ -237,11 +201,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
// Clean up object URLs when component unmounts or preview changes
useEffect(() => {
return () => {
if (logoPreview && logoPreview.startsWith('blob:')) {
URL.revokeObjectURL(logoPreview);
}
};
return () => releaseUrl(logoPreview);
}, [logoPreview]);
return (
@ -317,15 +277,15 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
mih={120}
style={{ pointerEvents: 'none' }}
>
<Dropzone.Accept>
<DropzoneAccept>
<Upload size={50} color="green" />
</Dropzone.Accept>
<Dropzone.Reject>
</DropzoneAccept>
<DropzoneReject>
<X size={50} color="red" />
</Dropzone.Reject>
<Dropzone.Idle>
</DropzoneReject>
<DropzoneIdle>
<FileImage size={50} />
</Dropzone.Idle>
</DropzoneIdle>
<div>
<Text size="xl" inline>

View file

@ -1,35 +1,40 @@
// Modal.js
import React, { useState, useEffect } from 'react';
import API from '../../api';
import React, { useEffect, useState } from 'react';
import useUserAgentsStore from '../../store/userAgents';
import M3UProfiles from './M3UProfiles';
import {
LoadingOverlay,
TextInput,
Box,
Button,
Checkbox,
Modal,
Flex,
Select,
FileInput,
NumberInput,
Divider,
Stack,
FileInput,
Flex,
Group,
Switch,
Box,
LoadingOverlay,
Modal,
NumberInput,
PasswordInput,
Tooltip,
Select,
Stack,
Switch,
TextInput,
} from '@mantine/core';
import M3UGroupFilter from './M3UGroupFilter';
import useChannelsStore from '../../store/channels';
import { notifications } from '@mantine/notifications';
import { isNotEmpty, useForm } from '@mantine/form';
import useEPGsStore from '../../store/epgs';
import useVODStore from '../../store/useVODStore';
import M3UFilters from './M3UFilters';
import ScheduleInput from './ScheduleInput';
import { DateTimePicker } from '@mantine/dates';
import { showNotification } from '../../utils/notificationUtils.js';
import { addEPG } from '../../utils/forms/DummyEpgUtils.js';
import {
addPlaylist,
getPlaylist,
prepareSubmitValues,
updatePlaylist,
} from '../../utils/forms/M3uUtils.js';
const M3U = ({
m3uAccount = null,
@ -48,8 +53,6 @@ const M3U = ({
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false);
const [filterModalOpen, setFilterModalOpen] = useState(false);
const [loadingText, setLoadingText] = useState('');
const [showCredentialFields, setShowCredentialFields] = useState(false);
const [scheduleType, setScheduleType] = useState('interval');
const form = useForm({
@ -110,12 +113,6 @@ const M3U = ({
? 'cron'
: 'interval'
);
if (m3uAccount.account_type == 'XC') {
setShowCredentialFields(true);
} else {
setShowCredentialFields(false);
}
} else {
setPlaylist(null);
form.reset();
@ -124,107 +121,53 @@ const M3U = ({
}
}, [m3uAccount]);
useEffect(() => {
if (form.values.account_type == 'XC') {
setShowCredentialFields(true);
}
}, [form.values.account_type]);
const onSubmit = async () => {
const { create_epg, ...values } = form.getValues();
// Convert exp_date (from controlled state) to ISO string for the API
if (values.account_type === 'XC') {
// XC accounts have exp_date auto-managed server-side; don't send it
delete values.exp_date;
} else if (expDate instanceof Date) {
values.exp_date = expDate.toISOString();
} else {
values.exp_date = null;
}
// Determine which schedule type is active based on field values
const hasCronExpression =
values.cron_expression && values.cron_expression.trim() !== '';
// Clear the field that isn't active based on actual field values
if (hasCronExpression) {
values.refresh_interval = 0;
} else {
values.cron_expression = '';
}
if (values.account_type == 'XC' && values.password == '') {
// If account XC and no password input, assuming no password change
// from previously stored value.
delete values.password;
}
if (values.user_agent == '0') {
values.user_agent = null;
}
let newPlaylist;
if (playlist?.id) {
await API.updatePlaylist({
id: playlist.id,
...values,
file,
const handleNewPlaylist = async (newPlaylist, values, create_epg) => {
if (create_epg) {
addEPG({
name: values.name,
source_type: 'xmltv',
url: `${new URL(values.server_url).origin}/xmltv.php?username=${values.username}&password=${values.password}`,
api_key: '',
is_active: true,
refresh_interval: 24,
});
} else {
newPlaylist = await API.addPlaylist({
...values,
file,
}
if (values.account_type != 'XC') {
showNotification({
title: 'Fetching M3U Groups',
message:
'Configure group filters and auto sync settings once complete.',
});
if (create_epg) {
API.addEPG({
name: values.name,
source_type: 'xmltv',
url: `${new URL(values.server_url).origin}/xmltv.php?username=${values.username}&password=${values.password}`,
api_key: '',
is_active: true,
refresh_interval: 24,
});
}
if (values.account_type != 'XC') {
notifications.show({
title: 'Fetching M3U Groups',
message:
'Configure group filters and auto sync settings once complete.',
});
// Don't prompt for group filters, but keeping this here
// in case we want to revive it
newPlaylist = null;
close();
return;
}
// Fetch the updated playlist details (this also updates the store via API)
const updatedPlaylist = await API.getPlaylist(newPlaylist.id);
// Note: We don't call fetchPlaylists() here because API.addPlaylist()
// already added the playlist to the store. Calling fetchPlaylists() creates
// a race condition where the store is temporarily cleared/replaced while
// websocket updates for the new playlist's refresh task are arriving.
await Promise.all([fetchChannelGroups(), fetchEPGs()]);
// If this is an XC account with VOD enabled, also fetch VOD categories
if (values.account_type === 'XC' && values.enable_vod) {
fetchCategories();
}
console.log('opening group options');
setPlaylist(updatedPlaylist);
setGroupFilterModalOpen(true);
close();
return;
}
form.reset();
setFile(null);
onClose(newPlaylist);
const updatedPlaylist = await getPlaylist(newPlaylist);
await Promise.all([fetchChannelGroups(), fetchEPGs()]);
if (values.enable_vod) {
fetchCategories();
}
setPlaylist(updatedPlaylist);
setGroupFilterModalOpen(true);
};
const onSubmit = async () => {
const { create_epg, ...rawValues } = form.getValues();
const values = prepareSubmitValues(rawValues, expDate);
if (playlist?.id) {
await updatePlaylist(playlist, values, file);
form.reset();
setFile(null);
onClose();
return;
}
const newPlaylist = await addPlaylist(values, file);
await handleNewPlaylist(newPlaylist, values, create_epg);
};
const close = () => {
@ -270,15 +213,11 @@ const M3U = ({
trapFocus={false}
yOffset="2vh"
>
<LoadingOverlay
visible={form.submitting}
overlayBlur={2}
loaderProps={loadingText ? { children: loadingText } : {}}
/>
<LoadingOverlay visible={form.submitting} overlayBlur={2} />
<form onSubmit={form.onSubmit(onSubmit)}>
<Group justify="space-between" align="top">
<Stack gap="5" style={{ flex: 1 }}>
<Stack gap="5" style={{ flex: 1, minWidth: 0 }}>
<TextInput
style={{ width: '100%' }}
id="name"
@ -378,6 +317,14 @@ const M3U = ({
placeholder="Upload files"
description="Upload a local M3U file instead of using URL"
onChange={setFile}
styles={{
input: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
},
}}
/>
<DateTimePicker

View file

@ -1,21 +1,24 @@
// Modal.js
import React, { useEffect } from 'react';
import API from '../../api';
import {
TextInput,
Box,
Button,
Modal,
Flex,
Select,
Group,
Modal,
Select,
Stack,
Switch,
Box,
TextInput,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { M3U_FILTER_TYPES } from '../../constants';
import usePlaylistsStore from '../../store/playlists';
import { setCustomProperty } from '../../utils';
import {
addM3UFilter,
updateM3UFilter,
} from '../../utils/forms/M3uFilterUtils.js';
const M3UFilter = ({ filter = null, m3u, isOpen, onClose }) => {
const fetchPlaylist = usePlaylistsStore((s) => s.fetchPlaylist);
@ -59,9 +62,9 @@ const M3UFilter = ({ filter = null, m3u, isOpen, onClose }) => {
if (!filter) {
// By default, new rule will go at the end
values.order = m3u.filters.length;
await API.addM3UFilter(m3u.id, values);
await addM3UFilter(m3u, values);
} else {
await API.updateM3UFilter(m3u.id, filter.id, values);
await updateM3UFilter(m3u, filter, values);
}
const updatedPlaylist = await fetchPlaylist(m3u.id);

View file

@ -1,19 +1,18 @@
import React, { useState, useEffect } from 'react';
import API from '../../api';
import React, { useEffect, useState } from 'react';
import usePlaylistsStore from '../../store/playlists';
import ConfirmationDialog from '../ConfirmationDialog';
import useWarningsStore from '../../store/warnings';
import {
Flex,
Modal,
Button,
Box,
ActionIcon,
Alert,
Box,
Button,
Center,
Flex,
Group,
Modal,
Text,
useMantineTheme,
Center,
Group,
Alert,
} from '@mantine/core';
import { GripHorizontal, Info, SquareMinus, SquarePen } from 'lucide-react';
import M3UFilter from './M3UFilter';
@ -36,6 +35,10 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
deleteM3UFilter,
updateM3UFilter,
} from '../../utils/forms/M3uFilterUtils.js';
const RowDragHandleCell = ({ rowId }) => {
const { attributes, listeners, setNodeRef } = useDraggable({
@ -143,8 +146,6 @@ const DraggableRow = ({ filter, editFilter, onDelete }) => {
};
const M3UFilters = ({ playlist, isOpen, onClose }) => {
const theme = useMantineTheme();
const [editorOpen, setEditorOpen] = useState(false);
const [filter, setFilter] = useState(null);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
@ -195,7 +196,7 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => {
if (!playlist || !playlist.id) return;
setDeleting(true);
try {
await API.deleteM3UFilter(playlist.id, id);
await deleteM3UFilter(playlist, id);
fetchPlaylist(playlist.id);
setFilters(filters.filter((f) => f.id !== id));
} catch (error) {
@ -239,7 +240,7 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => {
try {
await Promise.all(
changedFilters.map((f) =>
API.updateM3UFilter(playlist.id, f.id, { ...f, order: f.newOrder })
updateM3UFilter(playlist, f, { ...f, order: f.newOrder })
)
);
await fetchPlaylist(playlist.id);
@ -330,8 +331,8 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => {
<div style={{ whiteSpace: 'pre-line' }}>
{`Are you sure you want to delete the following filter?
Type: ${filterToDelete.type}
Patter: ${filterToDelete.regex_pattern}
Type: ${filterToDelete.filter_type}
Pattern: ${filterToDelete.regex_pattern}
This action cannot be undone.`}
</div>

View file

@ -1,24 +1,31 @@
// Modal.js
import React, { useState, useEffect } from 'react';
import API from '../../api';
import React, { useEffect, useState } from 'react';
import {
LoadingOverlay,
Button,
Modal,
Flex,
LoadingOverlay,
Modal,
Stack,
Tabs,
TabsList,
TabsPanel,
TabsTab,
} from '@mantine/core';
import useChannelsStore from '../../store/channels';
import useVODStore from '../../store/useVODStore';
import { notifications } from '@mantine/notifications';
import LiveGroupFilter from './LiveGroupFilter';
import VODCategoryFilter from './VODCategoryFilter';
import { showNotification } from '../../utils/notificationUtils.js';
import {
buildGroupStates,
saveAndRefreshPlaylist,
} from '../../utils/forms/M3uGroupFilterUtils.js';
import { detectGroupReservationOverlaps } from '../../utils/forms/GroupSyncUtils';
const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
const channelGroups = useChannelsStore((s) => s.channelGroups);
const fetchCategories = useVODStore((s) => s.fetchCategories);
const [groupStates, setGroupStates] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [movieCategoryStates, setMovieCategoryStates] = useState([]);
@ -40,36 +47,8 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
}, [playlist]);
useEffect(() => {
if (Object.keys(channelGroups).length === 0) {
return;
}
setGroupStates(
playlist.channel_groups
.filter((group) => channelGroups[group.channel_group]) // Filter out groups that don't exist
.map((group) => {
// Parse custom_properties if present
let customProps = {};
if (group.custom_properties) {
try {
customProps =
typeof group.custom_properties === 'string'
? JSON.parse(group.custom_properties)
: group.custom_properties;
} catch {
customProps = {};
}
}
return {
...group,
name: channelGroups[group.channel_group].name,
auto_channel_sync: group.auto_channel_sync || false,
auto_sync_channel_start: group.auto_sync_channel_start || 1.0,
auto_sync_channel_end: group.auto_sync_channel_end ?? null,
custom_properties: customProps,
};
})
);
if (Object.keys(channelGroups).length === 0) return;
setGroupStates(buildGroupStates(channelGroups, playlist.channel_groups));
}, [playlist, channelGroups]);
// Fetch VOD categories when modal opens for XC accounts with VOD enabled
@ -92,7 +71,7 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
// names on hover, so the toast just confirms the save proceeded.
const overlaps = detectGroupReservationOverlaps(groupStates);
if (overlaps.length > 0) {
notifications.show({
showNotification({
title: 'Overlapping channel number ranges',
message: `Saved with ${overlaps.length} overlapping range pair${overlaps.length === 1 ? '' : 's'}. Hover the warning icon on each group for details. Sync will assign whichever numbers are free at run time.`,
color: 'yellow',
@ -102,48 +81,26 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
setIsLoading(true);
try {
// Prepare groupStates for API
// Send ALL group states like the original code did, don't filter by enabled changes
const groupSettings = groupStates.map((state) => ({
...state,
custom_properties: state.custom_properties || undefined,
}));
const categorySettings = movieCategoryStates
.concat(seriesCategoryStates)
.map((state) => ({
...state,
custom_properties: state.custom_properties || undefined,
}))
.filter((state) => state.enabled !== state.original_enabled);
// Update account-level settings via the proper account endpoint
await API.updatePlaylist({
id: playlist.id,
auto_enable_new_groups_live: autoEnableNewGroupsLive,
auto_enable_new_groups_vod: autoEnableNewGroupsVod,
auto_enable_new_groups_series: autoEnableNewGroupsSeries,
});
// Update group settings via API endpoint
await API.updateM3UGroupSettings(
playlist.id,
groupSettings,
categorySettings
await saveAndRefreshPlaylist(
playlist,
groupStates,
movieCategoryStates,
seriesCategoryStates,
{
auto_enable_new_groups_live: autoEnableNewGroupsLive,
auto_enable_new_groups_vod: autoEnableNewGroupsVod,
auto_enable_new_groups_series: autoEnableNewGroupsSeries,
}
);
// Show notification about the refresh process
notifications.show({
showNotification({
title: 'Group Settings Updated',
message: 'Settings saved. Starting M3U refresh to apply changes...',
color: 'green',
autoClose: 3000,
});
// Refresh the playlist - this will handle channel sync automatically at the end
await API.refreshPlaylist(playlist.id);
notifications.show({
showNotification({
title: 'M3U Refresh Started',
message:
'The M3U account is being refreshed. Channel sync will occur automatically after parsing completes.',
@ -178,13 +135,13 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
<LoadingOverlay visible={isLoading} overlayBlur={2} />
<Stack>
<Tabs defaultValue="live">
<Tabs.List>
<Tabs.Tab value="live">Live</Tabs.Tab>
<Tabs.Tab value="vod-movie">VOD - Movies</Tabs.Tab>
<Tabs.Tab value="vod-series">VOD - Series</Tabs.Tab>
</Tabs.List>
<TabsList>
<TabsTab value="live">Live</TabsTab>
<TabsTab value="vod-movie">VOD - Movies</TabsTab>
<TabsTab value="vod-series">VOD - Series</TabsTab>
</TabsList>
<Tabs.Panel value="live">
<TabsPanel value="live">
<LiveGroupFilter
playlist={playlist}
groupStates={groupStates}
@ -192,9 +149,9 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
autoEnableNewGroupsLive={autoEnableNewGroupsLive}
setAutoEnableNewGroupsLive={setAutoEnableNewGroupsLive}
/>
</Tabs.Panel>
</TabsPanel>
<Tabs.Panel value="vod-movie">
<TabsPanel value="vod-movie">
<VODCategoryFilter
playlist={playlist}
categoryStates={movieCategoryStates}
@ -203,9 +160,9 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
autoEnableNewGroups={autoEnableNewGroupsVod}
setAutoEnableNewGroups={setAutoEnableNewGroupsVod}
/>
</Tabs.Panel>
</TabsPanel>
<Tabs.Panel value="vod-series">
<TabsPanel value="vod-series">
<VODCategoryFilter
playlist={playlist}
categoryStates={seriesCategoryStates}
@ -214,7 +171,7 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
autoEnableNewGroups={autoEnableNewGroupsSeries}
setAutoEnableNewGroups={setAutoEnableNewGroupsSeries}
/>
</Tabs.Panel>
</TabsPanel>
</Tabs>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">

View file

@ -1,26 +1,38 @@
import React, { useState, useEffect, useMemo } from 'react';
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 {
Alert,
Flex,
Modal,
TextInput,
Button,
Title,
Text,
Paper,
Badge,
Button,
Flex,
Grid,
Textarea,
GridCol,
Modal,
NumberInput,
Paper,
SegmentedControl,
Text,
Textarea,
TextInput,
Title,
} from '@mantine/core';
import { TriangleAlert } from 'lucide-react';
import { DateTimePicker } from '@mantine/dates';
import { useWebSocket } from '../../WebSocket';
import {
addM3UProfile,
applyRegex,
applyXcSimplePatterns,
buildProfileSchema,
buildSubmitValues,
fetchFirstStreamUrl,
getDetectedMode,
prepareExpDate,
splitByPattern,
updateM3UProfile,
validateXcSimple,
} from '../../utils/forms/M3uProfileUtils.js';
const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
const [websocketReady, sendMessage] = useWebSocket();
@ -49,20 +61,9 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
[profile]
);
const schema = Yup.object({
name: Yup.string().required('Name is required'),
search_pattern: Yup.string().when([], {
is: () => !isDefaultProfile && !isXC,
then: (schema) => schema.required('Search pattern is required'),
otherwise: (schema) => schema.notRequired(),
}),
replace_pattern: Yup.string().when([], {
is: () => !isDefaultProfile && !isXC,
then: (schema) => schema.required('Replace pattern is required'),
otherwise: (schema) => schema.notRequired(),
}),
notes: Yup.string(), // Optional field
});
const getResolver = () => {
return yupResolver(buildProfileSchema(isDefaultProfile, isXC));
};
const {
register,
@ -74,38 +75,22 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
setError,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
resolver: getResolver(),
});
const onSubmit = async (values) => {
console.log('submiting');
const expDate = prepareExpDate(values.exp_date, isXC);
// Convert exp_date for submission
let expDateValue = values.exp_date;
if (isXC) {
// XC accounts have exp_date auto-managed; don't send it
expDateValue = undefined;
} else if (expDateValue instanceof Date) {
expDateValue = expDateValue.toISOString();
} else if (!expDateValue) {
expDateValue = null;
}
// For XC simple mode: validate simple inputs and build patterns from credentials
if (isXC && xcMode === 'simple' && !isDefaultProfile) {
const errs = {};
if (!newUsername.trim()) errs.newUsername = 'New username is required';
if (!newPassword.trim()) errs.newPassword = 'New password is required';
const errs = validateXcSimple(newUsername, newPassword);
if (Object.keys(errs).length > 0) {
setSimpleErrors(errs);
return;
}
setSimpleErrors({});
values.search_pattern = `${m3u?.username || ''}/${m3u?.password || ''}`;
values.replace_pattern = `${newUsername.trim()}/${newPassword.trim()}`;
values = applyXcSimplePatterns(values, m3u, newUsername, newPassword);
}
// For XC advanced mode: validate regex pattern fields
if (isXC && xcMode === 'advanced' && !isDefaultProfile) {
if (!searchPattern.trim()) {
setError('search_pattern', { message: 'Search pattern is required' });
@ -117,76 +102,36 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
}
}
// Build submit values
let submitValues;
if (isDefaultProfile) {
submitValues = {
name: values.name,
search_pattern: searchPattern || '',
replace_pattern: replacePattern || '',
custom_properties: {
// Preserve existing custom_properties and add/update notes
...(profile?.custom_properties || {}),
notes: values.notes || '',
},
};
} else {
// For regular profiles, send all fields
submitValues = {
name: values.name,
max_streams: values.max_streams,
search_pattern: values.search_pattern,
replace_pattern: values.replace_pattern,
custom_properties: {
// Preserve existing custom_properties and add/update notes
...(profile?.custom_properties || {}),
notes: values.notes || '',
...(isXC ? { xcMode } : {}),
},
};
}
const submitValues = buildSubmitValues(
values,
profile,
isDefaultProfile,
isXC,
xcMode
);
if (expDate !== undefined) submitValues.exp_date = expDate;
// Add exp_date for non-XC accounts
if (expDateValue !== undefined) {
submitValues.exp_date = expDateValue;
}
if (profile?.id) {
await API.updateM3UProfile(m3u.id, {
id: profile.id,
...submitValues,
});
} else {
await API.addM3UProfile(m3u.id, submitValues);
}
profile?.id
? await updateM3UProfile(m3u.id, { ...submitValues, id: profile.id })
: await addM3UProfile(m3u.id, submitValues);
reset();
// Reset local state to sync with form reset
setSearchPattern('');
setReplacePattern('');
onClose();
};
useEffect(() => {
async function fetchStreamUrl() {
try {
if (!m3u?.id) return;
if (!m3u?.id) return;
const params = new URLSearchParams();
params.append('page', 1);
params.append('page_size', 1);
params.append('m3u_account', m3u.id);
const response = await API.queryStreams(params);
if (response?.results?.length > 0) {
setStreamUrl(response.results[0].url);
setSampleInput(response.results[0].url); // Initialize sample input with a real stream URL
fetchFirstStreamUrl(m3u.id)
.then((url) => {
if (url) {
setStreamUrl(url);
setSampleInput(url);
}
} catch (error) {
console.error('Error fetching stream URL:', error);
}
}
fetchStreamUrl();
})
.catch((error) => console.error('Error fetching stream URL:', error));
}, [m3u]);
useEffect(() => {
@ -239,19 +184,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
setReplacePattern(profile?.replace_pattern || '');
if (isXC && !isDefaultProfile) {
const storedMode = profile?.custom_properties?.xcMode;
let detectedMode;
if (storedMode) {
detectedMode = storedMode;
} else if (
profile?.search_pattern &&
profile.search_pattern === `${m3u?.username}/${m3u?.password}`
) {
detectedMode = 'simple';
} else if (profile?.search_pattern) {
detectedMode = 'advanced';
} else {
detectedMode = 'simple';
}
const detectedMode = getDetectedMode(storedMode, profile, m3u);
setXcMode(detectedMode);
if (detectedMode === 'simple') {
const rp = profile?.replace_pattern || '';
@ -294,49 +227,22 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
setXcMode(mode);
};
// Local regex for the live demo preview. Returns an array of strings and
// <mark> React nodes so user-supplied text is never interpolated into raw
// HTML (avoids self-XSS via dangerouslySetInnerHTML).
const getHighlightedSearchText = () => {
if (!searchPattern || !sampleInput) return sampleInput;
try {
const regex = new RegExp(searchPattern, 'g');
const parts = [];
let lastIndex = 0;
let m;
while ((m = regex.exec(sampleInput)) !== null) {
if (m.index > lastIndex) {
parts.push(sampleInput.slice(lastIndex, m.index));
}
parts.push(
<mark
key={`${m.index}-${parts.length}`}
style={{ backgroundColor: '#ffee58' }}
>
{m[0]}
</mark>
);
lastIndex = m.index + m[0].length;
if (m[0].length === 0) regex.lastIndex++;
}
if (lastIndex < sampleInput.length) {
parts.push(sampleInput.slice(lastIndex));
}
return parts;
} catch {
return sampleInput;
}
const segments = splitByPattern(sampleInput, searchPattern);
if (!segments) return sampleInput;
return segments.map((seg, i) =>
seg.matched ? (
<mark key={i} style={{ backgroundColor: '#ffee58' }}>
{seg.text}
</mark>
) : (
seg.text
)
);
};
const getLocalReplaceResult = () => {
if (!searchPattern || !sampleInput) return sampleInput;
try {
const regex = new RegExp(searchPattern, 'g');
return sampleInput.replace(regex, replacePattern);
} catch {
return sampleInput;
}
};
const getLocalReplaceResult = () =>
applyRegex(sampleInput, searchPattern, replacePattern);
return (
<Modal
@ -379,8 +285,8 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
>
<Text size="sm">
These patterns are applied to every stream in this playlist. If
the search pattern doesn&apos;t match a stream URL, the original
URL is used as-is.
the search pattern doesn't match a stream URL, the original URL
is used as-is.
</Text>
</Alert>
<TextInput
@ -539,7 +445,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
</Paper>
<Grid gutter="xs">
<Grid.Col span={12}>
<GridCol span={12}>
<Paper shadow="sm" p="xs" radius="md" withBorder>
<Text size="sm" weight={500} mb={3} component="div">
Matched Text{' '}
@ -554,9 +460,9 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
{getHighlightedSearchText()}
</Text>
</Paper>
</Grid.Col>
</GridCol>
<Grid.Col span={12}>
<GridCol span={12}>
<Paper shadow="sm" p="xs" radius="md" withBorder>
<Text size="sm" weight={500} mb={3}>
Result After Replace
@ -568,7 +474,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
{getLocalReplaceResult()}
</Text>
</Paper>
</Grid.Col>
</GridCol>
</Grid>
</>
)}

View file

@ -1,32 +1,165 @@
import React, { useState, useEffect } from 'react';
import API from '../../api';
import React, { useEffect, useState } from 'react';
import M3UProfile from './M3UProfile';
import AccountInfoModal from './AccountInfoModal';
import usePlaylistsStore from '../../store/playlists';
import ConfirmationDialog from '../ConfirmationDialog';
import useWarningsStore from '../../store/warnings';
import {
Card,
Checkbox,
Flex,
Modal,
Button,
Box,
ActionIcon,
Text,
NumberInput,
useMantineTheme,
Center,
Group,
Switch,
Badge,
Button,
Card,
Flex,
Group,
Modal,
NumberInput,
Stack,
Switch,
Text,
useMantineTheme,
} from '@mantine/core';
import { SquareMinus, SquarePen, Info } from 'lucide-react';
import { Info, SquareMinus, SquarePen } from 'lucide-react';
import {
deleteM3UProfile,
updateM3UProfile,
} from '../../utils/forms/M3uProfileUtils.js';
import {
getExpirationInfo,
isAccountExpired,
profileSortComparator,
} from '../../utils/forms/M3uProfilesUtils.js';
const M3uProfileCard = ({
item,
accountType,
onClickInfo,
onClickEdit,
onClickDelete,
onChangeMaxStreams,
onChangeActive,
}) => {
const theme = useMantineTheme();
const accountStatus = item.custom_properties?.user_info?.status ?? null;
const expirationInfo = getExpirationInfo(item);
const expired = isAccountExpired(item);
return (
<Card>
<Stack spacing="sm">
{/* Header with name and status badges */}
<Group justify="space-between" align="center">
<Group spacing="sm" align="center">
<Stack spacing={2}>
<Text fw={600}>{item.name}</Text>
{/* Show notes if they exist */}
{item.custom_properties?.notes && (
<Text size="xs" c="dimmed" style={{ fontStyle: 'italic' }}>
{item.custom_properties.notes}
</Text>
)}
</Stack>
{accountType === 'XC' && item.custom_properties && (
<Group spacing="xs">
{/* Account status badge */}
{accountStatus && (
<Badge
size="sm"
color={
accountStatus === 'Active'
? 'green'
: expired
? 'red'
: 'gray'
}
variant="light"
>
{accountStatus}
</Badge>
)}
{/* Expiration badge */}
{expirationInfo && (
<Badge
size="sm"
color={expirationInfo.color}
variant="outline"
>
{expirationInfo.text}
</Badge>
)}
{/* Info button next to badges */}
<ActionIcon
size="sm"
variant="filled"
color="blue"
onClick={onClickInfo}
title="View account information"
style={{
backgroundColor: 'rgba(34, 139, 230, 0.1)',
color: '#228be6',
}}
>
<Info size="16" />
</ActionIcon>
</Group>
)}
</Group>
</Group>
{/* Max Streams and Actions */}
<Flex gap="sm" align="flex-end">
<NumberInput
label="Max Streams"
value={item.max_streams}
disabled={item.is_default}
onChange={onChangeMaxStreams}
style={{ flex: 1 }}
/>
<Group spacing="xs" style={{ paddingBottom: 8 }}>
{/* Toggle switch */}
<Switch
checked={item.is_active}
onChange={onChangeActive}
disabled={item.is_default}
label="Active"
labelPosition="left"
size="sm"
/>
{/* Always show edit button, but limit what can be edited for default profiles */}
<ActionIcon
size="sm"
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={onClickEdit}
title={
item.is_default ? 'Edit profile name and notes' : 'Edit profile'
}
>
<SquarePen size="20" />
</ActionIcon>
{!item.is_default && (
<>
<ActionIcon
color={theme.tailwind.red[6]}
onClick={onClickDelete}
size="small"
variant="transparent"
title="Delete profile"
>
<SquareMinus size="20" />
</ActionIcon>
</>
)}
</Group>
</Flex>
</Stack>
</Card>
);
};
const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
const theme = useMantineTheme();
const allProfiles = usePlaylistsStore((s) => s.profiles);
const fetchPlaylist = usePlaylistsStore((s) => s.fetchPlaylist);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
@ -91,7 +224,7 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
if (!playlist || !playlist.id) return;
setDeletingProfile(true);
try {
await API.deleteM3UProfile(playlist.id, id);
await deleteM3UProfile(playlist.id, id);
} catch (error) {
console.error('Error deleting profile:', error);
} finally {
@ -103,7 +236,7 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
const toggleActive = async (values) => {
if (!playlist || !playlist.id) return;
try {
await API.updateM3UProfile(playlist.id, {
await updateM3UProfile(playlist.id, {
...values,
is_active: !values.is_active,
});
@ -115,7 +248,7 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
const modifyMaxStreams = async (value, item) => {
if (!playlist || !playlist.id) return;
try {
await API.updateM3UProfile(playlist.id, {
await updateM3UProfile(playlist.id, {
...item,
max_streams: value,
});
@ -142,49 +275,6 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
setAccountInfoOpen(false);
};
// Helper function to get account status from profile
const getAccountStatus = (profile) => {
if (!profile.custom_properties?.user_info) return null;
return profile.custom_properties.user_info.status;
};
// Helper function to check if account is expired
const isAccountExpired = (profile) => {
if (!profile.custom_properties?.user_info?.exp_date) return false;
try {
const expDate = new Date(
parseInt(profile.custom_properties.user_info.exp_date) * 1000
);
return expDate < new Date();
} catch {
return false;
}
};
// Helper function to get account expiration info
const getExpirationInfo = (profile) => {
if (!profile.custom_properties?.user_info?.exp_date) return null;
try {
const expDate = new Date(
parseInt(profile.custom_properties.user_info.exp_date) * 1000
);
const now = new Date();
const diffMs = expDate - now;
if (diffMs <= 0) return { text: 'Expired', color: 'red' };
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (days > 30) return { text: `${days} days`, color: 'green' };
if (days > 7) return { text: `${days} days`, color: 'yellow' };
if (days > 0) return { text: `${days} days`, color: 'orange' };
const hours = Math.floor(diffMs / (1000 * 60 * 60));
return { text: `${hours}h`, color: 'red' };
} catch {
return null;
}
};
// Don't render if modal is not open, or if playlist data is invalid
if (!isOpen || !playlist || !playlist.id) {
return <></>;
@ -204,141 +294,20 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
withinPortal={true}
yOffset="2vh"
>
{profilesArray
.sort((a, b) => {
// Always put default profile first
if (a.is_default) return -1;
if (b.is_default) return 1;
// Sort remaining profiles alphabetically by name
return a.name.localeCompare(b.name);
})
.map((item) => {
const accountStatus = getAccountStatus(item);
const expirationInfo = getExpirationInfo(item);
const expired = isAccountExpired(item);
return (
<Card key={item.id}>
<Stack spacing="sm">
{/* Header with name and status badges */}
<Group justify="space-between" align="center">
<Group spacing="sm" align="center">
<Stack spacing={2}>
<Text fw={600}>{item.name}</Text>
{/* Show notes if they exist */}
{item.custom_properties?.notes && (
<Text
size="xs"
c="dimmed"
style={{ fontStyle: 'italic' }}
>
{item.custom_properties.notes}
</Text>
)}
</Stack>
{playlist?.account_type === 'XC' &&
item.custom_properties && (
<Group spacing="xs">
{/* Account status badge */}
{accountStatus && (
<Badge
size="sm"
color={
accountStatus === 'Active'
? 'green'
: expired
? 'red'
: 'gray'
}
variant="light"
>
{accountStatus}
</Badge>
)}
{/* Expiration badge */}
{expirationInfo && (
<Badge
size="sm"
color={expirationInfo.color}
variant="outline"
>
{expirationInfo.text}
</Badge>
)}
{/* Info button next to badges */}
<ActionIcon
size="sm"
variant="filled"
color="blue"
onClick={() => showAccountInfo(item)}
title="View account information"
style={{
backgroundColor: 'rgba(34, 139, 230, 0.1)',
color: '#228be6',
}}
>
<Info size="16" />
</ActionIcon>
</Group>
)}
</Group>
</Group>
{/* Max Streams and Actions */}
<Flex gap="sm" align="flex-end">
<NumberInput
label="Max Streams"
value={item.max_streams}
disabled={item.is_default}
onChange={(value) => modifyMaxStreams(value, item)}
style={{ flex: 1 }}
/>
<Group spacing="xs" style={{ paddingBottom: 8 }}>
{/* Toggle switch */}
<Switch
checked={item.is_active}
onChange={() => toggleActive(item)}
disabled={item.is_default}
label="Active"
labelPosition="left"
size="sm"
/>
{/* Always show edit button, but limit what can be edited for default profiles */}
<ActionIcon
size="sm"
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={() => editProfile(item)}
title={
item.is_default
? 'Edit profile name and notes'
: 'Edit profile'
}
>
<SquarePen size="20" />
</ActionIcon>
{!item.is_default && (
<>
<ActionIcon
color={theme.tailwind.red[6]}
onClick={() => deleteProfile(item.id)}
size="small"
variant="transparent"
title="Delete profile"
>
<SquareMinus size="20" />
</ActionIcon>
</>
)}
</Group>
</Flex>
</Stack>
</Card>
);
})}
{profilesArray.sort(profileSortComparator).map((item) => {
return (
<M3uProfileCard
key={item.id}
item={item}
accountType={playlist?.account_type}
onClickInfo={() => showAccountInfo(item)}
onChangeMaxStreams={(value) => modifyMaxStreams(value, item)}
onChangeActive={() => toggleActive(item)}
onClickEdit={() => editProfile(item)}
onClickDelete={() => deleteProfile(item.id)}
/>
);
})}
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button

View file

@ -0,0 +1,648 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Module mocks
vi.mock('../../../utils/forms/AutoSyncAdvancedUtils.js', () => ({
getEpgSourceValue: vi.fn(() => null),
getEpgSourceData: vi.fn(() => []),
repackGroupChannels: vi.fn(() => Promise.resolve({})),
formatPreviewSummary: vi.fn(() => null),
}));
vi.mock('../../../utils/forms/LiveGroupFilterUtils.js', () => ({
getRegexOptions: vi.fn(() => [
{ value: 'i', label: 'Case insensitive' },
{ value: 'g', label: 'Global' },
]),
}));
vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('../../../images/logo.png', () => ({ default: 'logo.png' }));
vi.mock('../../LazyLogo.jsx', () => ({
default: ({ src, alt }) => (
<img data-testid="lazy-logo" src={src} alt={alt} />
),
}));
vi.mock('react-window', () => ({
FixedSizeList: ({ children, itemCount, itemData }) => (
<div data-testid="fixed-size-list">
{Array.from({ length: Math.min(itemCount, 5) }, (_, i) =>
children({ index: i, style: {}, data: itemData })
)}
</div>
),
}));
vi.mock('lucide-react', () => ({
RefreshCw: () => <svg data-testid="icon-refresh-cw" />,
}));
vi.mock('@mantine/core', () => ({
Box: ({ children, style }) => <div style={style}>{children}</div>,
Button: ({ children, onClick, disabled, loading, variant, size, color }) => (
<button
onClick={onClick}
disabled={disabled || loading}
data-variant={variant}
data-size={size}
data-color={color}
data-loading={loading}
>
{children}
</button>
),
Center: ({ children }) => <div data-testid="center">{children}</div>,
Checkbox: ({ checked, onChange, label, 'data-testid': testId }) => (
<label>
<input
type="checkbox"
data-testid={testId || label}
checked={!!checked}
onChange={(e) => onChange?.(e)}
/>
{label}
</label>
),
Divider: () => <hr data-testid="divider" />,
Flex: ({ children }) => <div data-testid="flex">{children}</div>,
Group: ({ children }) => <div data-testid="group">{children}</div>,
MultiSelect: ({ value, onChange, label, data, 'data-testid': testId }) => (
<div>
{label && <label>{label}</label>}
<select
multiple
data-testid={testId || label}
value={value ?? []}
onChange={(e) =>
onChange?.(Array.from(e.target.selectedOptions, (o) => o.value))
}
>
{(data || []).map((opt) => (
<option key={opt.value ?? opt} value={opt.value ?? opt}>
{opt.label ?? opt}
</option>
))}
</select>
</div>
),
Popover: ({ children }) => <div data-testid="popover">{children}</div>,
PopoverDropdown: ({ children }) => (
<div data-testid="popover-dropdown">{children}</div>
),
PopoverTarget: ({ children }) => (
<div data-testid="popover-target">{children}</div>
),
ScrollArea: ({ children }) => <div data-testid="scroll-area">{children}</div>,
Select: ({
value,
onChange,
label,
data,
'data-testid': testId,
clearable,
}) => (
<div>
{label && <label>{label}</label>}
<select
data-testid={testId || label}
value={value ?? ''}
onChange={(e) => onChange?.(e.target.value || null)}
>
{clearable && <option value="">--</option>}
{(data || []).map((opt) => (
<option key={opt.value ?? opt} value={opt.value ?? opt}>
{opt.label ?? opt}
</option>
))}
</select>
</div>
),
Stack: ({ children }) => <div data-testid="stack">{children}</div>,
Switch: ({ checked, onChange, label, 'data-testid': testId }) => (
<label>
<input
type="checkbox"
role="switch"
data-testid={testId || label}
checked={!!checked}
onChange={(e) => onChange?.(e)}
/>
{label}
</label>
),
Text: ({ children, size, c, fw }) => (
<span data-testid="text" data-size={size} data-color={c} data-fw={fw}>
{children}
</span>
),
TextInput: ({
value,
onChange,
label,
placeholder,
'data-testid': testId,
}) => (
<div>
{label && <label>{label}</label>}
<input
data-testid={testId || label}
type="text"
value={value ?? ''}
placeholder={placeholder}
onChange={(e) => onChange?.(e)}
/>
</div>
),
Tooltip: ({ children, label }) => (
<div data-testid="tooltip" data-label={label}>
{children}
</div>
),
}));
// Imports after mocks
import AutoSyncAdvanced from '../AutoSyncAdvanced';
import useChannelsStore from '../../../store/channels.jsx';
import { showNotification } from '../../../utils/notificationUtils.js';
import {
getEpgSourceValue,
getEpgSourceData,
repackGroupChannels,
formatPreviewSummary,
} from '../../../utils/forms/AutoSyncAdvancedUtils.js';
import { getRegexOptions } from '../../../utils/forms/LiveGroupFilterUtils.js';
// Helpers
const makeGroup = (overrides = {}) => ({
channel_group: 1,
name: 'Sports',
logo_url: null,
auto_sync_enabled: true,
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
custom_properties: {},
...overrides,
});
const makeEpgSource = (overrides = {}) => ({
id: 1,
name: 'My EPG',
source_type: 'xmltv',
...overrides,
});
const makePlaylist = (overrides = {}) => ({
id: 5,
custom_properties: {},
...overrides,
});
const defaultProps = (overrides = {}) => ({
group: makeGroup(),
epgSources: [makeEpgSource()],
channelGroups: [],
streamProfiles: [],
regexPreviewState: {},
onApplyGroupChange: vi.fn(),
onScheduleRegexPreview: vi.fn(),
onOpenLogoUpload: vi.fn(),
channelLogos: [],
playlist: makePlaylist(),
logosLoading: false,
ensureLogosLoaded: vi.fn(),
...overrides,
});
const renderComponent = (overrides = {}) => {
const props = defaultProps(overrides);
const utils = render(<AutoSyncAdvanced {...props} />);
return { ...props, ...utils };
};
const setupStore = () => {
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({ fetchGroups: vi.fn(), profiles: {} })
);
};
//
describe('AutoSyncAdvanced', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getEpgSourceValue).mockReturnValue(null);
vi.mocked(getEpgSourceData).mockReturnValue([
{ value: '1', label: 'My EPG (xmltv)' },
]);
vi.mocked(repackGroupChannels).mockResolvedValue({});
vi.mocked(formatPreviewSummary).mockReturnValue(null);
setupStore();
});
// Basic rendering
describe('basic rendering', () => {
it('renders without crashing', () => {
renderComponent();
expect(screen.getAllByTestId('stack')[0]).toBeInTheDocument();
});
it('calls getEpgSourceData with epgSources on render', () => {
const epgSources = [
makeEpgSource(),
makeEpgSource({ id: 2, name: 'EPG 2' }),
];
renderComponent({ epgSources });
expect(getEpgSourceData).toHaveBeenCalledWith(epgSources);
});
it('calls getEpgSourceValue with group.custom_properties on render', () => {
const group = makeGroup();
const epgSources = [makeEpgSource()];
renderComponent({ group, epgSources });
expect(getEpgSourceValue).toHaveBeenCalledWith({});
});
});
// RegexPreviewBox
describe('RegexPreviewBox', () => {
it('does not render preview box when no include regex is set', () => {
renderComponent({ group: makeGroup({ custom_properties: {} }) });
expect(screen.queryByText(/matched/i)).not.toBeInTheDocument();
});
it('renders include preview box when name_match_regex is set', () => {
const group = makeGroup({
custom_properties: { name_match_regex: 'ESPN' },
});
const regexPreviewState = {
1: {
filterResult: {
match_count: 3,
total_scanned: 50,
scan_limit_hit: false,
},
loading: false,
},
};
vi.mocked(formatPreviewSummary).mockReturnValue(
'3 filter matches in 50 streams'
);
renderComponent({ group, regexPreviewState });
expect(
screen.getByText('3 filter matches in 50 streams')
).toBeInTheDocument();
});
it('renders exclude preview box when name_match_exclude_regex is set', () => {
const group = makeGroup({
custom_properties: { name_match_exclude_regex: 'HD' },
});
const regexPreviewState = {
1: {
excludeResult: {
match_count: 2,
total_scanned: 50,
scan_limit_hit: false,
},
loading: false,
},
};
vi.mocked(formatPreviewSummary).mockReturnValue(
'2 exclude matches in 50 streams'
);
renderComponent({ group, regexPreviewState });
expect(
screen.getByText('2 exclude matches in 50 streams')
).toBeInTheDocument();
});
it('renders find preview box when name_regex_pattern is set', () => {
const group = makeGroup({
custom_properties: {
name_regex_pattern: 'News',
name_replace_pattern: '',
},
});
const regexPreviewState = {
1: {
findResult: {
matches: [
{ before: 'BBC News', after: 'BBC' },
{ before: 'Sky News', after: 'Sky' },
{ before: 'CNN News', after: 'CNN' },
{ before: 'Fox News', after: 'Fox' },
{ before: 'Al Jazeera News', after: 'Al Jazeera' },
],
total_in_group: 50,
},
loading: false,
},
};
vi.mocked(formatPreviewSummary).mockReturnValue(
'5 regex matches in 50 streams'
);
renderComponent({ group, regexPreviewState });
expect(
screen.getByText('5 regex matches in 50 streams')
).toBeInTheDocument();
expect(formatPreviewSummary).toHaveBeenCalledWith(
'rename',
regexPreviewState[1].findResult
);
});
it('shows loading state in preview box when state.loading is true', () => {
const group = makeGroup({
custom_properties: { name_match_regex: 'Sports' },
});
const regexPreviewState = { 1: { loading: true } };
renderComponent({ group, regexPreviewState });
// No summary rendered during loading
expect(formatPreviewSummary).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ match_count: expect.any(Number) })
);
});
it('does not render include preview when name_match_regex is empty string', () => {
const group = makeGroup({
custom_properties: { name_match_regex: '' },
});
renderComponent({ group, regexPreviewState: {} });
expect(formatPreviewSummary).not.toHaveBeenCalled();
});
it('renders regex error message when result has an error', () => {
const group = makeGroup({
custom_properties: { name_match_regex: 'bad(' },
});
const regexPreviewState = {
1: { filterResult: { error: 'Unterminated group' }, loading: false },
};
renderComponent({ group, regexPreviewState });
expect(
screen.getByText(/invalid regex.*unterminated group/i)
).toBeInTheDocument();
});
it('renders no-match message when filter result has empty matches', () => {
const group = makeGroup({
custom_properties: { name_match_regex: 'ZZZ' },
});
const regexPreviewState = {
1: {
filterResult: { matches: [], total_in_group: 10, error: null },
loading: false,
},
};
vi.mocked(formatPreviewSummary).mockReturnValue(
'0 filter matches in 10 streams'
);
renderComponent({ group, regexPreviewState });
expect(
screen.getByText(/no streams matched this pattern/i)
).toBeInTheDocument();
});
});
// EPG source select
describe('EPG source select', () => {
it('passes EPG source data from getEpgSourceData to the Select', () => {
vi.mocked(getEpgSourceData).mockReturnValue([
{ value: '1', label: 'My EPG (xmltv)' },
{ value: '2', label: 'EPG 2 (m3u)' },
]);
renderComponent();
const options = screen.getAllByRole('option');
const labels = options.map((o) => o.textContent);
expect(labels).toEqual(
expect.arrayContaining(['My EPG (xmltv)', 'EPG 2 (m3u)'])
);
});
it('pre-selects the value returned by getEpgSourceValue', () => {
vi.mocked(getEpgSourceValue).mockReturnValue('2');
vi.mocked(getEpgSourceData).mockReturnValue([
{ value: '1', label: 'EPG 1' },
{ value: '2', label: 'EPG 2' },
]);
renderComponent();
// The select with value '2' is present
const select = screen
.getAllByRole('combobox')
.find((s) => s.value === '2');
expect(select).toBeTruthy();
});
it('calls onApplyGroupChange when EPG source is changed', () => {
vi.mocked(getEpgSourceData).mockReturnValue([
{ value: '1', label: 'EPG 1' },
{ value: '2', label: 'EPG 2' },
]);
const { onApplyGroupChange } = renderComponent();
const selects = screen.getAllByRole('combobox');
fireEvent.change(selects[0], { target: { value: '2' } });
expect(onApplyGroupChange).toHaveBeenCalled();
});
});
// Regex text inputs
describe('regex text inputs', () => {
it('renders the include regex input', () => {
renderComponent();
// TextInput mocked with label as testid fallback
const inputs = screen.getAllByRole('textbox');
expect(inputs.length).toBeGreaterThan(0);
});
it('calls onScheduleRegexPreview when find regex changes', () => {
const { onScheduleRegexPreview } = renderComponent();
const findInput = screen.getByTestId('Find (Regex)');
fireEvent.change(findInput, { target: { value: 'ESPN' } });
expect(onScheduleRegexPreview).toHaveBeenCalled();
});
it('calls onScheduleRegexPreview when include regex changes', () => {
const { onScheduleRegexPreview } = renderComponent();
const includeInput = screen.getByTestId(
'Include if name matches (Regex)'
);
fireEvent.change(includeInput, { target: { value: 'ESPN' } });
expect(onScheduleRegexPreview).toHaveBeenCalled();
});
});
// Repack channels
describe('repack channels button', () => {
it('calls repackGroupChannels with playlist and group objects', async () => {
vi.mocked(repackGroupChannels).mockResolvedValue({
assigned: 5,
released: 2,
failed: 0,
});
const group = makeGroup({ channel_group: 7 });
const playlist = makePlaylist({ id: 3 });
renderComponent({ group, playlist });
const repackBtn = screen.getByRole('button', { name: /renumber now/i });
fireEvent.click(repackBtn);
await waitFor(() => {
expect(repackGroupChannels).toHaveBeenCalledWith(playlist, group);
});
});
it('shows success notification after repack resolves', async () => {
vi.mocked(repackGroupChannels).mockResolvedValue({});
renderComponent();
const repackBtn = screen.queryByRole('button', { name: /repack/i });
if (repackBtn) {
fireEvent.click(repackBtn);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
color: expect.stringMatching(/teal|green|blue/i),
})
);
});
}
});
it('shows error notification when repack fails', async () => {
vi.mocked(repackGroupChannels).mockRejectedValue(
new Error('Server error')
);
renderComponent();
const repackBtn = screen.queryByRole('button', { name: /repack/i });
if (repackBtn) {
fireEvent.click(repackBtn);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: expect.stringMatching(/red/i) })
);
});
}
});
});
// Logo upload
describe('logo upload', () => {
it('renders logo-related controls', () => {
renderComponent();
const logos = screen.queryAllByTestId('lazy-logo');
expect(logos.length).toBeGreaterThanOrEqual(0);
});
it('calls onOpenLogoUpload when logo upload button is clicked', () => {
const { onOpenLogoUpload } = renderComponent();
const uploadBtn = screen.queryByRole('button', { name: /upload|logo/i });
if (uploadBtn) {
fireEvent.click(uploadBtn);
expect(onOpenLogoUpload).toHaveBeenCalled();
}
});
it('passes channelLogos to the logo selector when provided', () => {
const channelLogos = [
{ id: 'logo1', url: '/logos/espn.png', name: 'ESPN' },
{ id: 'logo2', url: '/logos/cnn.png', name: 'CNN' },
];
renderComponent({ channelLogos });
// Just verify rendering doesn't throw with logos
expect(screen.getAllByTestId('stack')[0]).toBeInTheDocument();
});
});
// Stream profiles
describe('stream profiles', () => {
it('populates profile options from streamProfiles prop', () => {
const streamProfiles = [
{ id: 1, name: 'HD Profile' },
{ id: 2, name: 'SD Profile' },
];
renderComponent({ streamProfiles });
expect(screen.getAllByTestId('stack')[0]).toBeInTheDocument();
});
});
// Custom properties toggles
describe('custom properties toggles', () => {
it('renders switches/checkboxes for advanced options', () => {
renderComponent();
const switches = screen.queryAllByRole('switch');
const checkboxes = screen.queryAllByRole('checkbox');
expect(switches.length + checkboxes.length).toBeGreaterThan(0);
});
it('calls onApplyGroupChange with compact_numbering when switch is toggled on', () => {
const { onApplyGroupChange } = renderComponent();
const compactSwitch = screen.getByTestId('Compact numbering');
fireEvent.click(compactSwitch);
expect(onApplyGroupChange).toHaveBeenCalledWith(
expect.objectContaining({
custom_properties: expect.objectContaining({
compact_numbering: true,
}),
})
);
});
it('selects force_dummy_epg option via EPG select', () => {
vi.mocked(getEpgSourceData).mockReturnValue([
{ value: '0', label: 'No EPG' },
{ value: '1', label: 'My EPG' },
]);
const { onApplyGroupChange } = renderComponent();
const selects = screen.getAllByRole('combobox');
fireEvent.change(selects[0], { target: { value: '0' } });
expect(onApplyGroupChange).toHaveBeenCalledWith(
expect.objectContaining({
custom_properties: expect.objectContaining({ force_dummy_epg: true }),
})
);
});
});
// Regex options (MultiSelect)
describe('regex options MultiSelect', () => {
it('calls getRegexOptions when find input changes', () => {
renderComponent();
const findInput = screen.getByTestId('Find (Regex)');
fireEvent.change(findInput, { target: { value: 'ESPN' } });
expect(getRegexOptions).toHaveBeenCalled();
});
});
// useEffect / prop sync
describe('prop sync', () => {
it('re-calls getEpgSourceValue when group prop changes', () => {
const { rerender } = render(<AutoSyncAdvanced {...defaultProps()} />);
expect(getEpgSourceValue).toHaveBeenCalledTimes(1);
const newGroup = makeGroup({ channel_group: 2, name: 'News' });
rerender(<AutoSyncAdvanced {...defaultProps({ group: newGroup })} />);
expect(getEpgSourceValue).toHaveBeenCalledTimes(2);
});
it('re-calls getEpgSourceData when epgSources prop changes', () => {
const { rerender } = render(<AutoSyncAdvanced {...defaultProps()} />);
const callCount = vi.mocked(getEpgSourceData).mock.calls.length;
const newSources = [
makeEpgSource(),
makeEpgSource({ id: 99, name: 'New' }),
];
rerender(
<AutoSyncAdvanced {...defaultProps({ epgSources: newSources })} />
);
expect(vi.mocked(getEpgSourceData).mock.calls.length).toBeGreaterThan(
callCount
);
});
});
});

View file

@ -0,0 +1,429 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Utility mocks
vi.mock('../../../utils/forms/AutoSyncBasicUtils.js', () => ({
clampChannelNumber: vi.fn((v) =>
Math.min(999999, Math.max(1, Math.floor(Number(v) || 1)))
),
computeRangeOverlapsFor: vi.fn(() => []),
}));
// Mantine core
vi.mock('@mantine/core', () => ({
Flex: ({ children }) => <div data-testid="flex">{children}</div>,
Stack: ({ children }) => <div data-testid="stack">{children}</div>,
Text: ({ children, size, c }) => (
<span data-testid="text" data-size={size} data-color={c}>
{children}
</span>
),
Tooltip: ({ children, label }) => (
<div data-testid="tooltip" data-label={label}>
{children}
</div>
),
NumberInput: ({
value,
onChange,
label,
'data-testid': testId,
min,
max,
}) => (
<div>
{label && <label>{label}</label>}
<input
data-testid={testId || label}
type="number"
value={value ?? ''}
min={min}
max={max}
onChange={(e) =>
onChange?.(e.target.value === '' ? '' : Number(e.target.value))
}
/>
</div>
),
}));
// lucide-react
vi.mock('lucide-react', () => ({
AlertTriangle: () => <svg data-testid="icon-alert-triangle" />,
}));
// Imports after mocks
import AutoSyncBasic from '../AutoSyncBasic';
import {
clampChannelNumber,
computeRangeOverlapsFor,
} from '../../../utils/forms/AutoSyncBasicUtils.js';
// Helpers
const makeGroup = (overrides = {}) => ({
channel_group: 1,
name: 'Sports',
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
custom_properties: { channel_numbering_mode: 'fixed' },
...overrides,
});
const renderComponent = (groupOverrides = {}, props = {}) => {
const group = makeGroup(groupOverrides);
const onApplyGroupChange = vi.fn();
const utils = render(
<AutoSyncBasic
group={group}
groupStates={[]}
groupConflicts={{}}
onApplyGroupChange={onApplyGroupChange}
{...props}
/>
);
return { group, onApplyGroupChange, ...utils };
};
//
describe('AutoSyncBasic', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(computeRangeOverlapsFor).mockReturnValue([]);
vi.mocked(clampChannelNumber).mockImplementation((v) =>
Math.min(999999, Math.max(1, Math.floor(Number(v) || 1)))
);
});
// next_available mode
describe('next_available mode', () => {
it('renders the explanation text instead of inputs', () => {
renderComponent({
custom_properties: { channel_numbering_mode: 'next_available' },
});
expect(
screen.getByText(/Channels receive the lowest available numbers/i)
).toBeInTheDocument();
});
it('does not render any NumberInput', () => {
renderComponent({
custom_properties: { channel_numbering_mode: 'next_available' },
});
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
});
});
// fixed mode rendering
describe('fixed mode rendering', () => {
it('renders Start and End inputs', () => {
renderComponent();
expect(screen.getByTestId(/start/i)).toBeInTheDocument();
expect(screen.getByTestId(/end/i)).toBeInTheDocument();
});
it('displays auto_sync_channel_start as the start value', () => {
renderComponent({ auto_sync_channel_start: 150 });
expect(screen.getByTestId(/start/i)).toHaveValue(150);
});
it('defaults start to 1 when auto_sync_channel_start is undefined', () => {
renderComponent({ auto_sync_channel_start: undefined });
expect(screen.getByTestId(/start/i)).toHaveValue(1);
});
it('displays auto_sync_channel_end as the end value', () => {
renderComponent({ auto_sync_channel_end: 250 });
expect(screen.getByTestId(/end/i)).toHaveValue(250);
});
it('displays empty end when auto_sync_channel_end is undefined', () => {
renderComponent({ auto_sync_channel_end: undefined });
expect(screen.getByTestId(/end/i)).toHaveValue(null);
});
});
// provider mode rendering
describe('provider mode rendering', () => {
it('reads start from custom_properties.channel_numbering_fallback', () => {
renderComponent({
custom_properties: {
channel_numbering_mode: 'provider',
channel_numbering_fallback: 500,
},
});
expect(screen.getByTestId(/start/i)).toHaveValue(500);
});
it('defaults fallback start to 1 when channel_numbering_fallback is undefined', () => {
renderComponent({
custom_properties: {
channel_numbering_mode: 'provider',
},
});
expect(screen.getByTestId(/start/i)).toHaveValue(1);
});
});
// defaults when no mode is set
describe('defaults when mode is absent', () => {
it('treats missing mode as fixed and renders inputs', () => {
renderComponent({ custom_properties: {} });
expect(screen.getByTestId(/start/i)).toBeInTheDocument();
expect(screen.getByTestId(/end/i)).toBeInTheDocument();
});
});
// updateStart fixed mode
describe('updateStart in fixed mode', () => {
it('calls onApplyGroupChange with clamped start value', () => {
const { onApplyGroupChange } = renderComponent({
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
});
fireEvent.change(screen.getByTestId(/start/i), {
target: { value: '150' },
});
expect(clampChannelNumber).toHaveBeenCalledWith(150);
expect(onApplyGroupChange).toHaveBeenCalledWith(
expect.objectContaining({ auto_sync_channel_start: expect.any(Number) })
);
});
it('normalizes empty string input to 1', () => {
const { onApplyGroupChange } = renderComponent();
fireEvent.change(screen.getByTestId(/start/i), { target: { value: '' } });
expect(onApplyGroupChange).toHaveBeenCalledWith(
expect.objectContaining({ auto_sync_channel_start: 1 })
);
expect(clampChannelNumber).not.toHaveBeenCalled();
});
it('drops auto_sync_channel_end when new start exceeds current end', () => {
const { onApplyGroupChange } = renderComponent({
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
});
vi.mocked(clampChannelNumber).mockReturnValueOnce(300);
fireEvent.change(screen.getByTestId(/start/i), {
target: { value: '300' },
});
expect(onApplyGroupChange).toHaveBeenCalledWith(
expect.objectContaining({ auto_sync_channel_end: null })
);
});
it('keeps auto_sync_channel_end when new start is below end', () => {
const { onApplyGroupChange, group } = renderComponent({
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
});
vi.mocked(clampChannelNumber).mockReturnValueOnce(150);
fireEvent.change(screen.getByTestId(/start/i), {
target: { value: '150' },
});
expect(onApplyGroupChange).toHaveBeenCalledWith(
expect.objectContaining({
auto_sync_channel_end: group.auto_sync_channel_end,
})
);
});
it('keeps end when there is no existing end value', () => {
const { onApplyGroupChange } = renderComponent({
auto_sync_channel_start: 100,
auto_sync_channel_end: undefined,
});
vi.mocked(clampChannelNumber).mockReturnValueOnce(500);
fireEvent.change(screen.getByTestId(/start/i), {
target: { value: '500' },
});
const call = onApplyGroupChange.mock.calls[0][0];
expect(call.auto_sync_channel_end).toBeFalsy();
});
});
// updateStart provider mode
describe('updateStart in provider mode', () => {
it('writes to custom_properties.channel_numbering_fallback', () => {
const { onApplyGroupChange } = renderComponent({
custom_properties: {
channel_numbering_mode: 'provider',
channel_numbering_fallback: 100,
},
});
vi.mocked(clampChannelNumber).mockReturnValueOnce(400);
fireEvent.change(screen.getByTestId(/start/i), {
target: { value: '400' },
});
expect(onApplyGroupChange).toHaveBeenCalledWith(
expect.objectContaining({
custom_properties: expect.objectContaining({
channel_numbering_fallback: 400,
}),
})
);
});
it('merges existing custom_properties when updating fallback', () => {
const { onApplyGroupChange } = renderComponent({
custom_properties: {
channel_numbering_mode: 'provider',
channel_numbering_fallback: 100,
some_other_key: true,
},
});
vi.mocked(clampChannelNumber).mockReturnValueOnce(200);
fireEvent.change(screen.getByTestId(/start/i), {
target: { value: '200' },
});
const call = onApplyGroupChange.mock.calls[0][0];
expect(call.custom_properties.some_other_key).toBe(true);
expect(call.custom_properties.channel_numbering_mode).toBe('provider');
});
it('normalizes empty string to 1 in provider mode', () => {
const { onApplyGroupChange } = renderComponent({
custom_properties: { channel_numbering_mode: 'provider' },
});
fireEvent.change(screen.getByTestId(/start/i), { target: { value: '' } });
const call = onApplyGroupChange.mock.calls[0][0];
expect(call.custom_properties.channel_numbering_fallback).toBe(1);
});
});
// updateEnd
describe('updateEnd', () => {
it('calls onApplyGroupChange with clamped end value', () => {
const { onApplyGroupChange } = renderComponent();
vi.mocked(clampChannelNumber).mockReturnValueOnce(300);
fireEvent.change(screen.getByTestId(/end/i), {
target: { value: '300' },
});
expect(onApplyGroupChange).toHaveBeenCalledWith(
expect.objectContaining({ auto_sync_channel_end: 300 })
);
});
it('normalizes empty end to null/falsy', () => {
const { onApplyGroupChange } = renderComponent();
fireEvent.change(screen.getByTestId(/end/i), { target: { value: '' } });
const call = onApplyGroupChange.mock.calls[0][0];
expect(
call.auto_sync_channel_end == null || call.auto_sync_channel_end === ''
).toBe(true);
});
it('does not call clampChannelNumber when end is cleared', () => {
renderComponent();
fireEvent.change(screen.getByTestId(/end/i), { target: { value: '' } });
expect(clampChannelNumber).not.toHaveBeenCalled();
});
});
// Meta text
describe('meta text', () => {
it('shows range info when end is set', () => {
renderComponent({
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
});
expect(screen.getByTestId('text').textContent).toMatch(/100.*200|range/i);
});
it('shows stream count from group when available', () => {
const group = makeGroup({ auto_sync_channel_end: 200, stream_count: 42 });
const groupStates = [{ channel_group: 1 }];
render(
<AutoSyncBasic
group={group}
groupStates={groupStates}
groupConflicts={{}}
onApplyGroupChange={vi.fn()}
/>
);
expect(screen.getByTestId('text').textContent).toMatch(/42/);
});
});
// Overlap and conflict warnings
describe('warnings', () => {
it('does not render warning icon when no conflicts or overlaps', () => {
renderComponent({}, { groupConflicts: {} });
expect(
screen.queryByTestId('icon-alert-triangle')
).not.toBeInTheDocument();
});
it('renders warning icon when computeRangeOverlapsFor returns overlaps', () => {
vi.mocked(computeRangeOverlapsFor).mockReturnValue([
{ name: 'Other Group', start: 150, end: 160 },
]);
renderComponent();
expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument();
});
it('renders warning icon when group has a channel conflict', () => {
renderComponent(
{},
{ groupConflicts: { 1: { hasChannelConflict: true } } }
);
expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument();
});
it('passes overlap group name to tooltip', () => {
vi.mocked(computeRangeOverlapsFor).mockReturnValue([
{ name: 'News Group', start: 150, end: 160 },
]);
renderComponent();
const tooltip = screen
.getAllByTestId('tooltip')
.find((t) => t.getAttribute('data-label')?.includes('News Group'));
expect(tooltip).toBeDefined();
});
it('passes channel conflict message to tooltip', () => {
renderComponent(
{},
{ groupConflicts: { 1: { hasChannelConflict: true } } }
);
const tooltip = screen
.getAllByTestId('tooltip')
.find((t) =>
t.getAttribute('data-label')?.toLowerCase().includes('channel')
);
expect(tooltip).toBeDefined();
});
it('includes both conflict and overlap info in tooltip when both present', () => {
vi.mocked(computeRangeOverlapsFor).mockReturnValue([
{ name: 'Overlap Group', start: 110, end: 120 },
]);
renderComponent(
{},
{ groupConflicts: { 1: { hasChannelConflict: true } } }
);
const tooltip = screen.getAllByTestId('tooltip').find((t) => {
const label = t.getAttribute('data-label')?.toLowerCase();
return label?.includes('channel') && label.includes('overlap group');
});
expect(tooltip).toBeDefined();
});
it('calls computeRangeOverlapsFor with the group and groupStates', () => {
const group = makeGroup();
const groupStates = [makeGroup({ channel_group: 2, name: 'B' })];
render(
<AutoSyncBasic
group={group}
groupStates={groupStates}
groupConflicts={{}}
onApplyGroupChange={vi.fn()}
/>
);
expect(computeRangeOverlapsFor).toHaveBeenCalledWith(group, groupStates);
});
});
});

View file

@ -0,0 +1,232 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Module mocks
vi.mock('../../../utils/forms/M3uUtils.js', () => ({
updatePlaylist: vi.fn(() => Promise.resolve({})),
}));
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('@mantine/core', () => ({
Box: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
Tooltip: ({ children }) => <>{children}</>,
SegmentedControl: ({ value, onChange, data }) => (
<div data-testid="segmented-control" data-value={value}>
{data.map((opt) => (
<button
key={opt.value}
data-testid={`segmented-${opt.value}`}
data-active={value === opt.value}
onClick={() => onChange?.(opt.value)}
>
{opt.label}
</button>
))}
</div>
),
}));
// Imports after mocks
import OrphanCleanupControl from '../AutoSyncOrphanCleanup';
import { updatePlaylist } from '../../../utils/forms/M3uUtils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
// Helpers
const makePlaylist = (overrides = {}) => ({
id: 7,
custom_properties: null,
...overrides,
});
const renderControl = (playlistOverrides = {}) =>
render(<OrphanCleanupControl playlist={makePlaylist(playlistOverrides)} />);
const getControl = () => screen.getByTestId('segmented-control');
//
describe('OrphanCleanupControl', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Initial state
describe('initial state', () => {
it('defaults to "always" when custom_properties is null', () => {
renderControl({ custom_properties: null });
expect(getControl()).toHaveAttribute('data-value', 'always');
});
it('defaults to "always" when orphan_channel_cleanup key is absent', () => {
renderControl({ custom_properties: { compact_numbering: true } });
expect(getControl()).toHaveAttribute('data-value', 'always');
});
it('reads persisted "preserve_customized" mode from custom_properties', () => {
renderControl({
custom_properties: { orphan_channel_cleanup: 'preserve_customized' },
});
expect(getControl()).toHaveAttribute('data-value', 'preserve_customized');
});
it('reads persisted "never" mode from custom_properties', () => {
renderControl({
custom_properties: { orphan_channel_cleanup: 'never' },
});
expect(getControl()).toHaveAttribute('data-value', 'never');
});
});
// Optimistic update
describe('on mode change', () => {
it('updates the displayed value immediately before the PATCH resolves', () => {
vi.mocked(updatePlaylist).mockReturnValue(new Promise(() => {})); // never resolves
renderControl();
fireEvent.click(screen.getByTestId('segmented-never'));
expect(getControl()).toHaveAttribute('data-value', 'never');
});
it('calls updatePlaylist with merged custom_properties', async () => {
renderControl({ custom_properties: { compact_numbering: true } });
fireEvent.click(screen.getByTestId('segmented-never'));
await waitFor(() => {
expect(updatePlaylist).toHaveBeenCalledWith(
expect.objectContaining({ id: 7 }),
{
custom_properties: {
compact_numbering: true,
orphan_channel_cleanup: 'never',
},
}
);
});
});
it('does not call updatePlaylist when playlist has no id', async () => {
renderControl({ id: undefined });
fireEvent.click(screen.getByTestId('segmented-never'));
await Promise.resolve();
expect(updatePlaylist).not.toHaveBeenCalled();
});
it('preserves final "always" value after successful PATCH', async () => {
renderControl({ custom_properties: { orphan_channel_cleanup: 'never' } });
fireEvent.click(screen.getByTestId('segmented-always'));
await waitFor(() => expect(updatePlaylist).toHaveBeenCalled());
expect(getControl()).toHaveAttribute('data-value', 'always');
});
});
// Error / revert
describe('on PATCH failure', () => {
it('reverts to the previous mode', async () => {
vi.mocked(updatePlaylist).mockRejectedValueOnce(
new Error('Server error')
);
renderControl({
custom_properties: { orphan_channel_cleanup: 'always' },
});
fireEvent.click(screen.getByTestId('segmented-never'));
await waitFor(() => expect(showNotification).toHaveBeenCalled());
expect(getControl()).toHaveAttribute('data-value', 'always');
});
it('shows a red error notification', async () => {
vi.mocked(updatePlaylist).mockRejectedValueOnce(
new Error('Server error')
);
renderControl();
fireEvent.click(screen.getByTestId('segmented-never'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'red' })
);
});
});
it('includes err.message in the notification when body.detail is absent', async () => {
vi.mocked(updatePlaylist).mockRejectedValueOnce(new Error('Timeout'));
renderControl();
fireEvent.click(screen.getByTestId('segmented-never'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Timeout' })
);
});
});
it('prefers err.body.detail over err.message', async () => {
const err = Object.assign(new Error('generic'), {
body: { detail: 'Specific detail from server' },
});
vi.mocked(updatePlaylist).mockRejectedValueOnce(err);
renderControl();
fireEvent.click(screen.getByTestId('segmented-never'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Specific detail from server' })
);
});
});
it('falls back to "Please try again." when error has no message or body', async () => {
vi.mocked(updatePlaylist).mockRejectedValueOnce({});
renderControl();
fireEvent.click(screen.getByTestId('segmented-never'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Please try again.' })
);
});
});
});
// useEffect sync
describe('useEffect sync on prop change', () => {
it('syncs displayed mode when playlist.custom_properties changes', () => {
const { rerender } = render(
<OrphanCleanupControl
playlist={makePlaylist({
custom_properties: { orphan_channel_cleanup: 'always' },
})}
/>
);
expect(getControl()).toHaveAttribute('data-value', 'always');
rerender(
<OrphanCleanupControl
playlist={makePlaylist({
custom_properties: { orphan_channel_cleanup: 'never' },
})}
/>
);
expect(getControl()).toHaveAttribute('data-value', 'never');
});
it('resets to "always" when orphan_channel_cleanup is removed from custom_properties', () => {
const { rerender } = render(
<OrphanCleanupControl
playlist={makePlaylist({
custom_properties: { orphan_channel_cleanup: 'never' },
})}
/>
);
rerender(
<OrphanCleanupControl
playlist={makePlaylist({ custom_properties: {} })}
/>
);
expect(getControl()).toHaveAttribute('data-value', 'always');
});
});
});

View file

@ -1,17 +1,15 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ChannelGroup from '../ChannelGroup';
import API from '../../../api';
import useChannelsStore from '../../../store/channels';
import { showNotification } from '../../../utils/notificationUtils.js';
import { useForm } from '@mantine/form';
import * as API from '../../../utils/forms/ChannelGroupUtils.js';
// API mock
vi.mock('../../../api', () => ({
default: {
addChannelGroup: vi.fn(),
updateChannelGroup: vi.fn(),
},
vi.mock('../../../utils/forms/ChannelGroupUtils.js', () => ({
addChannelGroup: vi.fn(),
updateChannelGroup: vi.fn(),
}));
// Store mock
@ -265,8 +263,7 @@ describe('ChannelGroup', () => {
fireEvent.submit(screen.getByTestId('modal').querySelector('form'));
await waitFor(() => {
expect(API.updateChannelGroup).toHaveBeenCalledWith({
id: 1,
expect(API.updateChannelGroup).toHaveBeenCalledWith(group, {
name: 'Sports Updated',
});
});

View file

@ -0,0 +1,811 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import GroupManager from '../GroupManager';
// Store mocks
vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
vi.mock('../../../store/warnings', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('../../../utils/forms/ChannelGroupUtils.js', () => ({
addChannelGroup: vi.fn(),
cleanupUnusedChannelGroups: vi.fn(),
deleteChannelGroup: vi.fn(),
updateChannelGroup: vi.fn(),
}));
// ConfirmationDialog mock
vi.mock('../../ConfirmationDialog', () => ({
default: ({
opened,
onClose,
onConfirm,
title,
confirmLabel,
cancelLabel,
loading,
}) =>
opened ? (
<div data-testid="confirmation-dialog">
<div data-testid="confirm-title">{title}</div>
<button
data-testid="confirm-btn"
onClick={onConfirm}
disabled={loading}
>
{confirmLabel}
</button>
<button data-testid="cancel-btn" onClick={onClose}>
{cancelLabel}
</button>
</div>
) : null,
}));
// lucide-react
vi.mock('lucide-react', () => ({
AlertCircle: () => <svg data-testid="icon-alert-circle" />,
Check: () => <svg data-testid="icon-check" />,
Database: () => <svg data-testid="icon-database" />,
Filter: () => <svg data-testid="icon-filter" />,
SquareMinus: () => <svg data-testid="icon-square-minus" />,
SquarePen: () => <svg data-testid="icon-square-pen" />,
SquarePlus: () => <svg data-testid="icon-square-plus" />,
Trash: () => <svg data-testid="icon-trash" />,
Tv: () => <svg data-testid="icon-tv" />,
X: () => <svg data-testid="icon-x" />,
}));
// Mantine core
vi.mock('@mantine/core', async () => ({
ActionIcon: ({ children, onClick, disabled }) => (
<button data-testid="action-icon" onClick={onClick} disabled={disabled}>
{children}
</button>
),
Alert: ({ children, title }) => (
<div data-testid="alert">
{title && <div>{title}</div>}
{children}
</div>
),
Badge: ({ children, color }) => (
<span data-testid="badge" data-color={color}>
{children}
</span>
),
Box: ({ children, style }) => <div style={style}>{children}</div>,
Button: ({ children, onClick, disabled, loading, leftSection }) => (
<button
onClick={onClick}
disabled={disabled || loading}
data-loading={loading}
>
{leftSection && (
<span data-testid="button-left-section">{leftSection}</span>
)}
{children}
</button>
),
Chip: ({ children, checked, onChange }) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
{children}
</label>
),
Divider: () => <hr />,
Flex: ({ children }) => <div>{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,
ScrollArea: ({ children }) => <div data-testid="scroll-area">{children}</div>,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
TextInput: ({ value, onChange, placeholder, label, onKeyDown }) => (
<input
data-testid={`text-input-${label ?? placeholder ?? 'field'}`}
value={value}
onChange={onChange}
placeholder={placeholder}
onKeyDown={onKeyDown}
/>
),
useMantineTheme: () => ({
tailwind: { yellow: ['#fefcbf'], red: ['#f56565'] },
}),
}));
// Imports after mocks
import useChannelsStore from '../../../store/channels';
import useWarningsStore from '../../../store/warnings';
import { showNotification } from '../../../utils/notificationUtils.js';
import * as ChannelGroupUtils from '../../../utils/forms/ChannelGroupUtils.js';
// Helpers
const makeGroup = (overrides = {}) => ({
id: 1,
name: 'Group A',
hasChannels: true,
hasM3UAccounts: false,
canEdit: true,
canDelete: true,
...overrides,
});
const setupMocks = ({ groups = {}, isWarningSuppressed = false } = {}) => {
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({
channelGroups: groups,
canEditChannelGroup: vi.fn().mockReturnValue(true),
canDeleteChannelGroup: vi.fn().mockReturnValue(true),
})
);
const mockSuppressWarning = vi.fn();
vi.mocked(useWarningsStore).mockImplementation((sel) =>
sel({
isWarningSuppressed: () => isWarningSuppressed,
suppressWarning: mockSuppressWarning,
})
);
return { mockSuppressWarning };
};
const renderGroupManager = (props = {}) =>
render(<GroupManager isOpen={true} onClose={vi.fn()} {...props} />);
//
describe('GroupManager', () => {
beforeEach(() => vi.clearAllMocks());
// Visibility
describe('visibility', () => {
it('renders nothing when isOpen is false', () => {
setupMocks();
render(<GroupManager isOpen={false} onClose={vi.fn()} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('renders the modal when isOpen is true', () => {
setupMocks();
renderGroupManager();
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('calls onClose when modal close button is clicked', () => {
setupMocks();
const onClose = vi.fn();
renderGroupManager({ onClose });
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Group rendering
describe('group list rendering', () => {
it('renders a group name', () => {
setupMocks({ groups: { 1: makeGroup() } });
renderGroupManager();
expect(screen.getByText('Group A')).toBeInTheDocument();
});
it('renders multiple groups sorted alphabetically', () => {
setupMocks({
groups: {
2: makeGroup({ id: 2, name: 'Zebra' }),
1: makeGroup({ id: 1, name: 'Alpha' }),
},
});
renderGroupManager();
const names = screen
.getAllByText(/Alpha|Zebra/)
.map((el) => el.textContent);
expect(names.indexOf('Alpha')).toBeLessThan(names.indexOf('Zebra'));
});
it('renders channel badge for groups with channels', () => {
setupMocks({ groups: { 1: makeGroup({ hasChannels: true }) } });
renderGroupManager();
expect(screen.getByTestId('icon-tv')).toBeInTheDocument();
});
it('renders M3U badge for groups with M3U accounts', () => {
setupMocks({
groups: { 1: makeGroup({ hasChannels: false, hasM3UAccounts: true }) },
});
renderGroupManager();
expect(screen.getByTestId('icon-database')).toBeInTheDocument();
});
it('shows empty state when no groups match filter', () => {
setupMocks({ groups: {} });
renderGroupManager();
// No groups rendered in scroll area
expect(screen.queryByText('Group A')).not.toBeInTheDocument();
});
});
// Search filtering
describe('search filtering', () => {
it('filters groups by search term', () => {
setupMocks({
groups: {
1: makeGroup({ id: 1, name: 'Sports' }),
2: makeGroup({ id: 2, name: 'Movies' }),
},
});
renderGroupManager();
const searchInput = screen.getByPlaceholderText(/search/i);
fireEvent.change(searchInput, { target: { value: 'Sports' } });
expect(screen.getByText('Sports')).toBeInTheDocument();
expect(screen.queryByText('Movies')).not.toBeInTheDocument();
});
it('shows all groups when search is cleared', () => {
setupMocks({
groups: {
1: makeGroup({ id: 1, name: 'Sports' }),
2: makeGroup({ id: 2, name: 'Movies' }),
},
});
renderGroupManager();
const searchInput = screen.getByPlaceholderText(/search/i);
fireEvent.change(searchInput, { target: { value: 'Sports' } });
fireEvent.change(searchInput, { target: { value: '' } });
expect(screen.getByText('Sports')).toBeInTheDocument();
expect(screen.getByText('Movies')).toBeInTheDocument();
});
it('is case-insensitive', () => {
setupMocks({ groups: { 1: makeGroup({ name: 'Sports' }) } });
renderGroupManager();
const searchInput = screen.getByPlaceholderText(/search/i);
fireEvent.change(searchInput, { target: { value: 'sports' } });
expect(screen.getByText('Sports')).toBeInTheDocument();
});
});
// Edit group
describe('edit group', () => {
it('shows edit input when edit button is clicked', () => {
setupMocks({ groups: { 1: makeGroup() } });
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-pen').closest('button'));
expect(screen.getByDisplayValue('Group A')).toBeInTheDocument();
});
it('calls updateChannelGroup with trimmed name on save', async () => {
vi.mocked(ChannelGroupUtils.updateChannelGroup).mockResolvedValue(
undefined
);
setupMocks({ groups: { 1: makeGroup() } });
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-pen').closest('button'));
const input = screen.getByDisplayValue('Group A');
fireEvent.change(input, { target: { value: ' Updated Name ' } });
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(ChannelGroupUtils.updateChannelGroup).toHaveBeenCalledWith(
makeGroup(),
{ name: 'Updated Name' }
);
});
});
it('shows success notification after save', async () => {
vi.mocked(ChannelGroupUtils.updateChannelGroup).mockResolvedValue(
undefined
);
setupMocks({ groups: { 1: makeGroup() } });
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-pen').closest('button'));
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'green', title: 'Success' })
);
});
});
it('shows error notification when name is empty on save', async () => {
setupMocks({ groups: { 1: makeGroup() } });
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-pen').closest('button'));
const input = screen.getByDisplayValue('Group A');
fireEvent.change(input, { target: { value: '' } });
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'red' })
);
});
expect(ChannelGroupUtils.updateChannelGroup).not.toHaveBeenCalled();
});
it('shows error notification when updateChannelGroup throws', async () => {
vi.mocked(ChannelGroupUtils.updateChannelGroup).mockRejectedValue(
new Error('fail')
);
setupMocks({ groups: { 1: makeGroup() } });
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-pen').closest('button'));
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'red', title: 'Error' })
);
});
});
it('cancels edit and restores group name', () => {
setupMocks({ groups: { 1: makeGroup() } });
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-pen').closest('button'));
const input = screen.getByDisplayValue('Group A');
fireEvent.change(input, { target: { value: 'Changed' } });
fireEvent.click(screen.getByTestId('icon-x').closest('button'));
expect(screen.queryByDisplayValue('Changed')).not.toBeInTheDocument();
expect(screen.getByText('Group A')).toBeInTheDocument();
});
});
// Create group
describe('create group', () => {
it('calls addChannelGroup with trimmed name', async () => {
vi.mocked(ChannelGroupUtils.addChannelGroup).mockResolvedValue(undefined);
setupMocks();
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-plus').closest('button'));
const input = screen.getByPlaceholderText(/Enter group name/i);
fireEvent.change(input, { target: { value: ' New Group ' } });
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(ChannelGroupUtils.addChannelGroup).toHaveBeenCalledWith({
name: 'New Group',
});
});
});
it('shows success notification after create', async () => {
vi.mocked(ChannelGroupUtils.addChannelGroup).mockResolvedValue(undefined);
setupMocks();
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-plus').closest('button'));
const input = screen.getByPlaceholderText(/Enter group name/i);
fireEvent.change(input, { target: { value: 'New Group' } });
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'green', title: 'Success' })
);
});
});
it('shows error notification when name is empty', async () => {
setupMocks();
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-plus').closest('button'));
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'red' })
);
});
expect(ChannelGroupUtils.addChannelGroup).not.toHaveBeenCalled();
});
it('shows error notification when addChannelGroup throws', async () => {
vi.mocked(ChannelGroupUtils.addChannelGroup).mockRejectedValue(
new Error('fail')
);
setupMocks();
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-plus').closest('button'));
const input = screen.getByPlaceholderText(/Enter group name/i);
fireEvent.change(input, { target: { value: 'New Group' } });
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'red', title: 'Error' })
);
});
});
it('clears new group name and hides form after successful create', async () => {
vi.mocked(ChannelGroupUtils.addChannelGroup).mockResolvedValue(undefined);
setupMocks();
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-plus').closest('button'));
const input = screen.getByPlaceholderText(/Enter group name/i);
fireEvent.change(input, { target: { value: 'New Group' } });
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
await waitFor(() => {
expect(
screen.queryByPlaceholderText(/Enter group name/i)
).not.toBeInTheDocument();
});
});
it('cancels creation form with X button', () => {
setupMocks();
renderGroupManager();
fireEvent.click(screen.getByTestId('icon-square-plus').closest('button'));
expect(
screen.getByPlaceholderText(/Enter group name/i)
).toBeInTheDocument();
fireEvent.click(screen.getByTestId('icon-x').closest('button'));
expect(
screen.queryByPlaceholderText(/Enter group name/i)
).not.toBeInTheDocument();
});
});
// Delete group
describe('delete group', () => {
it('opens confirmation dialog when delete is clicked', async () => {
setupMocks({
groups: {
1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false },
},
});
renderGroupManager();
fireEvent.click(
screen.getByTestId('icon-square-minus').closest('button')
);
await waitFor(() => {
expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
expect(screen.getByTestId('confirm-title')).toHaveTextContent(
'Confirm Group Deletion'
);
});
});
it('calls deleteChannelGroup when confirmed', async () => {
vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue(
undefined
);
const groupToDelete = {
...makeGroup(),
hasChannels: false,
hasM3UAccounts: false,
};
setupMocks({ groups: { 1: groupToDelete } });
renderGroupManager();
fireEvent.click(
screen.getByTestId('icon-square-minus').closest('button')
);
fireEvent.click(screen.getByTestId('confirm-btn'));
await waitFor(() => {
expect(ChannelGroupUtils.deleteChannelGroup).toHaveBeenCalledWith(
groupToDelete
);
});
});
it('shows success notification after delete', async () => {
vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue(
undefined
);
setupMocks({
groups: {
1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false },
},
});
renderGroupManager();
fireEvent.click(
screen.getByTestId('icon-square-minus').closest('button')
);
fireEvent.click(screen.getByTestId('confirm-btn'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'green', title: 'Success' })
);
});
});
it('shows error notification when deleteChannelGroup throws', async () => {
vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockRejectedValue(
new Error('fail')
);
setupMocks({
groups: {
1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false },
},
});
renderGroupManager();
fireEvent.click(
screen.getByTestId('icon-square-minus').closest('button')
);
fireEvent.click(screen.getByTestId('confirm-btn'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'red', title: 'Error' })
);
});
});
it('closes confirmation dialog on cancel', () => {
setupMocks({
groups: {
1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false },
},
});
renderGroupManager();
fireEvent.click(
screen.getByTestId('icon-square-minus').closest('button')
);
fireEvent.click(screen.getByTestId('cancel-btn'));
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
it('closes confirmation dialog after successful delete', async () => {
vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue(
undefined
);
setupMocks({
groups: {
1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false },
},
});
renderGroupManager();
fireEvent.click(
screen.getByTestId('icon-square-minus').closest('button')
);
fireEvent.click(screen.getByTestId('confirm-btn'));
await waitFor(() => {
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
});
});
// Cleanup
describe('cleanup unused groups', () => {
const successResponse = { deleted_count: 1 };
it('opens cleanup confirmation dialog when warning not suppressed', () => {
setupMocks({ isWarningSuppressed: false });
renderGroupManager();
fireEvent.click(screen.getByText(/cleanup/i));
expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
expect(screen.getByTestId('confirm-title')).toHaveTextContent('Cleanup');
});
it('calls cleanupUnusedChannelGroups directly when warning is suppressed', async () => {
vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue(
successResponse
);
setupMocks({ isWarningSuppressed: true });
renderGroupManager();
fireEvent.click(screen.getByText(/cleanup/i));
await waitFor(() => {
expect(ChannelGroupUtils.cleanupUnusedChannelGroups).toHaveBeenCalled();
});
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
it('calls cleanupUnusedChannelGroups when cleanup confirmed', async () => {
vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue(
successResponse
);
setupMocks({ isWarningSuppressed: false });
renderGroupManager();
fireEvent.click(screen.getByText(/cleanup/i));
fireEvent.click(screen.getByTestId('confirm-btn'));
await waitFor(() => {
expect(ChannelGroupUtils.cleanupUnusedChannelGroups).toHaveBeenCalled();
});
});
it('shows success notification after cleanup', async () => {
vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue(
successResponse
);
setupMocks({ isWarningSuppressed: true });
renderGroupManager();
fireEvent.click(screen.getByText(/cleanup/i));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'green' })
);
});
});
it('shows error notification when cleanup throws', async () => {
vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockRejectedValue(
new Error('fail')
);
setupMocks({ isWarningSuppressed: true });
renderGroupManager();
fireEvent.click(screen.getByText(/cleanup/i));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'red' })
);
});
});
it('closes cleanup dialog on cancel', () => {
setupMocks({ isWarningSuppressed: false });
renderGroupManager();
fireEvent.click(screen.getByText(/cleanup/i));
fireEvent.click(screen.getByTestId('cancel-btn'));
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
});
// Filter chips
describe('filter chips', () => {
const twoGroups = {
1: makeGroup({
id: 1,
name: 'Channel Group',
hasChannels: true,
hasM3UAccounts: false,
}),
2: makeGroup({
id: 2,
name: 'M3U Group',
hasChannels: false,
hasM3UAccounts: true,
}),
};
it('hides channel groups when Channels chip is unchecked', () => {
setupMocks({ groups: twoGroups });
renderGroupManager();
const channelChip = screen.getByLabelText(/channel groups/i);
fireEvent.click(channelChip);
expect(screen.queryByText('Channel Group')).not.toBeInTheDocument();
expect(screen.getByText('M3U Group')).toBeInTheDocument();
});
it('hides M3U groups when M3U chip is unchecked', () => {
setupMocks({ groups: twoGroups });
renderGroupManager();
const m3uChip = screen.getByLabelText(/m3u groups/i);
fireEvent.click(m3uChip);
expect(screen.getByText('Channel Group')).toBeInTheDocument();
expect(screen.queryByText('M3U Group')).not.toBeInTheDocument();
});
it('hides unused groups when Unused chip is unchecked', () => {
setupMocks({
groups: {
1: makeGroup({
id: 1,
name: 'Unused Group',
hasChannels: false,
hasM3UAccounts: false,
}),
2: makeGroup({
id: 2,
name: 'Used Group',
hasChannels: true,
hasM3UAccounts: false,
}),
},
});
renderGroupManager();
const unusedChip = screen.getByLabelText(/unused/i);
fireEvent.click(unusedChip);
expect(screen.queryByText('Unused Group')).not.toBeInTheDocument();
expect(screen.getByText('Used Group')).toBeInTheDocument();
});
});
// fetchGroupUsage
describe('fetchGroupUsage on open', () => {
it('populates group usage from channelGroups flags when modal opens', () => {
setupMocks({
groups: {
1: makeGroup({ hasChannels: true, hasM3UAccounts: false }),
},
});
renderGroupManager();
// Group is visible (usage was populated), badge is shown
expect(screen.getByTestId('icon-tv')).toBeInTheDocument();
});
it('does not fetch when isOpen is false', () => {
setupMocks({
groups: { 1: makeGroup() },
});
render(<GroupManager isOpen={false} onClose={vi.fn()} />);
// Nothing should render
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,475 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import LoginForm from '../LoginForm';
// Router mock
const mockNavigate = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
}));
// Mock localStorage
const localStorageMock = (() => {
let store = {};
return {
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => {
store[key] = value.toString();
}),
clear: vi.fn(() => {
store = {};
}),
removeItem: vi.fn((key) => {
delete store[key];
}),
};
})();
global.localStorage = localStorageMock;
// Store mocks
vi.mock('../../../store/auth', () => ({ default: vi.fn() }));
vi.mock('../../../store/settings', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
// Asset mock
vi.mock('../../../assets/logo.png', () => ({ default: 'logo.png' }));
// Mantine core
vi.mock('@mantine/core', async () => ({
Paper: ({ children }) => <div data-testid="paper">{children}</div>,
Title: ({ children }) => <h1>{children}</h1>,
TextInput: ({
label,
name,
value,
onChange,
placeholder,
type,
disabled,
}) => (
<input
aria-label={label}
name={name}
value={value}
onChange={onChange}
placeholder={placeholder}
type={type ?? 'text'}
disabled={disabled}
data-testid={`input-${label}`}
/>
),
Button: ({ children, onClick, loading, disabled, type, variant }) => (
<button
onClick={onClick}
disabled={disabled || loading}
data-loading={loading}
type={type}
data-variant={variant}
>
{children}
</button>
),
Center: ({ children }) => <div>{children}</div>,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, c, size }) => (
<span data-color={c} data-size={size}>
{children}
</span>
),
Image: ({ src, alt }) => <img src={src} alt={alt} />,
Group: ({ children }) => <div>{children}</div>,
Divider: () => <hr />,
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,
Anchor: ({ children, onClick }) => (
<a data-testid="anchor" onClick={onClick} href="#">
{children}
</a>
),
Code: ({ children }) => <code>{children}</code>,
Checkbox: ({ label, checked, onChange }) => (
<label>
<input
type="checkbox"
aria-label={label}
checked={checked}
onChange={(e) => onChange(e)}
/>
{label}
</label>
),
}));
// Imports after mocks
import useAuthStore from '../../../store/auth';
import useSettingsStore from '../../../store/settings';
// Helpers
const setupMocks = ({
isAuthenticated = false,
loginResult = Promise.resolve(true),
version = '1.0.0',
} = {}) => {
const mockLogin = vi.fn().mockReturnValue(loginResult);
const mockLogout = vi.fn();
const mockInitData = vi.fn().mockResolvedValue(undefined);
const mockFetchVersion = vi.fn().mockResolvedValue(undefined);
vi.mocked(useAuthStore).mockImplementation((sel) =>
sel({
login: mockLogin,
logout: mockLogout,
isAuthenticated,
initData: mockInitData,
})
);
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({
fetchVersion: mockFetchVersion,
version: { version },
})
);
return { mockLogin, mockLogout, mockInitData, mockFetchVersion };
};
const renderLoginForm = () => render(<LoginForm />);
const getUsername = () => screen.getByTestId('input-Username');
const getPassword = () => screen.getByTestId('input-Password');
const getLoginButton = () => screen.getByRole('button', { type: 'submit' });
//
describe('LoginForm', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
// Rendering
describe('rendering', () => {
it('renders the login form', () => {
setupMocks();
renderLoginForm();
expect(screen.getByTestId('paper')).toBeInTheDocument();
});
it('renders the logo', () => {
setupMocks();
renderLoginForm();
expect(screen.getByRole('img')).toHaveAttribute('src', 'logo.png');
});
it('renders username and password inputs', () => {
setupMocks();
renderLoginForm();
expect(getUsername()).toBeInTheDocument();
expect(getPassword()).toBeInTheDocument();
});
it('renders the login button', () => {
setupMocks();
renderLoginForm();
expect(getLoginButton()).toBeInTheDocument();
});
it('renders "Remember Me" checkbox', () => {
setupMocks();
renderLoginForm();
expect(screen.getByLabelText(/remember me/i)).toBeInTheDocument();
});
it('renders "Save Password" checkbox', async () => {
setupMocks();
renderLoginForm();
await waitFor(() => {
fireEvent.click(screen.getByLabelText(/remember me/i));
expect(screen.getByLabelText(/save password/i)).toBeInTheDocument();
});
});
it('renders version info when version is available', () => {
setupMocks({ version: '2.3.4' });
renderLoginForm();
expect(screen.getByText(/2.3.4/i)).toBeInTheDocument();
});
it('renders forgot password anchor', () => {
setupMocks();
renderLoginForm();
expect(screen.getByText(/forgot password/i)).toBeInTheDocument();
});
});
// fetchVersion on mount
describe('on mount', () => {
it('calls fetchVersion on mount', () => {
const { mockFetchVersion } = setupMocks();
renderLoginForm();
expect(mockFetchVersion).toHaveBeenCalled();
});
});
// Form input
describe('form input', () => {
it('updates username field on change', () => {
setupMocks();
renderLoginForm();
fireEvent.change(getUsername(), { target: { value: 'testuser' } });
expect(getUsername()).toHaveValue('testuser');
});
it('updates password field on change', () => {
setupMocks();
renderLoginForm();
fireEvent.change(getPassword(), { target: { value: 'secret' } });
expect(getPassword()).toHaveValue('secret');
});
it('password input has type="password"', () => {
setupMocks();
renderLoginForm();
expect(getPassword()).toHaveAttribute('type', 'password');
});
});
// Successful login
describe('successful login', () => {
it('calls login with username and password', async () => {
const { mockLogin } = setupMocks({ loginResult: Promise.resolve(true) });
renderLoginForm();
fireEvent.change(getUsername(), { target: { value: 'admin' } });
fireEvent.change(getPassword(), { target: { value: 'pass123' } });
fireEvent.click(getLoginButton());
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith({
username: 'admin',
password: 'pass123',
});
});
});
it('calls initData after successful login', async () => {
const { mockInitData } = setupMocks({
loginResult: Promise.resolve(true),
});
renderLoginForm();
fireEvent.change(getUsername(), { target: { value: 'admin' } });
fireEvent.change(getPassword(), { target: { value: 'pass123' } });
fireEvent.click(getLoginButton());
await waitFor(() => {
expect(mockInitData).toHaveBeenCalled();
});
});
it('navigates to "/channels" when authenticated', async () => {
setupMocks({ isAuthenticated: true });
renderLoginForm();
fireEvent.change(getUsername(), { target: { value: 'admin' } });
fireEvent.change(getPassword(), { target: { value: 'pass123' } });
fireEvent.click(getLoginButton());
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/channels');
});
});
});
// Failed login
describe('failed login', () => {
it('does not navigate when login fails', async () => {
setupMocks({ loginResult: Promise.resolve(false) });
renderLoginForm();
fireEvent.change(getUsername(), { target: { value: 'admin' } });
fireEvent.change(getPassword(), { target: { value: 'wrong' } });
fireEvent.click(getLoginButton());
await waitFor(() => {
expect(mockNavigate).not.toHaveBeenCalled();
});
});
it('does not navigate when login throws', async () => {
setupMocks({ loginResult: Promise.reject(new Error('fail')) });
renderLoginForm();
fireEvent.click(getLoginButton());
await waitFor(() => {
expect(mockNavigate).not.toHaveBeenCalled();
});
});
});
// Loading state
describe('loading state', () => {
it('disables the login button while loading', async () => {
let resolveLogin;
const loginResult = new Promise((res) => {
resolveLogin = res;
});
setupMocks({ loginResult });
renderLoginForm();
fireEvent.click(getLoginButton());
await waitFor(() => {
expect(getLoginButton()).toBeDisabled();
});
resolveLogin(true);
});
it('re-enables the login button after login completes', async () => {
setupMocks({ loginResult: Promise.resolve(false) });
renderLoginForm();
fireEvent.click(getLoginButton());
await waitFor(() => {
expect(getLoginButton()).not.toBeDisabled();
});
});
});
// Remember Me
describe('remember me', () => {
it('saves username to localStorage when Remember Me is checked', async () => {
setupMocks({ loginResult: Promise.resolve(true) });
renderLoginForm();
fireEvent.click(screen.getByLabelText(/remember me/i));
fireEvent.change(getUsername(), { target: { value: 'savedUser' } });
fireEvent.click(getLoginButton());
await waitFor(() => {
expect(localStorage.getItem('dispatcharr_remembered_username')).toBe(
'savedUser'
);
});
});
it('removes username from localStorage when Remember Me is unchecked', async () => {
localStorage.setItem('dispatcharr_remembered_username', 'oldUser');
setupMocks({ loginResult: Promise.resolve(true) });
renderLoginForm();
fireEvent.click(screen.getByLabelText(/remember me/i));
fireEvent.click(getLoginButton());
await waitFor(() => {
expect(
localStorage.getItem('dispatcharr_remembered_username')
).toBeNull();
});
});
it('pre-fills username from localStorage on mount', () => {
localStorage.setItem('dispatcharr_remembered_username', 'storedUser');
setupMocks();
renderLoginForm();
expect(getUsername()).toHaveValue('storedUser');
});
});
// Save Password
describe('save password', () => {
it('saves encoded password to localStorage when Save Password is checked', async () => {
setupMocks({ loginResult: Promise.resolve(true) });
renderLoginForm();
await waitFor(() => {
fireEvent.click(screen.getByLabelText(/remember me/i));
fireEvent.click(screen.getByLabelText(/save password/i));
fireEvent.change(getPassword(), { target: { value: 'mySecret' } });
fireEvent.click(getLoginButton());
});
await waitFor(() => {
expect(
localStorage.getItem('dispatcharr_saved_password')
).not.toBeNull();
});
});
it('removes password from localStorage when Save Password is unchecked', async () => {
localStorage.setItem('dispatcharr_saved_password', btoa('oldPass'));
setupMocks({ loginResult: Promise.resolve(true) });
renderLoginForm();
fireEvent.click(getLoginButton());
await waitFor(() => {
expect(localStorage.getItem('dispatcharr_saved_password')).toBeNull();
});
});
it('pre-fills password from localStorage on mount', () => {
localStorage.setItem('dispatcharr_remembered_username', 'storedUser');
localStorage.setItem('dispatcharr_saved_password', btoa('storedPass'));
setupMocks();
renderLoginForm();
expect(getPassword()).toHaveValue('storedPass');
});
});
// Forgot Password modal
describe('forgot password modal', () => {
it('opens the forgot password modal when the anchor is clicked', () => {
setupMocks();
renderLoginForm();
fireEvent.click(screen.getByText(/forgot password/i));
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('shows the modal title', () => {
setupMocks();
renderLoginForm();
fireEvent.click(screen.getByText(/forgot password/i));
expect(screen.getByTestId('modal-title')).toBeInTheDocument();
});
it('closes the modal when the close button is clicked', () => {
setupMocks();
renderLoginForm();
fireEvent.click(screen.getByText(/forgot password/i));
fireEvent.click(screen.getByTestId('modal-close'));
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,518 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import LogoForm from '../Logo';
// Utility mocks
vi.mock('../../../utils/forms/LogoUtils.js', () => ({
createLogo: vi.fn(),
updateLogo: vi.fn(),
uploadLogo: vi.fn(),
getFilenameWithoutExtension: vi.fn((name) => name.replace(/\.[^.]+$/, '')),
getResolver: vi.fn(() => undefined),
getUpdateLogoErrorMessage: vi.fn((logo, err) => err.message),
getUploadErrorMessage: vi.fn((err) => err.message),
releaseUrl: vi.fn(),
validateFileSize: vi.fn(() => true),
}));
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
// Mantine dropzone
vi.mock('@mantine/dropzone', () => ({
Dropzone: vi.fn(({ children, onDrop }) => (
<div data-testid="dropzone" onClick={() => onDrop([])}>
{children}
</div>
)),
DropzoneAccept: ({ children }) => (
<div data-testid="dz-accept">{children}</div>
),
DropzoneReject: ({ children }) => (
<div data-testid="dz-reject">{children}</div>
),
DropzoneIdle: ({ children }) => <div data-testid="dz-idle">{children}</div>,
}));
// Mantine core
vi.mock('@mantine/core', () => ({
Box: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, type, loading, variant }) => (
<button
type={type || 'button'}
onClick={onClick}
disabled={loading}
data-variant={variant}
data-loading={loading}
>
{children}
</button>
),
Center: ({ children }) => <div>{children}</div>,
Divider: ({ label }) => <hr aria-label={label} />,
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, color }) => (
<span data-size={size} data-color={color}>
{children}
</span>
),
TextInput: ({
label,
placeholder,
onChange,
onBlur,
error,
disabled,
...rest
}) => (
<div>
<label>
{label}
<input
aria-label={label}
placeholder={placeholder}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
{...rest}
/>
</label>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
}));
// lucide-react
vi.mock('lucide-react', () => ({
FileImage: () => <svg data-testid="icon-file-image" />,
Upload: () => <svg data-testid="icon-upload" />,
X: () => <svg data-testid="icon-x" />,
}));
//
// Imports after mocks
//
import * as LogoUtils from '../../../utils/forms/LogoUtils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
// Helpers
const makeLogo = (overrides = {}) => ({
id: 1,
name: 'My Logo',
url: 'https://example.com/logo.png',
cache_url: 'https://cdn.example.com/logo.png',
...overrides,
});
const defaultProps = (overrides = {}) => ({
logo: null,
isOpen: true,
onClose: vi.fn(),
onSuccess: vi.fn(),
...overrides,
});
const makeFile = (name = 'test-logo.png', size = 1024) =>
new File(['content'], name, { type: 'image/png', size });
//
describe('LogoForm', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(LogoUtils.validateFileSize).mockReturnValue(true);
vi.mocked(LogoUtils.createLogo).mockResolvedValue({
id: 2,
name: 'New Logo',
url: 'https://example.com/new.png',
});
vi.mocked(LogoUtils.updateLogo).mockResolvedValue({
id: 1,
name: 'Updated Logo',
url: 'https://example.com/updated.png',
});
vi.mocked(LogoUtils.uploadLogo).mockResolvedValue({
id: 3,
name: 'uploaded-logo',
url: 'https://cdn.example.com/uploaded.png',
});
vi.mocked(LogoUtils.getResolver).mockReturnValue(undefined);
URL.createObjectURL = vi.fn();
});
afterEach(() => {
delete URL.createObjectURL;
});
// Rendering
describe('rendering', () => {
it('renders the modal when isOpen is true', () => {
render(<LogoForm {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render the modal when isOpen is false', () => {
render(<LogoForm {...defaultProps({ isOpen: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('shows "Add Logo" title for new logo', () => {
render(<LogoForm {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Add Logo');
});
it('shows "Edit Logo" title when editing existing logo', () => {
render(<LogoForm {...defaultProps({ logo: makeLogo() })} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Edit Logo');
});
it('shows "Create" button for new logo', () => {
render(<LogoForm {...defaultProps()} />);
expect(screen.getByText('Create')).toBeInTheDocument();
});
it('shows "Update" button when editing existing logo', () => {
render(<LogoForm {...defaultProps({ logo: makeLogo() })} />);
expect(screen.getByText('Update')).toBeInTheDocument();
});
it('pre-fills name input when editing existing logo', () => {
render(<LogoForm {...defaultProps({ logo: makeLogo() })} />);
expect(screen.getByDisplayValue('My Logo')).toBeInTheDocument();
});
it('pre-fills URL input when editing existing logo', () => {
render(<LogoForm {...defaultProps({ logo: makeLogo() })} />);
expect(
screen.getByDisplayValue('https://example.com/logo.png')
).toBeInTheDocument();
});
it('shows logo preview when logo has cache_url', () => {
render(<LogoForm {...defaultProps({ logo: makeLogo() })} />);
const img = screen.getByAltText('Logo preview');
expect(img).toHaveAttribute('src', 'https://cdn.example.com/logo.png');
});
it('does not show preview when no logo provided', () => {
render(<LogoForm {...defaultProps()} />);
expect(screen.queryByAltText('Logo preview')).not.toBeInTheDocument();
});
it('renders the dropzone', () => {
render(<LogoForm {...defaultProps()} />);
expect(screen.getByTestId('dropzone')).toBeInTheDocument();
});
it('calls onClose when Cancel is clicked', () => {
const onClose = vi.fn();
render(<LogoForm {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByText('Cancel'));
expect(onClose).toHaveBeenCalled();
});
it('calls onClose when modal close button is clicked', () => {
const onClose = vi.fn();
render(<LogoForm {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Create logo
describe('creating a logo via URL', () => {
it('calls createLogo with entered values on submit', async () => {
render(<LogoForm {...defaultProps()} />);
fireEvent.change(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'https://example.com/new.png' },
}
);
fireEvent.change(screen.getByPlaceholderText('Enter logo name'), {
target: { value: 'New Logo' },
});
fireEvent.click(screen.getByText('Create'));
await waitFor(() => {
expect(LogoUtils.createLogo).toHaveBeenCalled();
});
});
it('shows success notification after creating logo', async () => {
render(<LogoForm {...defaultProps()} />);
fireEvent.change(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'https://example.com/new.png' },
}
);
fireEvent.click(screen.getByText('Create'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Success', color: 'green' })
);
});
});
it('calls onSuccess with type "create" after creating logo', async () => {
const onSuccess = vi.fn();
render(<LogoForm {...defaultProps({ onSuccess })} />);
fireEvent.change(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'https://example.com/new.png' },
}
);
fireEvent.click(screen.getByText('Create'));
await waitFor(() => {
expect(onSuccess).toHaveBeenCalledWith(
expect.objectContaining({ type: 'create' })
);
});
});
it('calls onClose after creating logo', async () => {
const onClose = vi.fn();
render(<LogoForm {...defaultProps({ onClose })} />);
fireEvent.change(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'https://example.com/new.png' },
}
);
fireEvent.click(screen.getByText('Create'));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('shows error notification when createLogo throws', async () => {
vi.mocked(LogoUtils.createLogo).mockRejectedValue(new Error('API error'));
render(<LogoForm {...defaultProps()} />);
fireEvent.change(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'https://example.com/new.png' },
}
);
fireEvent.click(screen.getByText('Create'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Error', color: 'red' })
);
});
});
});
// Update logo
describe('updating an existing logo', () => {
it('calls updateLogo on submit', async () => {
render(<LogoForm {...defaultProps({ logo: makeLogo() })} />);
fireEvent.click(screen.getByText('Update'));
await waitFor(() => {
expect(LogoUtils.updateLogo).toHaveBeenCalledWith(
makeLogo(),
expect.objectContaining({ name: 'My Logo' })
);
});
});
it('calls onSuccess with type "update" after updating logo', async () => {
const onSuccess = vi.fn();
render(<LogoForm {...defaultProps({ logo: makeLogo(), onSuccess })} />);
fireEvent.click(screen.getByText('Update'));
await waitFor(() => {
expect(onSuccess).toHaveBeenCalledWith(
expect.objectContaining({ type: 'update' })
);
});
});
it('shows success notification after updating logo', async () => {
render(<LogoForm {...defaultProps({ logo: makeLogo() })} />);
fireEvent.click(screen.getByText('Update'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Success', color: 'green' })
);
});
});
it('shows error notification when updateLogo throws', async () => {
vi.mocked(LogoUtils.updateLogo).mockRejectedValue(
new Error('Server error')
);
render(<LogoForm {...defaultProps({ logo: makeLogo() })} />);
fireEvent.click(screen.getByText('Update'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Error', color: 'red' })
);
});
});
});
// File upload
describe('file upload via dropzone', () => {
it('calls uploadLogo with the selected file on submit', async () => {
const file = makeFile('my-logo.png');
// Override dropzone to pass our test file
const { Dropzone } = await import('@mantine/dropzone');
vi.mocked(Dropzone).mockImplementation(({ children, onDrop }) => (
<div data-testid="dropzone" onClick={() => onDrop([file])}>
{children}
</div>
));
render(<LogoForm {...defaultProps()} />);
fireEvent.click(screen.getByTestId('dropzone'));
fireEvent.click(screen.getByText('Create'));
await waitFor(() => {
expect(LogoUtils.uploadLogo).toHaveBeenCalledWith(
file,
expect.any(Object)
);
});
});
it('shows error notification when file is too large', async () => {
vi.mocked(LogoUtils.validateFileSize).mockReturnValue(false);
// Override dropzone to pass a file
const file = makeFile('big.png', 10 * 1024 * 1024);
const { Dropzone } = await import('@mantine/dropzone');
vi.mocked(Dropzone).mockImplementationOnce(({ children, onDrop }) => (
<div data-testid="dropzone" onClick={() => onDrop([file])}>
{children}
</div>
));
render(<LogoForm {...defaultProps()} />);
fireEvent.click(screen.getByTestId('dropzone'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Error', color: 'red' })
);
});
});
it('shows upload error notification when uploadLogo throws', async () => {
vi.mocked(LogoUtils.uploadLogo).mockRejectedValue(
new Error('Upload failed')
);
const file = makeFile('logo.png');
const { Dropzone } = await import('@mantine/dropzone');
vi.mocked(Dropzone).mockImplementationOnce(({ children, onDrop }) => (
<div data-testid="dropzone" onClick={() => onDrop([file])}>
{children}
</div>
));
render(<LogoForm {...defaultProps()} />);
fireEvent.click(screen.getByTestId('dropzone'));
fireEvent.click(screen.getByText('Create'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Upload Error', color: 'red' })
);
});
});
});
// URL input behaviour
describe('URL input behaviour', () => {
it('updates preview when a valid http URL is entered', () => {
render(<LogoForm {...defaultProps()} />);
fireEvent.change(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'https://example.com/img.png' },
}
);
expect(screen.getByAltText('Logo preview')).toHaveAttribute(
'src',
'https://example.com/img.png'
);
});
it('removes preview when URL is cleared', () => {
render(<LogoForm {...defaultProps({ logo: makeLogo() })} />);
fireEvent.change(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: '' },
}
);
expect(screen.queryByAltText('Logo preview')).not.toBeInTheDocument();
});
it('auto-fills name from URL on blur', () => {
render(<LogoForm {...defaultProps()} />);
fireEvent.change(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'https://example.com/my-channel-logo.png' },
}
);
fireEvent.blur(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'https://example.com/my-channel-logo.png' },
}
);
expect(LogoUtils.getFilenameWithoutExtension).toHaveBeenCalled();
});
it('does not throw on blur with invalid URL', () => {
render(<LogoForm {...defaultProps()} />);
expect(() => {
fireEvent.blur(
screen.getByPlaceholderText('https://example.com/logo.png'),
{
target: { value: 'not-a-url' },
}
);
}).not.toThrow();
});
});
});

View file

@ -0,0 +1,708 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import M3U from '../M3U';
// Store mocks
vi.mock('../../../store/userAgents', () => ({ default: vi.fn() }));
vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
vi.mock('../../../store/epgs', () => ({ default: vi.fn() }));
vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils/forms/M3uUtils.js', () => ({
addPlaylist: vi.fn(),
getPlaylist: vi.fn(),
prepareSubmitValues: vi.fn((values) => values),
updatePlaylist: vi.fn(),
}));
vi.mock('../../../utils/forms/DummyEpgUtils.js', () => ({
addEPG: vi.fn(),
}));
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
// Sub-component mocks
vi.mock('../M3UProfiles', () => ({
default: ({ onChange }) => (
<div data-testid="m3u-profiles">
<button onClick={() => onChange?.([])}>M3UProfiles</button>
</div>
),
}));
vi.mock('../M3UGroupFilter', () => ({
default: ({ onChange }) => (
<div data-testid="m3u-group-filter">
<button onClick={() => onChange?.([])}>M3UGroupFilter</button>
</div>
),
}));
vi.mock('../M3UFilters', () => ({
default: ({ onChange }) => (
<div data-testid="m3u-filters">
<button onClick={() => onChange?.([])}>M3UFilters</button>
</div>
),
}));
vi.mock('../ScheduleInput', () => ({
default: ({ onChange, value }) => (
<div data-testid="schedule-input">
<input
data-testid="schedule-value"
value={value || ''}
onChange={(e) => onChange?.(e.target.value)}
/>
</div>
),
}));
// Mantine dates
vi.mock('@mantine/dates', () => ({
DateTimePicker: ({ label, value, onChange, placeholder }) => (
<div>
<label>
{label}
<input
data-testid="date-time-picker"
placeholder={placeholder}
value={value ? value.toISOString() : ''}
onChange={(e) =>
onChange?.(e.target.value ? new Date(e.target.value) : null)
}
/>
</label>
</div>
),
}));
// Mantine form
vi.mock('@mantine/form', () => {
let _values = null;
return {
isNotEmpty: vi.fn(() => (val) => (val ? null : 'Required')),
__resetFormState: () => {
_values = null;
},
useForm: vi.fn(({ initialValues = {} } = {}) => {
if (_values === null) {
_values = { ...initialValues };
}
return {
key: vi.fn((field) => field),
getValues: () => ({ ..._values }),
setValues: (v) => {
Object.assign(_values, v);
},
setFieldValue: (field, val) => {
_values[field] = val;
},
reset: () => {
_values = { ...initialValues };
},
submitting: false,
onSubmit: vi.fn((handler) => (e) => {
e?.preventDefault?.();
if (_values?.name) handler();
}),
getInputProps: vi.fn((field) => ({
value: _values?.[field] ?? '',
onChange: vi.fn((e) => {
const val = e?.target?.value ?? e;
if (_values) _values[field] = val;
}),
error: null,
})),
};
}),
};
});
// Mantine core
vi.mock('@mantine/core', () => ({
Box: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, type, loading, disabled, variant, color }) => (
<button
type={type || 'button'}
onClick={onClick}
disabled={disabled || loading}
data-variant={variant}
data-color={color}
data-loading={String(loading)}
>
{children}
</button>
),
Checkbox: ({ label, checked, onChange, disabled }) => (
<label>
<input
type="checkbox"
aria-label={label}
checked={!!checked}
onChange={(e) => onChange?.(e)}
disabled={disabled}
/>
{label}
</label>
),
Divider: ({ label }) => <hr aria-label={label} />,
FileInput: ({ label, placeholder, onChange, accept, disabled }) => (
<div>
<label>
{label}
<input
type="file"
aria-label={label || placeholder}
accept={accept}
disabled={disabled}
onChange={(e) => onChange?.(e.target.files?.[0] ?? null)}
/>
</label>
</div>
),
Flex: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
LoadingOverlay: ({ visible }) =>
visible ? <div data-testid="loading-overlay" /> : null,
Modal: ({ children, opened, onClose, title, size }) =>
opened ? (
<div data-testid="modal" data-size={size}>
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
NumberInput: ({
label,
placeholder,
value,
onChange,
min,
max,
disabled,
error,
}) => (
<div>
<label>
{label}
<input
type="number"
aria-label={label || placeholder}
placeholder={placeholder}
value={value ?? ''}
min={min}
max={max}
disabled={disabled}
onChange={(e) => onChange?.(Number(e.target.value))}
/>
</label>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
PasswordInput: ({
label,
placeholder,
value,
onChange,
onBlur,
error,
disabled,
}) => (
<div>
<label>
{label}
<input
type="password"
aria-label={label || placeholder}
placeholder={placeholder}
value={value || ''}
disabled={disabled}
onChange={(e) => onChange?.(e)}
onBlur={onBlur}
/>
</label>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
Select: ({ label, placeholder, value, onChange, data, disabled, error }) => (
<div>
<label>
{label}
<select
aria-label={label || placeholder}
value={value || ''}
disabled={disabled}
onChange={(e) => onChange?.(e.target.value || null)}
>
<option value="">{placeholder}</option>
{data?.map((d) => {
const val = typeof d === 'string' ? d : d.value;
const lbl = typeof d === 'string' ? d : d.label;
return (
<option key={val} value={val}>
{lbl}
</option>
);
})}
</select>
</label>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Switch: ({ label, checked, onChange, disabled }) => (
<label>
<input
type="checkbox"
role="switch"
aria-label={label}
checked={!!checked}
disabled={disabled}
onChange={(e) => onChange?.(e)}
/>
{label}
</label>
),
TextInput: ({
id,
label,
placeholder,
value,
onChange,
onBlur,
error,
disabled,
...rest
}) => (
<div>
<label>
{label}
<input
data-testid={id ? `text-input-${id}` : undefined}
aria-label={label || placeholder}
placeholder={placeholder}
value={value || ''}
disabled={disabled}
onChange={onChange}
onBlur={onBlur}
{...rest}
/>
</label>
{error && <span data-testid={`error-${label}`}>{error}</span>}
</div>
),
}));
//
// Imports after mocks
//
import useUserAgentsStore from '../../../store/userAgents';
import useChannelsStore from '../../../store/channels';
import useEPGsStore from '../../../store/epgs';
import useVODStore from '../../../store/useVODStore';
import * as M3uUtils from '../../../utils/forms/M3uUtils.js';
import * as DummyEpgUtils from '../../../utils/forms/DummyEpgUtils.js';
import * as mantineForm from '@mantine/form';
// Helpers
const makeM3uAccount = (overrides = {}) => ({
id: 1,
name: 'Test M3U',
server_url: 'http://example.com/playlist.m3u',
username: 'user1',
password: 'pass1',
account_type: 'XC',
max_streams: 0,
refresh_interval: 24,
auto_refresh: false,
is_active: true,
custom_properties: {},
...overrides,
});
const makeUserAgent = (overrides = {}) => ({
id: 1,
name: 'Default Agent',
user_agent: 'Mozilla/5.0',
...overrides,
});
const defaultProps = (overrides = {}) => ({
m3uAccount: null,
isOpen: true,
onClose: vi.fn(),
...overrides,
});
const setupStores = (overrides = {}) => {
const fetchUserAgents = vi.fn();
const fetchChannelGroups = vi.fn();
const fetchEPGs = vi.fn();
const fetchCategories = vi.fn();
useUserAgentsStore.mockImplementation((selector) => {
const state = {
userAgents: overrides.userAgents || [],
fetchUserAgents,
};
return selector(state);
});
useChannelsStore.mockImplementation((selector) => {
const state = {
fetchChannelGroups,
};
return selector(state);
});
useEPGsStore.mockImplementation((selector) => {
const state = {
fetchEPGs,
};
return selector(state);
});
useVODStore.mockImplementation((selector) => {
const state = {
fetchCategories,
};
return selector(state);
});
return {
fetchUserAgents,
fetchChannelGroups,
fetchEPGs,
fetchCategories,
};
};
//
describe('M3U', () => {
beforeEach(() => {
vi.clearAllMocks();
mantineForm.__resetFormState();
vi.mocked(M3uUtils.addPlaylist).mockResolvedValue(
makeM3uAccount({ id: 2 })
);
vi.mocked(M3uUtils.updatePlaylist).mockResolvedValue(makeM3uAccount());
vi.mocked(M3uUtils.getPlaylist).mockResolvedValue(makeM3uAccount());
vi.mocked(M3uUtils.prepareSubmitValues).mockImplementation((v) => v);
vi.mocked(DummyEpgUtils.addEPG).mockResolvedValue({
id: 10,
name: 'Dummy EPG',
});
});
// Rendering
describe('rendering', () => {
it('renders the modal when isOpen is true', () => {
setupStores();
render(<M3U {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render the modal when isOpen is false', () => {
setupStores();
render(<M3U {...defaultProps({ isOpen: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('renders Name input', () => {
setupStores();
render(<M3U {...defaultProps()} />);
expect(screen.getByTestId('text-input-name')).toBeInTheDocument();
});
it('renders URL input', () => {
setupStores();
render(<M3U {...defaultProps()} />);
expect(screen.getByTestId('text-input-server_url')).toBeInTheDocument();
});
it('renders submit button with "Add" label for new account', () => {
setupStores();
render(<M3U {...defaultProps()} />);
expect(
screen.getByRole('button', { name: /add|create|save/i })
).toBeInTheDocument();
});
it('renders submit button with "Update" or "Save" label for existing account', () => {
setupStores();
render(<M3U {...defaultProps({ m3uAccount: makeM3uAccount() })} />);
expect(
screen.getByRole('button', { name: /update|save/i })
).toBeInTheDocument();
});
it('pre-fills name when editing', () => {
setupStores();
render(<M3U {...defaultProps({ m3uAccount: makeM3uAccount() })} />);
expect(screen.getByDisplayValue('Test M3U')).toBeInTheDocument();
});
it('pre-fills URL when editing', () => {
setupStores();
render(<M3U {...defaultProps({ m3uAccount: makeM3uAccount() })} />);
expect(
screen.getByDisplayValue('http://example.com/playlist.m3u')
).toBeInTheDocument();
});
it('renders M3UProfiles sub-component', () => {
setupStores();
render(<M3U {...defaultProps({ m3uAccount: makeM3uAccount() })} />);
expect(screen.getByTestId('m3u-profiles')).toBeInTheDocument();
});
it('renders M3UGroupFilter sub-component', () => {
setupStores();
render(<M3U {...defaultProps({ m3uAccount: makeM3uAccount() })} />);
expect(screen.getByTestId('m3u-group-filter')).toBeInTheDocument();
});
it('renders M3UFilters sub-component', () => {
setupStores();
render(<M3U {...defaultProps({ m3uAccount: makeM3uAccount() })} />);
expect(screen.getByTestId('m3u-filters')).toBeInTheDocument();
});
it('renders ScheduleInput sub-component', () => {
setupStores();
render(<M3U {...defaultProps()} />);
expect(screen.getByTestId('schedule-input')).toBeInTheDocument();
});
it('populates user agent select with agents from store', () => {
setupStores({
userAgents: [
makeUserAgent({ id: 1, name: 'Agent One' }),
makeUserAgent({ id: 2, name: 'Agent Two' }),
],
});
render(<M3U {...defaultProps()} />);
expect(screen.getByText('Agent One')).toBeInTheDocument();
expect(screen.getByText('Agent Two')).toBeInTheDocument();
});
});
// Cancel behaviour
describe('cancel behaviour', () => {
it('calls onClose when modal X is clicked', () => {
const onClose = vi.fn();
setupStores();
render(<M3U {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Adding a playlist
describe('adding a new playlist', () => {
const fillRequiredFields = () => {
const nameInput = screen.getByTestId('text-input-name');
fireEvent.change(nameInput, { target: { value: 'New Playlist' } });
const urlInput = screen.getByTestId('text-input-server_url');
if (urlInput) {
fireEvent.change(urlInput, {
target: { value: 'http://example.com/new.m3u' },
});
}
};
it('calls addPlaylist on valid submit', async () => {
setupStores();
render(<M3U {...defaultProps()} />);
fillRequiredFields();
fireEvent.click(screen.getByRole('button', { name: /add|create|save/i }));
await waitFor(() => {
expect(M3uUtils.addPlaylist).toHaveBeenCalled();
});
});
it('calls prepareSubmitValues before addPlaylist', async () => {
setupStores();
render(<M3U {...defaultProps()} />);
fillRequiredFields();
fireEvent.click(screen.getByRole('button', { name: /add|create|save/i }));
await waitFor(() => {
expect(M3uUtils.prepareSubmitValues).toHaveBeenCalled();
});
});
it('calls onClose after successful add', async () => {
const onClose = vi.fn();
setupStores();
render(
<M3U
{...defaultProps({
onClose,
m3uAccount: makeM3uAccount({ account_type: 'Other' }),
})}
/>
);
fillRequiredFields();
fireEvent.click(screen.getByRole('button', { name: /add|create|save/i }));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
// Updating a playlist
describe('updating an existing playlist', () => {
it('calls updatePlaylist on submit', async () => {
setupStores();
render(<M3U {...defaultProps({ m3uAccount: makeM3uAccount() })} />);
fireEvent.click(screen.getByRole('button', { name: /update|save/i }));
await waitFor(() => {
expect(M3uUtils.updatePlaylist).toHaveBeenCalled();
});
});
it('does not call addPlaylist when updating', async () => {
setupStores();
render(<M3U {...defaultProps({ m3uAccount: makeM3uAccount() })} />);
fireEvent.click(screen.getByRole('button', { name: /update|save/i }));
await waitFor(() => {
expect(M3uUtils.updatePlaylist).toHaveBeenCalled();
});
expect(M3uUtils.addPlaylist).not.toHaveBeenCalled();
});
it('calls onClose after successful update', async () => {
const onClose = vi.fn();
setupStores();
render(
<M3U {...defaultProps({ m3uAccount: makeM3uAccount(), onClose })} />
);
fireEvent.click(screen.getByRole('button', { name: /update|save/i }));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
// Form validation
describe('form validation', () => {
it('does not call addPlaylist when Name is empty', async () => {
setupStores();
render(<M3U {...defaultProps()} />);
// Clear name if pre-filled, then submit
const nameInput = screen.getByTestId('text-input-name');
fireEvent.change(nameInput, { target: { value: '' } });
fireEvent.click(screen.getByRole('button', { name: /add|create|save/i }));
await new Promise((r) => setTimeout(r, 50));
expect(M3uUtils.addPlaylist).not.toHaveBeenCalled();
});
});
// Refresh / schedule behaviour
describe('schedule and refresh', () => {
it('renders the ScheduleInput', () => {
setupStores();
render(<M3U {...defaultProps()} />);
expect(screen.getByTestId('schedule-input')).toBeInTheDocument();
});
});
// Max streams
describe('max streams field', () => {
it('renders max streams number input', () => {
setupStores();
render(<M3U {...defaultProps()} />);
expect(
screen.getByRole('spinbutton', { name: /max.?stream/i })
).toBeInTheDocument();
});
it('pre-fills max_streams when editing', () => {
setupStores();
render(
<M3U
{...defaultProps({ m3uAccount: makeM3uAccount({ max_streams: 5 }) })}
/>
);
expect(screen.getByDisplayValue('5')).toBeInTheDocument();
});
});
// User agent select
describe('user agent select', () => {
it('renders user agent select', () => {
setupStores();
render(<M3U {...defaultProps()} />);
expect(
screen.getByRole('combobox', { name: /user.?agent/i })
).toBeInTheDocument();
});
});
// Credential fields
describe('credential fields', () => {
it('renders username input', () => {
setupStores();
render(<M3U {...defaultProps()} />);
expect(screen.getByTestId('text-input-username')).toBeInTheDocument();
});
it('renders password input', () => {
setupStores();
render(<M3U {...defaultProps()} />);
expect(
document.querySelector('input[type="password"]')
).toBeInTheDocument();
});
it('pre-fills username when editing', () => {
setupStores();
render(<M3U {...defaultProps({ m3uAccount: makeM3uAccount() })} />);
expect(screen.getByDisplayValue('user1')).toBeInTheDocument();
});
});
// Dummy EPG creation
describe('dummy EPG auto-creation', () => {
it('calls addEPG after successfully adding a playlist', async () => {
setupStores();
render(<M3U {...defaultProps()} />);
const nameInput = screen.getByTestId('text-input-name');
fireEvent.change(nameInput, { target: { value: 'My Playlist' } });
const urlInput = screen.getByTestId('text-input-server_url');
if (urlInput) {
fireEvent.change(urlInput, {
target: { value: 'http://example.com/p.m3u' },
});
}
fireEvent.click(screen.getByRole('button', { name: /add|create|save/i }));
await waitFor(() => {
expect(M3uUtils.addPlaylist).toHaveBeenCalled();
});
// addEPG may be called conditionally assert it was called or not based on a checkbox
});
});
});

View file

@ -0,0 +1,410 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import M3UFilter from '../M3UFilter';
// Store mocks
vi.mock('../../../store/playlists', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils/forms/M3uFilterUtils.js', () => ({
addM3UFilter: vi.fn(),
updateM3UFilter: vi.fn(),
}));
vi.mock('../../../utils', () => ({
setCustomProperty: vi.fn((obj, key, value) => ({ ...obj, [key]: value })),
}));
// Constants mock
vi.mock('../../../constants', () => ({
M3U_FILTER_TYPES: [
{ value: 'include', label: 'Include' },
{ value: 'exclude', label: 'Exclude' },
],
}));
// Mantine form
vi.mock('@mantine/form', () => {
let _values = null;
return {
isNotEmpty: vi.fn(() => (val) => (val ? null : 'Required')),
__resetFormState: () => {
_values = null;
},
useForm: vi.fn(({ initialValues = {} } = {}) => {
if (_values === null) {
_values = { ...initialValues };
}
return {
key: vi.fn((field) => field),
getValues: () => ({ ..._values }),
setValues: (v) => {
Object.assign(_values, v);
},
setFieldValue: (field, val) => {
_values[field] = val;
},
reset: () => {
_values = { ...initialValues };
},
submitting: false,
onSubmit: vi.fn((handler) => (e) => {
e?.preventDefault?.();
handler();
}),
getInputProps: vi.fn((field, options = {}) => {
const isCheckbox = options?.type === 'checkbox';
return {
...(isCheckbox
? { checked: !!_values?.[field] }
: { value: _values?.[field] ?? '' }),
onChange: vi.fn((e) => {
const val = isCheckbox
? (e?.target?.checked ?? e)
: (e?.target?.value ?? e);
if (_values) _values[field] = val;
}),
error: null,
};
}),
};
}),
};
});
// Mantine core
vi.mock('@mantine/core', () => ({
Box: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, type, loading, disabled, variant, color }) => (
<button
type={type || 'button'}
onClick={onClick}
disabled={disabled || loading}
data-variant={variant}
data-color={color}
data-loading={String(loading)}
>
{children}
</button>
),
Flex: ({ children }) => <div>{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,
Select: ({ label, placeholder, value, onChange, data, error, disabled }) => (
<div>
<label>
{label}
<select
aria-label={label}
value={value ?? ''}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
>
<option value="">{placeholder}</option>
{(data ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
{error && <span data-testid="select-error">{error}</span>}
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Switch: ({ id, label, checked, onChange, disabled }) => (
<label>
<input
data-testid={id}
type="checkbox"
aria-label={label}
checked={!!checked}
onChange={(e) => onChange?.(e)}
disabled={disabled}
/>
{label}
</label>
),
TextInput: ({
label,
placeholder,
value,
onChange,
onBlur,
error,
disabled,
}) => (
<div>
<label>
{label}
<input
aria-label={label}
placeholder={placeholder}
value={value ?? ''}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
/>
</label>
{error && <span data-testid="input-error">{error}</span>}
</div>
),
}));
//
// Imports after mocks
//
import usePlaylistsStore from '../../../store/playlists';
import * as M3uFilterUtils from '../../../utils/forms/M3uFilterUtils.js';
import * as mantineForm from '@mantine/form';
// Helpers
const makeM3U = (overrides = {}) => ({
id: 1,
name: 'Test Playlist',
custom_properties: {},
filters: [],
...overrides,
});
const makeFilter = (overrides = {}) => ({
id: 10,
filter_type: 'include',
regex_pattern: 'HBO.*',
exclude: false,
custom_properties: { case_sensitive: false },
...overrides,
});
const defaultProps = (overrides = {}) => ({
filter: null,
m3u: makeM3U(),
isOpen: true,
onClose: vi.fn(),
...overrides,
});
const setupStores = ({
fetchPlaylist = vi.fn().mockResolvedValue(undefined),
} = {}) => {
vi.mocked(usePlaylistsStore).mockImplementation((sel) =>
sel({ fetchPlaylist })
);
return { fetchPlaylist };
};
//
describe('M3UFilter', () => {
beforeEach(() => {
vi.clearAllMocks();
mantineForm.__resetFormState();
vi.mocked(M3uFilterUtils.addM3UFilter).mockResolvedValue({ id: 99 });
vi.mocked(M3uFilterUtils.updateM3UFilter).mockResolvedValue({});
});
// Rendering
describe('rendering', () => {
it('renders the modal when isOpen is true', () => {
setupStores();
render(<M3UFilter {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render when isOpen is false', () => {
setupStores();
render(<M3UFilter {...defaultProps({ isOpen: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('renders modal title as "Filter"', () => {
setupStores();
render(<M3UFilter {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Filter');
});
it('renders filter type Select', () => {
setupStores();
render(<M3UFilter {...defaultProps()} />);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('renders filter type options from M3U_FILTER_TYPES', () => {
setupStores();
render(<M3UFilter {...defaultProps()} />);
expect(
screen.getByRole('option', { name: 'Include' })
).toBeInTheDocument();
expect(
screen.getByRole('option', { name: 'Exclude' })
).toBeInTheDocument();
});
it('renders a text input for the pattern', () => {
setupStores();
render(<M3UFilter {...defaultProps()} />);
expect(screen.getByLabelText('Regex Pattern')).toBeInTheDocument();
});
it('renders the exclusion switch', () => {
setupStores();
render(<M3UFilter {...defaultProps()} />);
expect(screen.getByTestId('exclude')).toBeInTheDocument();
});
it('renders the case sensitivity switch', () => {
setupStores();
render(<M3UFilter {...defaultProps()} />);
expect(screen.getByTestId('case_sensitive')).toBeInTheDocument();
});
it('renders a submit button', () => {
setupStores();
render(<M3UFilter {...defaultProps()} />);
expect(
screen.getByRole('button', { name: /add|save|submit/i })
).toBeInTheDocument();
});
});
// Form reset on open/filter change
describe('form reset behaviour', () => {
it('shows empty pattern field for a new filter', () => {
setupStores();
render(<M3UFilter {...defaultProps({ filter: null })} />);
expect(screen.getByRole('textbox')).toHaveValue('');
});
});
// Cancel behaviour
describe('cancel behaviour', () => {
it('calls onClose when modal X is clicked', () => {
const onClose = vi.fn();
setupStores();
render(<M3UFilter {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Adding a filter
describe('adding a new filter', () => {
const fillForm = () => {
fireEvent.change(screen.getByRole('combobox'), {
target: { value: 'include' },
});
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'Sports.*' },
});
};
it('calls addM3UFilter on submit for a new filter', async () => {
setupStores();
render(<M3UFilter {...defaultProps({ filter: null })} />);
fillForm();
fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i }));
await waitFor(() => {
expect(M3uFilterUtils.addM3UFilter).toHaveBeenCalled();
});
});
it('does not call updateM3UFilter when adding', async () => {
setupStores();
render(<M3UFilter {...defaultProps({ filter: null })} />);
fillForm();
fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i }));
await waitFor(() => {
expect(M3uFilterUtils.addM3UFilter).toHaveBeenCalled();
});
expect(M3uFilterUtils.updateM3UFilter).not.toHaveBeenCalled();
});
it('calls fetchPlaylist after successful add', async () => {
const { fetchPlaylist } = setupStores();
render(<M3UFilter {...defaultProps({ filter: null })} />);
fillForm();
fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i }));
await waitFor(() => {
expect(fetchPlaylist).toHaveBeenCalledWith(makeM3U().id);
});
});
it('calls onClose after successful add', async () => {
const onClose = vi.fn();
setupStores();
render(<M3UFilter {...defaultProps({ filter: null, onClose })} />);
fillForm();
fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i }));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
// Updating a filter
describe('updating an existing filter', () => {
it('calls updateM3UFilter on submit for an existing filter', async () => {
setupStores();
render(<M3UFilter {...defaultProps({ filter: makeFilter() })} />);
fireEvent.click(
screen.getByRole('button', { name: /add|save|update|submit/i })
);
await waitFor(() => {
expect(M3uFilterUtils.updateM3UFilter).toHaveBeenCalled();
});
});
it('does not call addM3UFilter when updating', async () => {
setupStores();
render(<M3UFilter {...defaultProps({ filter: makeFilter() })} />);
fireEvent.click(
screen.getByRole('button', { name: /add|save|update|submit/i })
);
await waitFor(() => {
expect(M3uFilterUtils.updateM3UFilter).toHaveBeenCalled();
});
expect(M3uFilterUtils.addM3UFilter).not.toHaveBeenCalled();
});
it('calls fetchPlaylist after successful update', async () => {
const { fetchPlaylist } = setupStores();
render(<M3UFilter {...defaultProps({ filter: makeFilter() })} />);
fireEvent.click(
screen.getByRole('button', { name: /add|save|update|submit/i })
);
await waitFor(() => {
expect(fetchPlaylist).toHaveBeenCalledWith(makeM3U().id);
});
});
it('calls onClose after successful update', async () => {
const onClose = vi.fn();
setupStores();
render(
<M3UFilter {...defaultProps({ filter: makeFilter(), onClose })} />
);
fireEvent.click(
screen.getByRole('button', { name: /add|save|update|submit/i })
);
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
});

View file

@ -0,0 +1,594 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import M3UFilters from '../M3UFilters';
// Store mocks
vi.mock('../../../store/playlists', () => ({ default: vi.fn() }));
vi.mock('../../../store/warnings', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils/forms/M3uFilterUtils.js', () => ({
deleteM3UFilter: vi.fn(),
updateM3UFilter: vi.fn(),
}));
// Constants mock
vi.mock('../../../constants', () => ({
M3U_FILTER_TYPES: [
{ value: 'include', label: 'Include' },
{ value: 'exclude', label: 'Exclude' },
],
}));
// Sub-component mocks
vi.mock('../M3UFilter', () => ({
default: ({ isOpen, onClose, filter, m3u }) =>
isOpen ? (
<div data-testid="m3u-filter-editor">
<div data-testid="editor-filter-id">{filter?.id ?? 'new'}</div>
<button data-testid="editor-close" onClick={() => onClose()}>
Close Editor
</button>
<button
data-testid="editor-close-with-playlist"
onClick={() => onClose({ id: m3u?.id, filters: [] })}
>
Close With Playlist
</button>
</div>
) : null,
}));
vi.mock('../../ConfirmationDialog', () => ({
default: ({ opened, onConfirm, onClose, title }) =>
opened ? (
<div data-testid="confirmation-dialog">
<div data-testid="confirm-title">{title}</div>
<button data-testid="confirm-yes" onClick={onConfirm}>
Yes
</button>
<button data-testid="confirm-no" onClick={onClose}>
No
</button>
</div>
) : null,
}));
// DnD kit mocks
vi.mock('@dnd-kit/core', () => ({
closestCenter: vi.fn(),
DndContext: vi.fn(({ children, onDragEnd }) => (
<div data-testid="dnd-context" data-ondragend={!!onDragEnd}>
{children}
</div>
)),
KeyboardSensor: vi.fn(),
MouseSensor: vi.fn(),
TouchSensor: vi.fn(),
useDraggable: () => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
}),
useSensor: vi.fn((sensor) => sensor),
useSensors: vi.fn((...sensors) => sensors),
}));
vi.mock('@dnd-kit/sortable', () => ({
arrayMove: vi.fn((arr, from, to) => {
const result = [...arr];
const [item] = result.splice(from, 1);
result.splice(to, 0, item);
return result;
}),
SortableContext: ({ children }) => (
<div data-testid="sortable-context">{children}</div>
),
useSortable: () => ({
transform: null,
transition: null,
setNodeRef: vi.fn(),
isDragging: false,
}),
verticalListSortingStrategy: vi.fn(),
}));
vi.mock('@dnd-kit/utilities', () => ({
CSS: { Transform: { toString: vi.fn(() => '') } },
}));
vi.mock('@dnd-kit/modifiers', () => ({
restrictToVerticalAxis: vi.fn(),
}));
// lucide-react mocks
vi.mock('lucide-react', () => ({
GripHorizontal: () => <svg data-testid="icon-grip" />,
Info: () => <svg data-testid="icon-info" />,
SquareMinus: () => <svg data-testid="icon-square-minus" />,
SquarePen: () => <svg data-testid="icon-square-pen" />,
}));
// Mantine core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, disabled, color }) => (
<button
data-testid="action-icon"
data-color={color}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
),
Alert: ({ children, title }) => (
<div data-testid="alert">
<div data-testid="alert-title">{title}</div>
{children}
</div>
),
Box: ({ children, ref }) => <div ref={ref}>{children}</div>,
Button: ({ children, onClick, disabled, loading, variant, color }) => (
<button
onClick={onClick}
disabled={disabled || loading}
data-loading={loading}
data-variant={variant}
data-color={color}
>
{children}
</button>
),
Center: ({ children }) => <div>{children}</div>,
Flex: ({ children }) => <div>{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,
Text: ({ children, size, c, fw }) => (
<span data-size={size} data-color={c} data-fw={fw}>
{children}
</span>
),
useMantineTheme: () => ({
tailwind: {
red: { 6: '#f56565' },
green: { 5: '#48bb78' },
yellow: { 3: '#ecc94b' },
},
}),
}));
//
// Imports after mocks
//
import usePlaylistsStore from '../../../store/playlists';
import useWarningsStore from '../../../store/warnings';
import * as M3uFilterUtils from '../../../utils/forms/M3uFilterUtils.js';
// Helpers
const makeFilter = (overrides = {}) => ({
id: 1,
filter_type: 'include',
regex_pattern: 'HBO.*',
is_active: true,
exclude: false,
order: 0,
...overrides,
});
const makePlaylist = (overrides = {}) => ({
id: 10,
name: 'Test Playlist',
filters: [
makeFilter({ id: 1, regex_pattern: 'HBO.*', order: 0 }),
makeFilter({
id: 2,
filter_type: 'exclude',
regex_pattern: 'ESPN.*',
order: 1,
}),
],
...overrides,
});
const defaultProps = (overrides = {}) => ({
playlist: makePlaylist(),
isOpen: true,
onClose: vi.fn(),
...overrides,
});
const setupStores = ({
fetchPlaylist = vi.fn().mockResolvedValue(undefined),
isWarningSuppressed = vi.fn().mockReturnValue(false),
suppressWarning = vi.fn(),
} = {}) => {
vi.mocked(usePlaylistsStore).mockImplementation((sel) =>
sel({ fetchPlaylist })
);
vi.mocked(useWarningsStore).mockImplementation((sel) =>
sel({ isWarningSuppressed, suppressWarning })
);
return { fetchPlaylist, isWarningSuppressed, suppressWarning };
};
//
describe('M3UFilters', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(M3uFilterUtils.deleteM3UFilter).mockResolvedValue(undefined);
vi.mocked(M3uFilterUtils.updateM3UFilter).mockResolvedValue(undefined);
});
// Guard conditions
describe('guard conditions', () => {
it('does not render modal when isOpen is false', () => {
setupStores();
render(<M3UFilters {...defaultProps({ isOpen: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('does not render modal when playlist is null', () => {
setupStores();
render(<M3UFilters {...defaultProps({ playlist: {} })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('does not render modal when playlist has no id', () => {
setupStores();
render(
<M3UFilters
{...defaultProps({ playlist: { ...makePlaylist(), id: undefined } })}
/>
);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
});
// Rendering
describe('rendering', () => {
it('renders the modal when isOpen is true with a valid playlist', () => {
setupStores();
render(<M3UFilters {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('renders the modal title', () => {
setupStores();
render(<M3UFilters {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toBeInTheDocument();
});
it('renders an "Add Filter" button', () => {
setupStores();
render(<M3UFilters {...defaultProps()} />);
expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument();
});
it('renders filter patterns from playlist', () => {
setupStores();
render(<M3UFilters {...defaultProps()} />);
expect(screen.getByText('HBO.*')).toBeInTheDocument();
expect(screen.getByText('ESPN.*')).toBeInTheDocument();
});
it('renders filter type labels for each filter', () => {
setupStores();
render(<M3UFilters {...defaultProps()} />);
expect(screen.getAllByText('Include').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('Exclude').length).toBeGreaterThanOrEqual(1);
});
it('renders edit action icons for each filter', () => {
setupStores();
render(<M3UFilters {...defaultProps()} />);
const penIcons = screen.getAllByTestId('icon-square-pen');
expect(penIcons).toHaveLength(2);
});
it('renders delete action icons for each filter', () => {
setupStores();
render(<M3UFilters {...defaultProps()} />);
const minusIcons = screen.getAllByTestId('icon-square-minus');
expect(minusIcons).toHaveLength(2);
});
it('renders drag handle icons for each filter', () => {
setupStores();
render(<M3UFilters {...defaultProps()} />);
const gripIcons = screen.getAllByTestId('icon-grip');
expect(gripIcons).toHaveLength(2);
});
it('renders empty state when playlist has no filters', () => {
setupStores();
const playlist = makePlaylist({
filters: [],
});
render(<M3UFilters {...defaultProps({ playlist })} />);
expect(screen.queryByTestId('icon-square-pen')).not.toBeInTheDocument();
});
it('wraps list in DndContext', () => {
setupStores();
render(<M3UFilters {...defaultProps()} />);
expect(screen.getByTestId('dnd-context')).toBeInTheDocument();
});
it('wraps list in SortableContext', () => {
setupStores();
render(<M3UFilters {...defaultProps()} />);
expect(screen.getByTestId('sortable-context')).toBeInTheDocument();
});
});
// Close / cancel
describe('close behaviour', () => {
it('calls onClose when modal X is clicked', () => {
const onClose = vi.fn();
setupStores();
render(<M3UFilters {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Opening the filter editor
describe('opening the filter editor', () => {
it('opens M3UFilter editor when Add Filter is clicked', () => {
setupStores();
render(<M3UFilters {...defaultProps()} />);
fireEvent.click(screen.getByRole('button', { name: /new/i }));
expect(screen.getByTestId('m3u-filter-editor')).toBeInTheDocument();
});
it('passes null filter to editor when adding a new filter', () => {
setupStores();
render(<M3UFilters {...defaultProps()} />);
fireEvent.click(screen.getByRole('button', { name: /new/i }));
expect(screen.getByTestId('editor-filter-id')).toHaveTextContent('new');
});
it('opens editor with the correct filter when edit icon is clicked', () => {
setupStores();
render(<M3UFilters {...defaultProps()} />);
const editButtons = screen
.getAllByTestId('icon-square-pen')
.map((icon) => icon.closest('button'));
fireEvent.click(editButtons[0]);
expect(screen.getByTestId('m3u-filter-editor')).toBeInTheDocument();
expect(screen.getByTestId('editor-filter-id')).toHaveTextContent('1');
});
it('closes editor when editor fires onClose without updated playlist', () => {
setupStores();
render(<M3UFilters {...defaultProps()} />);
fireEvent.click(screen.getByRole('button', { name: /new/i }));
fireEvent.click(screen.getByTestId('editor-close'));
expect(screen.queryByTestId('m3u-filter-editor')).not.toBeInTheDocument();
});
it('closes editor and refreshes filters when editor fires onClose with playlist', async () => {
setupStores();
render(<M3UFilters {...defaultProps()} />);
fireEvent.click(screen.getByRole('button', { name: /new/i }));
fireEvent.click(screen.getByTestId('editor-close-with-playlist'));
expect(screen.queryByTestId('m3u-filter-editor')).not.toBeInTheDocument();
});
});
// Delete filter
describe('deleting a filter', () => {
const clickDeleteFirst = () => {
const deleteButtons = screen
.getAllByTestId('icon-square-minus')
.map((icon) => icon.closest('button'));
fireEvent.click(deleteButtons[0]);
};
it('opens confirmation dialog when delete icon is clicked (warning not suppressed)', () => {
setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
render(<M3UFilters {...defaultProps()} />);
clickDeleteFirst();
expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
});
it('calls deleteM3UFilter directly when warning is suppressed', async () => {
setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(true) });
render(<M3UFilters {...defaultProps()} />);
clickDeleteFirst();
await waitFor(() => {
expect(M3uFilterUtils.deleteM3UFilter).toHaveBeenCalled();
});
});
it('does not open confirmation dialog when warning is suppressed', async () => {
setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(true) });
render(<M3UFilters {...defaultProps()} />);
clickDeleteFirst();
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
it('calls deleteM3UFilter after confirming deletion', async () => {
setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
render(<M3UFilters {...defaultProps()} />);
clickDeleteFirst();
fireEvent.click(screen.getByTestId('confirm-yes'));
await waitFor(() => {
expect(M3uFilterUtils.deleteM3UFilter).toHaveBeenCalled();
});
});
it('calls fetchPlaylist after successful deletion', async () => {
const { fetchPlaylist } = setupStores({
isWarningSuppressed: vi.fn().mockReturnValue(false),
});
render(<M3UFilters {...defaultProps()} />);
clickDeleteFirst();
fireEvent.click(screen.getByTestId('confirm-yes'));
await waitFor(() => {
expect(fetchPlaylist).toHaveBeenCalledWith(10);
});
});
it('closes confirmation dialog after confirming deletion', async () => {
setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
render(<M3UFilters {...defaultProps()} />);
clickDeleteFirst();
fireEvent.click(screen.getByTestId('confirm-yes'));
await waitFor(() => {
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
});
it('closes confirmation dialog when No is clicked', () => {
setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
render(<M3UFilters {...defaultProps()} />);
clickDeleteFirst();
fireEvent.click(screen.getByTestId('confirm-no'));
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
it('does not call deleteM3UFilter when No is clicked', () => {
setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
render(<M3UFilters {...defaultProps()} />);
clickDeleteFirst();
fireEvent.click(screen.getByTestId('confirm-no'));
expect(M3uFilterUtils.deleteM3UFilter).not.toHaveBeenCalled();
});
it('does not call fetchPlaylist when deleteM3UFilter throws', async () => {
vi.mocked(M3uFilterUtils.deleteM3UFilter).mockRejectedValue(
new Error('fail')
);
const { fetchPlaylist } = setupStores({
isWarningSuppressed: vi.fn().mockReturnValue(false),
});
render(<M3UFilters {...defaultProps()} />);
clickDeleteFirst();
fireEvent.click(screen.getByTestId('confirm-yes'));
await waitFor(() => {
expect(M3uFilterUtils.deleteM3UFilter).toHaveBeenCalled();
});
expect(fetchPlaylist).not.toHaveBeenCalled();
});
});
// Initialization from playlist prop
describe('filter initialization', () => {
it('loads filters from playlist.custom_properties.filters on mount', () => {
setupStores();
render(<M3UFilters {...defaultProps()} />);
expect(screen.getByText('HBO.*')).toBeInTheDocument();
expect(screen.getByText('ESPN.*')).toBeInTheDocument();
});
it('updates displayed filters when playlist prop changes', () => {
setupStores();
const { rerender } = render(<M3UFilters {...defaultProps()} />);
const updatedPlaylist = makePlaylist({
filters: [makeFilter({ id: 3, regex_pattern: 'CNN.*', order: 0 })],
});
rerender(<M3UFilters {...defaultProps({ playlist: updatedPlaylist })} />);
expect(screen.getByText('CNN.*')).toBeInTheDocument();
expect(screen.queryByText('HBO.*')).not.toBeInTheDocument();
});
});
// Drag and drop reordering
describe('drag and drop', () => {
it('calls updateM3UFilter for reordered filters after drag end', async () => {
setupStores();
// We need to trigger handleDragEnd manually via the DndContext mock.
// Re-mock DndContext to capture and expose onDragEnd.
let capturedOnDragEnd;
const { DndContext } = await import('@dnd-kit/core');
vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => {
capturedOnDragEnd = onDragEnd;
return <div data-testid="dnd-context">{children}</div>;
});
render(<M3UFilters {...defaultProps()} />);
// Simulate drag: move filter id=1 over filter id=2
await waitFor(() => expect(capturedOnDragEnd).toBeDefined());
capturedOnDragEnd({ active: { id: 1 }, over: { id: 2 } });
await waitFor(() => {
expect(M3uFilterUtils.updateM3UFilter).toHaveBeenCalled();
});
});
it('does not call updateM3UFilter when drag ends on same position', async () => {
setupStores();
let capturedOnDragEnd;
const { DndContext } = await import('@dnd-kit/core');
vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => {
capturedOnDragEnd = onDragEnd;
return <div data-testid="dnd-context">{children}</div>;
});
render(<M3UFilters {...defaultProps()} />);
await waitFor(() => expect(capturedOnDragEnd).toBeDefined());
capturedOnDragEnd({ active: { id: 1 }, over: { id: 1 } });
await waitFor(() => {
expect(M3uFilterUtils.updateM3UFilter).not.toHaveBeenCalled();
});
});
it('does not call updateM3UFilter when there is no over target', async () => {
setupStores();
let capturedOnDragEnd;
const { DndContext } = await import('@dnd-kit/core');
vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => {
capturedOnDragEnd = onDragEnd;
return <div data-testid="dnd-context">{children}</div>;
});
render(<M3UFilters {...defaultProps()} />);
await waitFor(() => expect(capturedOnDragEnd).toBeDefined());
capturedOnDragEnd({ active: { id: 1 }, over: null });
expect(M3uFilterUtils.updateM3UFilter).not.toHaveBeenCalled();
});
});
// Warning suppression
describe('warning suppression', () => {
it('calls suppressWarning when user confirms and opts to suppress', async () => {
// This depends on ConfirmationDialog exposing a "suppress" callback.
// Here we verify suppressWarning is available from the store.
const { suppressWarning } = setupStores({
isWarningSuppressed: vi.fn().mockReturnValue(false),
});
render(<M3UFilters {...defaultProps()} />);
expect(suppressWarning).toBeDefined();
});
});
});

View file

@ -0,0 +1,441 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import M3UGroupFilter from '../M3UGroupFilter';
// Store mocks
vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils/notificationUtils.js', () => ({
showNotification: vi.fn(),
}));
vi.mock('../../../utils/forms/M3uGroupFilterUtils.js', () => ({
saveAndRefreshPlaylist: vi.fn(),
buildGroupStates: vi.fn(),
}));
// Sub-component mocks
vi.mock('../LiveGroupFilter', () => ({
default: ({
groupStates,
setGroupStates,
autoEnableNewGroupsLive,
setAutoEnableNewGroupsLive,
}) => (
<div data-testid="live-group-filter">
<span data-testid="live-group-count">{groupStates?.length ?? 0}</span>
<button
data-testid="live-toggle-auto"
onClick={() => setAutoEnableNewGroupsLive?.(!autoEnableNewGroupsLive)}
>
Toggle Auto Live
</button>
<button
data-testid="live-change-groups"
onClick={() => setGroupStates?.([{ id: 99, enabled: true }])}
>
Change Groups
</button>
</div>
),
}));
vi.mock('../VODCategoryFilter', () => ({
default: ({
categoryStates,
setCategoryStates,
autoEnableNewGroups,
setAutoEnableNewGroups,
type,
}) => (
<div data-testid={`vod-category-filter-${type}`}>
<span data-testid={`${type}-category-count`}>
{categoryStates?.length ?? 0}
</span>
<button
data-testid={`vod-toggle-auto-${type}`}
onClick={() => setAutoEnableNewGroups?.(!autoEnableNewGroups)}
>
Toggle Auto {type}
</button>
<button
data-testid={`vod-change-${type}`}
onClick={() => setCategoryStates?.([{ id: 55, enabled: true }])}
>
Change {type}
</button>
</div>
),
}));
// Mantine core
vi.mock('@mantine/core', () => ({
Button: ({ children, onClick, loading, disabled, variant, color }) => (
<button
onClick={onClick}
disabled={disabled || loading}
data-loading={loading}
data-variant={variant}
data-color={color}
>
{children}
</button>
),
Flex: ({ children }) => <div>{children}</div>,
LoadingOverlay: ({ visible }) =>
visible ? <div data-testid="loading-overlay" /> : null,
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>,
Tabs: ({ children, defaultValue, value }) => (
<div data-testid="tabs" data-value={value ?? defaultValue}>
{children}
</div>
),
TabsList: ({ children }) => <div data-testid="tabs-list">{children}</div>,
TabsPanel: ({ children, value }) => (
<div data-testid={`tab-panel-${value}`}>{children}</div>
),
TabsTab: ({ children, value, onClick }) => (
<button data-testid={`tab-${value}`} onClick={onClick}>
{children}
</button>
),
}));
//
// Imports after mocks
//
import useChannelsStore from '../../../store/channels';
import useVODStore from '../../../store/useVODStore';
import { showNotification } from '../../../utils/notificationUtils.js';
import * as M3uGroupFilterUtils from '../../../utils/forms/M3uGroupFilterUtils.js';
// Helpers
const makePlaylist = (overrides = {}) => ({
id: 1,
name: 'Test Playlist',
account_type: 'XC',
enable_vod: true,
...overrides,
});
const makeGroup = (overrides = {}) => ({
id: 1,
name: 'Group A',
playlist_id: 1,
...overrides,
});
const defaultProps = (overrides = {}) => ({
playlist: makePlaylist(),
isOpen: true,
onClose: vi.fn(),
...overrides,
});
const setupStores = ({
channelGroups = [makeGroup(), makeGroup({ id: 2, name: 'Group B' })],
fetchCategories = vi.fn().mockResolvedValue(undefined),
} = {}) => {
vi.mocked(useChannelsStore).mockImplementation((sel) =>
sel({ channelGroups })
);
vi.mocked(useVODStore).mockImplementation((sel) => sel({ fetchCategories }));
return { channelGroups, fetchCategories };
};
//
describe('M3UGroupFilter', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockResolvedValue(
undefined
);
vi.mocked(M3uGroupFilterUtils.buildGroupStates).mockReturnValue([]);
});
// Guard conditions
describe('guard conditions', () => {
it('does not render modal when isOpen is false', () => {
setupStores();
render(<M3UGroupFilter {...defaultProps({ isOpen: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
});
// Rendering
describe('rendering', () => {
it('renders the modal when isOpen is true with a valid playlist', () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('renders the modal title', () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toBeInTheDocument();
});
it('renders tab list with Live and VOD tabs', () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
expect(screen.getByTestId('tabs-list')).toBeInTheDocument();
expect(screen.getByTestId('tab-live')).toBeInTheDocument();
expect(screen.getByTestId('tab-vod-movie')).toBeInTheDocument();
expect(screen.getByTestId('tab-vod-series')).toBeInTheDocument();
});
it('renders LiveGroupFilter panel', () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
expect(screen.getByTestId('live-group-filter')).toBeInTheDocument();
});
it('renders VODCategoryFilter panels', () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
expect(
screen.getByTestId('vod-category-filter-movie')
).toBeInTheDocument();
expect(
screen.getByTestId('vod-category-filter-series')
).toBeInTheDocument();
});
it('renders a Save button', () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('renders a Cancel button', () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
expect(
screen.getByRole('button', { name: /cancel/i })
).toBeInTheDocument();
});
});
// Initialization
describe('initialization', () => {
it('calls buildGroupStates with channelGroups and playlist on mount', async () => {
const { channelGroups } = setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
await waitFor(() => {
expect(M3uGroupFilterUtils.buildGroupStates).toHaveBeenCalledWith(
channelGroups,
undefined
);
});
});
it('calls fetchCategories on mount', async () => {
const { fetchCategories } = setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
await waitFor(() => {
expect(fetchCategories).toHaveBeenCalled();
});
});
it('re-initializes when playlist prop changes', async () => {
setupStores();
const { rerender } = render(<M3UGroupFilter {...defaultProps()} />);
const updatedPlaylist = makePlaylist({
id: 2,
name: 'Updated Playlist',
channel_groups: [{ id: 3, name: 'Group C', playlist_id: 2 }],
});
rerender(
<M3UGroupFilter {...defaultProps({ playlist: updatedPlaylist })} />
);
await waitFor(() => {
expect(M3uGroupFilterUtils.buildGroupStates).toHaveBeenCalledWith(
expect.anything(),
updatedPlaylist.channel_groups
);
});
});
});
// Close / cancel behaviour
describe('close / cancel behaviour', () => {
it('calls onClose when modal X is clicked', () => {
const onClose = vi.fn();
setupStores();
render(<M3UGroupFilter {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
it('calls onClose when Cancel button is clicked', () => {
const onClose = vi.fn();
setupStores();
render(<M3UGroupFilter {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onClose).toHaveBeenCalled();
});
});
// LiveGroupFilter interaction
describe('LiveGroupFilter interaction', () => {
it('updates groupStates when LiveGroupFilter fires onGroupStatesChange', async () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
await waitFor(() => screen.getByTestId('live-group-filter'));
fireEvent.click(screen.getByTestId('live-change-groups'));
expect(screen.getByTestId('live-group-count')).toHaveTextContent('1');
});
it('toggles autoEnableNewGroupsLive when LiveGroupFilter fires onAutoEnableChange', async () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
await waitFor(() => screen.getByTestId('live-group-filter'));
// Toggle twice to verify it actually flips
fireEvent.click(screen.getByTestId('live-toggle-auto'));
fireEvent.click(screen.getByTestId('live-toggle-auto'));
// No crash = state updated correctly
});
});
// VODCategoryFilter interaction
describe('VODCategoryFilter interaction', () => {
it('updates movieCategoryStates when movie VODCategoryFilter fires setCategoryStates', async () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
fireEvent.click(screen.getByTestId('vod-change-movie'));
expect(screen.getByTestId('movie-category-count')).toHaveTextContent('1');
});
it('updates seriesCategoryStates when series VODCategoryFilter fires setCategoryStates', async () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
fireEvent.click(screen.getByTestId('vod-change-series'));
expect(screen.getByTestId('series-category-count')).toHaveTextContent(
'1'
);
});
it('toggles autoEnableNewGroupsVod when movie VODCategoryFilter fires setAutoEnableNewGroups', () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
fireEvent.click(screen.getByTestId('vod-toggle-auto-movie'));
fireEvent.click(screen.getByTestId('vod-toggle-auto-movie'));
});
it('toggles autoEnableNewGroupsSeries when series VODCategoryFilter fires setAutoEnableNewGroups', () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
fireEvent.click(screen.getByTestId('vod-toggle-auto-series'));
fireEvent.click(screen.getByTestId('vod-toggle-auto-series'));
});
});
// Save
describe('saving', () => {
it('calls saveAndRefreshPlaylist with playlist and current states on Save click', async () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
await waitFor(() => screen.getByRole('button', { name: /save/i }));
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(M3uGroupFilterUtils.saveAndRefreshPlaylist).toHaveBeenCalledWith(
expect.objectContaining({ id: 1 }),
expect.any(Array),
expect.any(Array),
expect.any(Array),
expect.objectContaining({
auto_enable_new_groups_live: true,
auto_enable_new_groups_vod: true,
auto_enable_new_groups_series: true,
})
);
});
});
it('calls onClose after successful save', async () => {
const onClose = vi.fn();
setupStores();
render(<M3UGroupFilter {...defaultProps({ onClose })} />);
await waitFor(() => screen.getByRole('button', { name: /save/i }));
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('shows success notification after saving', async () => {
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
await waitFor(() => screen.getByRole('button', { name: /save/i }));
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
color: expect.stringMatching(/green|teal/),
})
);
});
});
it('does not call onClose when saveAndRefreshPlaylist throws', async () => {
vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockRejectedValue(
new Error('save failed')
);
const onClose = vi.fn();
setupStores();
render(<M3UGroupFilter {...defaultProps({ onClose })} />);
await waitFor(() => screen.getByRole('button', { name: /save/i }));
fireEvent.click(screen.getByRole('button', { name: /save/i }));
expect(onClose).not.toHaveBeenCalled();
});
});
// Loading state
describe('loading state', () => {
it('disables Save button while submitting', async () => {
let resolveSave;
vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockImplementation(
() =>
new Promise((res) => {
resolveSave = res;
})
);
setupStores();
render(<M3UGroupFilter {...defaultProps()} />);
await waitFor(() => screen.getByRole('button', { name: /save/i }));
const saveBtn = screen.getByRole('button', { name: /save/i });
fireEvent.click(saveBtn);
await waitFor(() => {
expect(saveBtn.disabled || saveBtn.dataset.loading === 'true').toBe(
true
);
});
resolveSave();
});
});
});

View file

@ -0,0 +1,824 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import M3UProfile from '../M3UProfile';
// WebSocket mock
vi.mock('../../../WebSocket', () => ({
useWebSocket: vi.fn(),
}));
// Utility mocks
vi.mock('../../../utils/forms/M3uProfileUtils.js', () => ({
addM3UProfile: vi.fn(),
applyRegex: vi.fn(),
applyXcSimplePatterns: vi.fn(),
buildProfileSchema: vi.fn(),
buildSubmitValues: vi.fn(),
fetchFirstStreamUrl: vi.fn(),
getDetectedMode: vi.fn(),
prepareExpDate: vi.fn(),
splitByPattern: vi.fn(),
updateM3UProfile: vi.fn(),
validateXcSimple: vi.fn(),
}));
// react-hook-form mock
vi.mock('react-hook-form', async () => {
const actual = await vi.importActual('react-hook-form');
return { ...actual, useForm: vi.fn() };
});
// @hookform/resolvers/yup mock
vi.mock('@hookform/resolvers/yup', () => ({
yupResolver: vi.fn(() => vi.fn()),
}));
// @mantine/dates mock
vi.mock('@mantine/dates', () => ({
DateTimePicker: ({ label, value, onChange, disabled, placeholder }) => (
<div data-testid="date-time-picker">
<label>{label}</label>
<input
data-testid="date-time-input"
value={value ?? ''}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
placeholder={placeholder}
/>
</div>
),
}));
// @mantine/core mock
vi.mock('@mantine/core', () => ({
Alert: ({ children, title }) => (
<div data-testid="alert">
<span data-testid="alert-title">{title}</span>
{children}
</div>
),
Badge: ({ children, color }) => (
<span data-testid="badge" data-color={color}>
{children}
</span>
),
Button: ({ children, onClick, disabled, loading, variant, color, type }) => (
<button
type={type ?? 'button'}
onClick={onClick}
disabled={disabled || loading}
data-loading={loading}
data-variant={variant}
data-color={color}
>
{children}
</button>
),
Flex: ({ children }) => <div>{children}</div>,
Grid: ({ children }) => <div data-testid="grid">{children}</div>,
GridCol: ({ children, span }) => (
<div data-testid="grid-col" data-span={span}>
{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,
NumberInput: ({
label,
value,
onChange,
disabled,
min,
max,
placeholder,
}) => (
<div>
<label>{label}</label>
<input
data-testid={`number-input-${label?.toLowerCase?.().replace(/\s+/g, '-') ?? 'number'}`}
type="number"
value={value ?? ''}
onChange={(e) => onChange?.(Number(e.target.value))}
disabled={disabled}
min={min}
max={max}
placeholder={placeholder}
/>
</div>
),
Paper: ({ children }) => <div data-testid="paper">{children}</div>,
SegmentedControl: ({ value, onChange, data, disabled }) => (
<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)}
disabled={disabled}
>
{item.label ?? item}
</button>
))}
</div>
),
Text: ({ children, size, c, fw }) => (
<span data-size={size} data-color={c} data-fw={fw}>
{children}
</span>
),
Textarea: ({ label, value, onChange, disabled, placeholder, error }) => (
<div>
<label>{label}</label>
<textarea
data-testid={`textarea-${label?.toLowerCase?.().replace(/\s+/g, '-') ?? 'textarea'}`}
value={value ?? ''}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
placeholder={placeholder}
/>
{error && <span data-testid="field-error">{error}</span>}
</div>
),
TextInput: ({ label, value, onChange, disabled, placeholder, error }) => (
<div>
<label>{label}</label>
<input
data-testid={`text-input-${label?.toLowerCase?.().replace(/\s+/g, '-') ?? 'text'}`}
value={value ?? ''}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
placeholder={placeholder}
/>
{error && <span data-testid="field-error">{error}</span>}
</div>
),
Title: ({ children, order }) => <h2 data-order={order}>{children}</h2>,
}));
//
// Imports after mocks
//
import { useWebSocket } from '../../../WebSocket';
import { useForm } from 'react-hook-form';
import * as M3uProfileUtils from '../../../utils/forms/M3uProfileUtils.js';
// Helpers
const makeM3U = (overrides = {}) => ({
id: 1,
name: 'Test M3U',
url: 'http://example.com/playlist.m3u',
username: 'user1',
password: 'pass1',
custom_properties: {
max_streams: 1,
profile: null,
...overrides.custom_properties,
},
...overrides,
});
const makeProfile = (overrides = {}) => ({
id: 10,
name: 'Test Profile',
type: 'regex',
search_pattern: '.*HBO.*',
replace_pattern: '',
max_streams: 2,
exp_date: null,
custom_properties: {},
is_default: false,
...overrides,
});
const makeFormMethods = (overrides = {}) => ({
register: vi.fn(() => ({
onChange: vi.fn(),
onBlur: vi.fn(),
ref: vi.fn(),
name: '',
})),
handleSubmit: vi.fn((fn) => (e) => {
e?.preventDefault?.();
return fn({});
}),
watch: vi.fn((field) => {
const defaults = {
type: 'regex',
search_pattern: '',
name: '',
max_streams: 1,
exp_date: null,
};
return field ? defaults[field] : defaults;
}),
setValue: vi.fn(),
reset: vi.fn(),
setError: vi.fn(),
formState: { errors: {}, isSubmitting: false },
control: {},
getValues: vi.fn(() => ({})),
...overrides,
});
const defaultProps = (overrides = {}) => ({
m3u: makeM3U(),
isOpen: true,
onClose: vi.fn(),
profile: null,
...overrides,
});
const setupWebSocket = ({ lastMessage = null } = {}) => {
const sendMessage = vi.fn();
vi.mocked(useWebSocket).mockReturnValue([true, sendMessage, lastMessage]);
return sendMessage;
};
const setupForm = (overrides = {}) => {
const formMethods = makeFormMethods(overrides);
vi.mocked(useForm).mockReturnValue(formMethods);
return formMethods;
};
//
describe('M3UProfile', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(M3uProfileUtils.addM3UProfile).mockResolvedValue(undefined);
vi.mocked(M3uProfileUtils.updateM3UProfile).mockResolvedValue(undefined);
vi.mocked(M3uProfileUtils.buildProfileSchema).mockReturnValue({});
vi.mocked(M3uProfileUtils.buildSubmitValues).mockReturnValue({});
vi.mocked(M3uProfileUtils.getDetectedMode).mockReturnValue('simple');
vi.mocked(M3uProfileUtils.prepareExpDate).mockReturnValue(null);
vi.mocked(M3uProfileUtils.fetchFirstStreamUrl).mockResolvedValue(
'http://example.com/stream1'
);
vi.mocked(M3uProfileUtils.applyRegex).mockReturnValue('');
vi.mocked(M3uProfileUtils.applyXcSimplePatterns).mockResolvedValue([]);
vi.mocked(M3uProfileUtils.validateXcSimple).mockReturnValue({});
vi.mocked(M3uProfileUtils.splitByPattern).mockReturnValue(null);
setupWebSocket();
setupForm();
});
// Guard conditions
describe('guard conditions', () => {
it('does not render modal when isOpen is false', () => {
render(<M3UProfile {...defaultProps({ isOpen: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
});
// Rendering
describe('rendering', () => {
it('renders the modal when isOpen is true with a valid m3u', () => {
render(<M3UProfile {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('renders "Edit Default Profile" title when editing default profile', () => {
const profile = makeProfile({ is_default: true });
render(<M3UProfile {...defaultProps({ profile })} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
/edit default profile/i
);
});
it('renders "M3U Profile" title when not default', () => {
render(<M3UProfile {...defaultProps()} />);
expect(screen.getByTestId('modal-title')).toHaveTextContent(
/M3U profile/i
);
});
it('renders a Save button', () => {
render(<M3UProfile {...defaultProps()} />);
expect(
screen.getByRole('button', { name: /submit/i })
).toBeInTheDocument();
});
it('renders the segmented control for XC profile type', () => {
const profile = makeProfile();
render(
<M3UProfile
{...defaultProps({ profile, m3u: makeM3U({ account_type: 'XC' }) })}
/>
);
expect(screen.getByTestId('segmented-control')).toBeInTheDocument();
});
it('does not render the segmented control for non-XC m3u', () => {
render(<M3UProfile {...defaultProps()} />);
expect(screen.queryByTestId('segmented-control')).not.toBeInTheDocument();
});
it('does not render the segmented control for default XC profile', () => {
const profile = makeProfile({ is_default: true });
render(
<M3UProfile
{...defaultProps({ profile, m3u: makeM3U({ account_type: 'XC' }) })}
/>
);
expect(screen.queryByTestId('segmented-control')).not.toBeInTheDocument();
});
it('renders the Name field', () => {
render(<M3UProfile {...defaultProps()} />);
expect(screen.getByTestId('text-input-name')).toBeInTheDocument();
});
it('renders the Max Streams field for non-default profile', () => {
render(<M3UProfile {...defaultProps()} />);
expect(screen.getByTestId(/number-input/i)).toBeInTheDocument();
});
it('does not render Max Streams field for default profile', () => {
const profile = makeProfile({ is_default: true });
render(<M3UProfile {...defaultProps({ profile })} />);
expect(screen.queryByTestId(/number-input/i)).not.toBeInTheDocument();
});
it('renders the DateTimePicker for non-XC expiration date', () => {
render(<M3UProfile {...defaultProps()} />);
expect(screen.getByTestId('date-time-picker')).toBeInTheDocument();
});
it('does not render DateTimePicker for XC m3u', () => {
render(
<M3UProfile
{...defaultProps({ m3u: makeM3U({ account_type: 'XC' }) })}
/>
);
expect(screen.queryByTestId('date-time-picker')).not.toBeInTheDocument();
});
it('renders default profile alert for default profiles', () => {
const profile = makeProfile({ is_default: true });
render(<M3UProfile {...defaultProps({ profile })} />);
expect(screen.getByTestId('alert-title')).toHaveTextContent(
/default profile/i
);
});
it('renders the Notes textarea', () => {
render(<M3UProfile {...defaultProps()} />);
expect(screen.getByTestId('textarea-notes')).toBeInTheDocument();
});
});
// Live regex demonstration
describe('live regex demonstration', () => {
it('renders the demo section for default profiles', () => {
const profile = makeProfile({ is_default: true });
render(<M3UProfile {...defaultProps({ profile })} />);
expect(
screen.getByPlaceholderText(/enter a sample url/i)
).toBeInTheDocument();
});
it('renders the demo section for non-XC m3u', () => {
render(<M3UProfile {...defaultProps()} />);
expect(
screen.getByPlaceholderText(/enter a sample url/i)
).toBeInTheDocument();
});
it('does not render the demo section for XC m3u in simple mode', () => {
// getDetectedMode returns 'simple' by default in beforeEach
render(
<M3UProfile
{...defaultProps({ m3u: makeM3U({ account_type: 'XC' }) })}
/>
);
expect(
screen.queryByPlaceholderText(/enter a sample url/i)
).not.toBeInTheDocument();
});
it('calls splitByPattern when rendering highlighted text', async () => {
vi.mocked(M3uProfileUtils.fetchFirstStreamUrl).mockResolvedValue(
'http://example.com/stream'
);
render(<M3UProfile {...defaultProps()} />);
await waitFor(() => {
expect(M3uProfileUtils.splitByPattern).toHaveBeenCalled();
});
});
it('calls applyRegex when rendering the replace result', async () => {
vi.mocked(M3uProfileUtils.fetchFirstStreamUrl).mockResolvedValue(
'http://example.com/stream'
);
render(<M3UProfile {...defaultProps()} />);
await waitFor(() => {
expect(M3uProfileUtils.applyRegex).toHaveBeenCalled();
});
});
it('populates sample input after fetching stream URL', async () => {
vi.mocked(M3uProfileUtils.fetchFirstStreamUrl).mockResolvedValue(
'http://example.com/stream1'
);
render(<M3UProfile {...defaultProps()} />);
await waitFor(() => {
expect(screen.getByPlaceholderText(/enter a sample url/i)).toHaveValue(
'http://example.com/stream1'
);
});
});
});
// Pre-filling existing profile
describe('pre-filling from profile prop', () => {
it('calls reset with profile values when a profile is provided', () => {
const formMethods = setupForm();
const profile = makeProfile();
render(<M3UProfile {...defaultProps({ profile })} />);
expect(formMethods.reset).toHaveBeenCalled();
});
it('calls buildProfileSchema on mount', () => {
render(<M3UProfile {...defaultProps()} />);
expect(M3uProfileUtils.buildProfileSchema).toHaveBeenCalled();
});
it('calls getDetectedMode when m3u is XC type', () => {
const profile = makeProfile();
render(
<M3UProfile
{...defaultProps({ profile, m3u: makeM3U({ account_type: 'XC' }) })}
/>
);
expect(M3uProfileUtils.getDetectedMode).toHaveBeenCalled();
});
});
// Form reset for new profile
describe('form reset for new profile', () => {
it('calls reset when modal opens with no profile', () => {
const formMethods = setupForm();
render(<M3UProfile {...defaultProps({ profile: null })} />);
expect(formMethods.reset).toHaveBeenCalled();
});
it('re-initializes when profile prop changes from null to a value', () => {
const formMethods = setupForm();
const { rerender } = render(
<M3UProfile {...defaultProps({ profile: null })} />
);
const profile = makeProfile();
rerender(<M3UProfile {...defaultProps({ profile })} />);
expect(formMethods.reset).toHaveBeenCalledTimes(2);
});
});
// Cancel / close behaviour
describe('cancel / close behaviour', () => {
it('calls onClose when modal X is clicked', () => {
const onClose = vi.fn();
render(<M3UProfile {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Adding a new profile
describe('adding a new profile', () => {
it('calls addM3UProfile when saving a new profile', async () => {
setupForm({
handleSubmit: vi.fn((fn) => (e) => {
e?.preventDefault?.();
return fn({ name: 'New Profile', type: 'regex', max_streams: 1 });
}),
});
render(<M3UProfile {...defaultProps({ profile: null })} />);
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(M3uProfileUtils.addM3UProfile).toHaveBeenCalled();
});
});
it('does not call updateM3UProfile when adding a new profile', async () => {
setupForm({
handleSubmit: vi.fn((fn) => (e) => {
e?.preventDefault?.();
return fn({ name: 'New Profile', type: 'regex', max_streams: 1 });
}),
});
render(<M3UProfile {...defaultProps({ profile: null })} />);
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(M3uProfileUtils.updateM3UProfile).not.toHaveBeenCalled();
});
});
it('calls onClose after successfully adding a profile', async () => {
const onClose = vi.fn();
setupForm({
handleSubmit: vi.fn((fn) => (e) => {
e?.preventDefault?.();
return fn({ name: 'New Profile', type: 'regex' });
}),
});
render(<M3UProfile {...defaultProps({ profile: null, onClose })} />);
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
// Updating an existing profile
describe('updating an existing profile', () => {
it('calls updateM3UProfile when saving an existing profile', async () => {
const profile = makeProfile();
setupForm({
handleSubmit: vi.fn((fn) => (e) => {
e?.preventDefault?.();
return fn({ name: 'Updated Profile', type: 'regex', max_streams: 2 });
}),
});
render(<M3UProfile {...defaultProps({ profile })} />);
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(M3uProfileUtils.updateM3UProfile).toHaveBeenCalled();
});
});
it('does not call addM3UProfile when updating an existing profile', async () => {
const profile = makeProfile();
setupForm({
handleSubmit: vi.fn((fn) => (e) => {
e?.preventDefault?.();
return fn({ name: 'Updated Profile', type: 'regex' });
}),
});
render(<M3UProfile {...defaultProps({ profile })} />);
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(M3uProfileUtils.addM3UProfile).not.toHaveBeenCalled();
});
});
it('calls prepareExpDate when form is submitted with a profile', async () => {
const profile = makeProfile({ exp_date: '2025-12-31T00:00:00Z' });
setupForm({
handleSubmit: vi.fn((fn) => (e) => {
e?.preventDefault?.();
return fn({ exp_date: profile.exp_date });
}),
});
render(<M3UProfile {...defaultProps({ profile })} />);
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(M3uProfileUtils.prepareExpDate).toHaveBeenCalledWith(
profile.exp_date,
false
);
});
});
it('calls onClose after successfully updating a profile', async () => {
const onClose = vi.fn();
const profile = makeProfile();
setupForm({
handleSubmit: vi.fn((fn) => (e) => {
e?.preventDefault?.();
return fn({ name: 'Updated Profile', type: 'regex' });
}),
});
render(<M3UProfile {...defaultProps({ profile, onClose })} />);
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
});
// Profile type switching (XC mode)
describe('profile type switching (XC mode)', () => {
const xcProps = () =>
defaultProps({
m3u: makeM3U({ account_type: 'XC' }),
profile: makeProfile(),
});
it('renders Simple and Advanced segments for XC m3u', () => {
render(<M3UProfile {...xcProps()} />);
expect(screen.getByTestId('segment-simple')).toBeInTheDocument();
expect(screen.getByTestId('segment-advanced')).toBeInTheDocument();
});
it('renders New Username / New Password fields in simple mode', () => {
render(<M3UProfile {...xcProps()} />);
expect(screen.getByTestId('text-input-new-username')).toBeInTheDocument();
expect(screen.getByTestId('text-input-new-password')).toBeInTheDocument();
});
it('switches to advanced mode and renders Search Pattern field', () => {
render(<M3UProfile {...xcProps()} />);
fireEvent.click(screen.getByTestId('segment-advanced'));
expect(
screen.getByTestId('text-input-search-pattern-(regex)')
).toBeInTheDocument();
});
it('calls setValue when switching to advanced mode', () => {
const formMethods = setupForm();
render(<M3UProfile {...xcProps()} />);
fireEvent.click(screen.getByTestId('segment-advanced'));
expect(formMethods.setValue).toHaveBeenCalledWith(
'search_pattern',
expect.any(String)
);
});
it('switches back to simple mode from advanced', () => {
render(<M3UProfile {...xcProps()} />);
fireEvent.click(screen.getByTestId('segment-advanced'));
fireEvent.click(screen.getByTestId('segment-simple'));
expect(screen.getByTestId('text-input-new-username')).toBeInTheDocument();
});
it('calls setValue when a segment is selected', () => {
const formMethods = setupForm();
render(<M3UProfile {...defaultProps()} />);
const regexSegment = screen.queryByTestId('segment-regex');
if (regexSegment) {
fireEvent.click(regexSegment);
expect(formMethods.setValue).toHaveBeenCalled();
}
});
it('shows validation errors when XC simple fields are empty on submit', async () => {
vi.mocked(M3uProfileUtils.validateXcSimple).mockReturnValue({
newUsername: 'Required',
newPassword: 'Required',
});
setupForm({
handleSubmit: vi.fn((fn) => (e) => {
e?.preventDefault?.();
return fn({});
}),
});
render(<M3UProfile {...xcProps()} />);
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(M3uProfileUtils.addM3UProfile).not.toHaveBeenCalled();
expect(M3uProfileUtils.updateM3UProfile).not.toHaveBeenCalled();
});
});
});
// Default profile Reset to Defaults button
describe('default profile — Reset to Defaults', () => {
it('renders the Reset to Defaults button for default profiles', () => {
const profile = makeProfile({ is_default: true });
render(<M3UProfile {...defaultProps({ profile })} />);
expect(
screen.getByRole('button', { name: /reset to defaults/i })
).toBeInTheDocument();
});
it('does not render Reset to Defaults for non-default profiles', () => {
render(<M3UProfile {...defaultProps()} />);
expect(
screen.queryByRole('button', { name: /reset to defaults/i })
).not.toBeInTheDocument();
});
it('calls setValue with default patterns when Reset to Defaults is clicked', () => {
const formMethods = setupForm();
const profile = makeProfile({ is_default: true });
render(<M3UProfile {...defaultProps({ profile })} />);
fireEvent.click(
screen.getByRole('button', { name: /reset to defaults/i })
);
expect(formMethods.setValue).toHaveBeenCalledWith(
'search_pattern',
'^(.*)$'
);
expect(formMethods.setValue).toHaveBeenCalledWith(
'replace_pattern',
'$1'
);
});
});
// Regex apply
describe('regex apply', () => {
it('calls fetchFirstStreamUrl and applyRegex when Apply is clicked', async () => {
setupForm({
watch: vi.fn((field) => {
if (field === 'type') return 'regex';
if (field === 'search_pattern') return '.*HBO.*';
return undefined;
}),
getValues: vi.fn(() => ({ search_pattern: '.*HBO.*', type: 'regex' })),
});
render(<M3UProfile {...defaultProps()} />);
const applyBtn = screen.queryByRole('button', { name: /apply/i });
if (applyBtn) {
fireEvent.click(applyBtn);
await waitFor(() => {
expect(M3uProfileUtils.fetchFirstStreamUrl).toHaveBeenCalled();
});
}
});
it('calls applyXcSimplePatterns when type is xc_simple and Apply is clicked', async () => {
setupForm({
watch: vi.fn((field) => {
if (field === 'type') return 'xc_simple';
return undefined;
}),
getValues: vi.fn(() => ({ type: 'xc_simple' })),
});
render(<M3UProfile {...defaultProps()} />);
const applyBtn = screen.queryByRole('button', { name: /apply/i });
if (applyBtn) {
fireEvent.click(applyBtn);
await waitFor(() => {
expect(M3uProfileUtils.applyXcSimplePatterns).toHaveBeenCalled();
});
}
});
});
// WebSocket integration
describe('WebSocket integration', () => {
it('initializes useWebSocket hook', () => {
render(<M3UProfile {...defaultProps()} />);
expect(useWebSocket).toHaveBeenCalled();
});
it('reacts to lastMessage changes without crashing', () => {
setupWebSocket({
lastMessage: { data: JSON.stringify({ type: 'update', payload: {} }) },
});
setupForm();
const { rerender } = render(<M3UProfile {...defaultProps()} />);
rerender(<M3UProfile {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
});
// Loading state
describe('loading state', () => {
it('disables Save button while submitting', () => {
setupForm({ formState: { errors: {}, isSubmitting: true } });
render(<M3UProfile {...defaultProps({ profile: null })} />);
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled();
});
it('enables Save button when not submitting', () => {
setupForm({ formState: { errors: {}, isSubmitting: false } });
render(<M3UProfile {...defaultProps({ profile: null })} />);
expect(
screen.getByRole('button', { name: /submit/i })
).not.toBeDisabled();
});
});
// buildSubmitValues integration
describe('buildSubmitValues', () => {
it('calls buildSubmitValues before saving', async () => {
setupForm({
handleSubmit: vi.fn((fn) => (e) => {
e?.preventDefault?.();
return fn({ name: 'Profile', type: 'regex', max_streams: 1 });
}),
});
render(<M3UProfile {...defaultProps({ profile: null })} />);
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(M3uProfileUtils.buildSubmitValues).toHaveBeenCalled();
});
});
});
});

View file

@ -0,0 +1,559 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import M3UProfiles from '../M3UProfiles';
// Store mocks
vi.mock('../../../store/playlists', () => ({ default: vi.fn() }));
vi.mock('../../../store/warnings', () => ({ default: vi.fn() }));
// Utility mocks
vi.mock('../../../utils/forms/M3uProfileUtils.js', () => ({
deleteM3UProfile: vi.fn(),
updateM3UProfile: vi.fn(),
}));
vi.mock('../../../utils/forms/M3uProfilesUtils.js', () => ({
getExpirationInfo: vi.fn(),
isAccountExpired: vi.fn(),
profileSortComparator: vi.fn(),
}));
// Sub-component mocks
vi.mock('../M3UProfile', () => ({
default: ({ isOpen, onClose, profile }) =>
isOpen ? (
<div data-testid="m3u-profile-modal">
<span data-testid="m3u-profile-editing">
{profile ? `editing-${profile.id}` : 'new'}
</span>
<button data-testid="m3u-profile-close" onClick={onClose}>
Close
</button>
</div>
) : null,
}));
vi.mock('../AccountInfoModal', () => ({
default: ({ isOpen, onClose, profile }) =>
isOpen ? (
<div data-testid="account-info-modal">
<span data-testid="account-info-profile">
{profile ? profile.id : 'none'}
</span>
<button data-testid="account-info-close" onClick={onClose}>
Close
</button>
</div>
) : null,
}));
vi.mock('../../ConfirmationDialog', () => ({
default: ({ opened, onConfirm, onClose, title, message }) =>
opened ? (
<div data-testid="confirmation-dialog">
<span data-testid="confirm-title">{title}</span>
<span data-testid="confirm-message">{message}</span>
<button data-testid="confirm-ok" onClick={onConfirm}>
Confirm
</button>
<button data-testid="confirm-cancel" onClick={onClose}>
Cancel
</button>
</div>
) : null,
}));
// lucide-react mocks
vi.mock('lucide-react', () => ({
Info: () => <svg data-testid="icon-info" />,
SquareMinus: () => <svg data-testid="icon-square-minus" />,
SquarePen: () => <svg data-testid="icon-square-pen" />,
}));
// Mantine core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, disabled, color }) => (
<button
data-testid="action-icon"
data-color={color}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
),
Badge: ({ children, color }) => (
<span data-testid="badge" data-color={color}>
{children}
</span>
),
Button: ({ children, onClick, disabled, loading, variant, color }) => (
<button
onClick={onClick}
disabled={disabled || loading}
data-variant={variant}
data-color={color}
data-loading={loading}
>
{children}
</button>
),
Card: ({ children, style }) => (
<div data-testid="profile-card" style={style}>
{children}
</div>
),
Flex: ({ children }) => <div>{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,
NumberInput: ({ label, value, onChange, disabled, min, max }) => (
<div>
<label>{label}</label>
<input
data-testid={`number-input-${label?.toLowerCase?.().replace(/\s+/g, '-')}`}
type="number"
value={value ?? ''}
disabled={disabled}
min={min}
max={max}
onChange={(e) => onChange?.(Number(e.target.value))}
/>
</div>
),
Stack: ({ children }) => <div>{children}</div>,
Switch: ({ label, checked, onChange, disabled }) => (
<label>
{label}
<input
data-testid={`switch-${label?.toLowerCase?.().replace(/\s+/g, '-')}`}
type="checkbox"
checked={!!checked}
disabled={disabled}
onChange={(e) => onChange?.(e)}
/>
</label>
),
Text: ({ children, size, c, fw }) => (
<span data-size={size} data-color={c} data-fw={fw}>
{children}
</span>
),
useMantineTheme: () => ({ tailwind: { yellow: 3, red: 6 } }),
}));
//
// Imports after mocks
//
import usePlaylistsStore from '../../../store/playlists';
import useWarningsStore from '../../../store/warnings';
import * as M3uProfileUtils from '../../../utils/forms/M3uProfileUtils.js';
import * as M3uProfilesUtils from '../../../utils/forms/M3uProfilesUtils.js';
// Helpers
const makeProfile = (overrides = {}) => ({
id: 1,
name: 'Test Profile',
type: 'regex',
max_streams: 2,
is_active: true,
custom_properties: {},
...overrides,
});
const makePlaylist = (overrides = {}) => ({
id: 1,
name: 'Test M3U',
account_type: 'XC',
...overrides,
});
const defaultProps = (overrides = {}) => ({
playlist: makePlaylist(),
isOpen: true,
onClose: vi.fn(),
...overrides,
});
const setupStores = ({
fetchPlaylist = vi.fn().mockResolvedValue(undefined),
suppressWarning = vi.fn(),
isWarningSuppressed = vi.fn().mockReturnValue(false),
profiles = { 1: [makeProfile()] },
} = {}) => {
vi.mocked(usePlaylistsStore).mockImplementation((sel) =>
sel({ fetchPlaylist, profiles })
);
vi.mocked(useWarningsStore).mockImplementation((sel) =>
sel({ suppressWarning, isWarningSuppressed })
);
return { fetchPlaylist, suppressWarning, isWarningSuppressed };
};
//
describe('M3UProfiles', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(M3uProfileUtils.deleteM3UProfile).mockResolvedValue(undefined);
vi.mocked(M3uProfileUtils.updateM3UProfile).mockResolvedValue(undefined);
vi.mocked(M3uProfilesUtils.getExpirationInfo).mockReturnValue({
label: null,
color: null,
});
vi.mocked(M3uProfilesUtils.isAccountExpired).mockReturnValue(false);
vi.mocked(M3uProfilesUtils.profileSortComparator).mockImplementation(
() => 0
);
});
// Guard conditions
describe('guard conditions', () => {
it('does not render modal when isOpen is false', () => {
setupStores();
render(<M3UProfiles {...defaultProps({ isOpen: false })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('does not render modal when playlist is null', () => {
setupStores();
render(<M3UProfiles {...defaultProps({ playlist: null })} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
});
// Rendering
describe('rendering', () => {
it('renders the modal when isOpen is true with a valid playlist', () => {
setupStores();
render(<M3UProfiles {...defaultProps()} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('renders an "Add Profile" button', () => {
setupStores();
render(<M3UProfiles {...defaultProps()} />);
expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument();
});
it('renders a profile card for each profile', () => {
const profiles = { 1: [makeProfile({ id: 1 }), makeProfile({ id: 2 })] };
setupStores({ profiles });
render(<M3UProfiles {...defaultProps()} />);
expect(screen.getAllByTestId('profile-card')).toHaveLength(2);
});
it('renders the profile name', () => {
const profiles = { 1: [makeProfile({ name: 'My Profile' })] };
setupStores({ profiles });
render(<M3UProfiles {...defaultProps()} />);
expect(screen.getByText('My Profile')).toBeInTheDocument();
});
it('renders an edit icon for each profile', () => {
setupStores();
render(<M3UProfiles {...defaultProps()} />);
expect(screen.getByTestId('icon-square-pen')).toBeInTheDocument();
});
it('renders a delete icon for each profile', () => {
setupStores();
render(<M3UProfiles {...defaultProps()} />);
expect(screen.getByTestId('icon-square-minus')).toBeInTheDocument();
});
it('renders an info icon for each profile', () => {
setupStores();
render(<M3UProfiles {...defaultProps()} />);
expect(screen.getByTestId('icon-info')).toBeInTheDocument();
});
it('renders an expiration badge when getExpirationInfo returns a label', () => {
vi.mocked(M3uProfilesUtils.getExpirationInfo).mockReturnValue({
text: 'Expires soon',
color: 'orange',
});
setupStores();
render(<M3UProfiles {...defaultProps()} />);
expect(screen.getByText('Expires soon')).toBeInTheDocument();
});
it('renders empty state message when there are no profiles', () => {
setupStores({ profiles: { 1: [] } });
render(<M3UProfiles {...defaultProps()} />);
expect(screen.queryByTestId('profile-card')).not.toBeInTheDocument();
});
it('renders profiles sorted by profileSortComparator', () => {
vi.mocked(M3uProfilesUtils.profileSortComparator).mockImplementation(
(a, b) => b.id - a.id
);
const profiles = {
1: [
makeProfile({ id: 1, name: 'Alpha' }),
makeProfile({ id: 2, name: 'Beta' }),
],
};
setupStores({ profiles });
render(<M3UProfiles {...defaultProps()} />);
const cards = screen.getAllByTestId('profile-card');
expect(cards).toHaveLength(2);
});
});
// Close behaviour
describe('close behaviour', () => {
it('calls onClose when modal X is clicked', () => {
const onClose = vi.fn();
setupStores();
render(<M3UProfiles {...defaultProps({ onClose })} />);
fireEvent.click(screen.getByTestId('modal-close'));
expect(onClose).toHaveBeenCalled();
});
});
// Add Profile
describe('add profile', () => {
it('opens M3UProfile modal when Add Profile is clicked', () => {
setupStores();
render(<M3UProfiles {...defaultProps()} />);
fireEvent.click(screen.getByRole('button', { name: /new/i }));
expect(screen.getByTestId('m3u-profile-modal')).toBeInTheDocument();
});
it('opens M3UProfile modal with null profile for a new profile', () => {
setupStores();
render(<M3UProfiles {...defaultProps()} />);
fireEvent.click(screen.getByRole('button', { name: /new/i }));
expect(screen.getByTestId('m3u-profile-editing').textContent).toBe('new');
});
it('closes M3UProfile modal when its onClose is called', () => {
setupStores();
render(<M3UProfiles {...defaultProps()} />);
fireEvent.click(screen.getByRole('button', { name: /new/i }));
fireEvent.click(screen.getByTestId('m3u-profile-close'));
expect(screen.queryByTestId('m3u-profile-modal')).not.toBeInTheDocument();
});
});
// Edit Profile
describe('edit profile', () => {
it('opens M3UProfile modal with the correct profile when edit is clicked', () => {
const profiles = { 1: [makeProfile({ id: 42 })] };
setupStores({ profiles });
render(<M3UProfiles {...defaultProps()} />);
const editBtn = screen.getByTestId('icon-square-pen').closest('button');
fireEvent.click(editBtn);
expect(screen.getByTestId('m3u-profile-modal')).toBeInTheDocument();
expect(screen.getByTestId('m3u-profile-editing').textContent).toBe(
'editing-42'
);
});
it('closes M3UProfile modal after edit onClose', () => {
setupStores();
render(<M3UProfiles {...defaultProps()} />);
const editBtn = screen.getByTestId('icon-square-pen').closest('button');
fireEvent.click(editBtn);
fireEvent.click(screen.getByTestId('m3u-profile-close'));
expect(screen.queryByTestId('m3u-profile-modal')).not.toBeInTheDocument();
});
});
// Account Info
describe('account info', () => {
it('opens AccountInfoModal when info icon is clicked', () => {
const profiles = { 1: [makeProfile({ id: 7 })] };
setupStores({ profiles });
render(<M3UProfiles {...defaultProps()} />);
const infoBtn = screen.getByTestId('icon-info').closest('button');
fireEvent.click(infoBtn);
expect(screen.getByTestId('account-info-modal')).toBeInTheDocument();
});
it('passes the correct profile to AccountInfoModal', () => {
const profiles = { 1: [makeProfile({ id: 7 })] };
setupStores({ profiles });
render(<M3UProfiles {...defaultProps()} />);
const infoBtn = screen.getByTestId('icon-info').closest('button');
fireEvent.click(infoBtn);
expect(screen.getByTestId('account-info-profile').textContent).toBe('7');
});
it('closes AccountInfoModal when its onClose is called', () => {
setupStores();
render(<M3UProfiles {...defaultProps()} />);
const infoBtn = screen.getByTestId('icon-info').closest('button');
fireEvent.click(infoBtn);
fireEvent.click(screen.getByTestId('account-info-close'));
expect(
screen.queryByTestId('account-info-modal')
).not.toBeInTheDocument();
});
});
// Delete profile
describe('delete profile', () => {
it('shows ConfirmationDialog when delete icon is clicked and warning not suppressed', () => {
setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
render(<M3UProfiles {...defaultProps()} />);
const deleteBtn = screen
.getByTestId('icon-square-minus')
.closest('button');
fireEvent.click(deleteBtn);
expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument();
});
it('calls deleteM3UProfile directly when warning is suppressed', async () => {
const profiles = { 1: [makeProfile()] };
setupStores({
isWarningSuppressed: vi.fn().mockReturnValue(true),
profiles,
});
render(<M3UProfiles {...defaultProps()} />);
const deleteBtn = screen
.getByTestId('icon-square-minus')
.closest('button');
fireEvent.click(deleteBtn);
await waitFor(() => {
expect(M3uProfileUtils.deleteM3UProfile).toHaveBeenCalledWith(
profiles[1][0]['id'],
1
);
});
});
it('calls deleteM3UProfile after confirming the dialog', async () => {
const profiles = { 1: [makeProfile()] };
setupStores({
isWarningSuppressed: vi.fn().mockReturnValue(true),
profiles,
});
setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
render(<M3UProfiles {...defaultProps()} />);
const deleteBtn = screen
.getByTestId('icon-square-minus')
.closest('button');
fireEvent.click(deleteBtn);
fireEvent.click(screen.getByTestId('confirm-ok'));
await waitFor(() => {
expect(M3uProfileUtils.deleteM3UProfile).toHaveBeenCalledWith(
profiles[1][0]['id'],
1
);
});
});
it('closes ConfirmationDialog after confirming', async () => {
setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
render(<M3UProfiles {...defaultProps()} />);
const deleteBtn = screen
.getByTestId('icon-square-minus')
.closest('button');
fireEvent.click(deleteBtn);
fireEvent.click(screen.getByTestId('confirm-ok'));
await waitFor(() => {
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
});
it('closes ConfirmationDialog when cancel is clicked', () => {
setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
render(<M3UProfiles {...defaultProps()} />);
const deleteBtn = screen
.getByTestId('icon-square-minus')
.closest('button');
fireEvent.click(deleteBtn);
fireEvent.click(screen.getByTestId('confirm-cancel'));
expect(
screen.queryByTestId('confirmation-dialog')
).not.toBeInTheDocument();
});
it('does not call deleteM3UProfile when cancel is clicked', async () => {
setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) });
render(<M3UProfiles {...defaultProps()} />);
const deleteBtn = screen
.getByTestId('icon-square-minus')
.closest('button');
fireEvent.click(deleteBtn);
fireEvent.click(screen.getByTestId('confirm-cancel'));
expect(M3uProfileUtils.deleteM3UProfile).not.toHaveBeenCalled();
});
});
// Max streams / Switch
describe('profile-level controls', () => {
it('renders NumberInput for max_streams on a profile card', () => {
const profiles = { 1: [makeProfile({ max_streams: 3 })] };
setupStores({ profiles });
render(<M3UProfiles {...defaultProps()} />);
expect(screen.getByRole('spinbutton')).toBeInTheDocument();
});
it('calls updateM3UProfile when max_streams NumberInput changes', async () => {
const profiles = { 1: [makeProfile({ max_streams: 2 })] };
setupStores({ profiles });
render(<M3UProfiles {...defaultProps()} />);
const input = screen.getByRole('spinbutton');
fireEvent.change(input, { target: { value: '5' } });
await waitFor(() => {
expect(M3uProfileUtils.updateM3UProfile).toHaveBeenCalled();
});
});
});
// Expiration / expired styling
describe('expiration display', () => {
it('applies expired styling when isAccountExpired returns true', () => {
vi.mocked(M3uProfilesUtils.isAccountExpired).mockReturnValue(true);
setupStores();
render(<M3UProfiles {...defaultProps()} />);
expect(screen.getByTestId('profile-card')).toBeInTheDocument();
});
it('renders expiration badge with correct color', () => {
vi.mocked(M3uProfilesUtils.getExpirationInfo).mockReturnValue({
text: '3 days left',
color: 'red',
});
setupStores();
render(<M3UProfiles {...defaultProps()} />);
const badge = screen.getByTestId('badge');
expect(badge.textContent).toBe('3 days left');
expect(badge.dataset.color).toBe('red');
});
});
// Warning suppression
describe('warning suppression', () => {
it('calls suppressWarning when user confirms and suppression is selected', async () => {
const { suppressWarning } = setupStores({
isWarningSuppressed: vi.fn().mockReturnValue(false),
});
render(<M3UProfiles {...defaultProps()} />);
expect(suppressWarning).toBeDefined();
});
});
});

View file

@ -1,12 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Box,
Button,
Text,
Group,
ActionIcon,
Stack,
} from '@mantine/core';
import { Box, Button, Text, Group, ActionIcon, Stack } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { GripVertical, Eye, EyeOff } from 'lucide-react';
import {
@ -36,7 +29,14 @@ import {
import { USER_LEVELS } from '../../../constants';
const DraggableNavItem = ({ item, isHidden, canHide, onToggleVisibility }) => {
const { transform, transition, setNodeRef, isDragging, attributes, listeners } = useSortable({
const {
transform,
transition,
setNodeRef,
isDragging,
attributes,
listeners,
} = useSortable({
id: item.id,
});
@ -73,7 +73,9 @@ const DraggableNavItem = ({ item, isHidden, canHide, onToggleVisibility }) => {
>
<GripVertical size={16} color="#888" />
</ActionIcon>
{IconComponent && <IconComponent size={18} color={isHidden ? '#666' : '#ccc'} />}
{IconComponent && (
<IconComponent size={18} color={isHidden ? '#666' : '#ccc'} />
)}
<Text size="sm" c={isHidden ? 'dimmed' : 'gray.3'}>
{item.label}
</Text>
@ -140,45 +142,48 @@ const NavOrderForm = ({ active }) => {
}, []);
// Debounced save function
const debouncedSave = useCallback(async (newOrder) => {
// Clear any pending save
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Store the pending order
pendingOrderRef.current = newOrder;
// Schedule save after 800ms of inactivity
saveTimeoutRef.current = setTimeout(async () => {
const orderToSave = pendingOrderRef.current;
if (!orderToSave) return;
setIsSaving(true);
try {
await setNavOrder(orderToSave);
notifications.show({
title: 'Navigation',
message: 'Order saved successfully',
color: 'green',
autoClose: 2000,
});
} catch {
// Revert on failure
const savedOrder = getNavOrder();
const orderedItems = getOrderedNavItems(savedOrder, isAdmin);
setItems(orderedItems);
notifications.show({
title: 'Error',
message: 'Failed to save navigation order',
color: 'red',
});
} finally {
setIsSaving(false);
pendingOrderRef.current = null;
const debouncedSave = useCallback(
async (newOrder) => {
// Clear any pending save
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
}, 800);
}, [setNavOrder, getNavOrder, isAdmin]);
// Store the pending order
pendingOrderRef.current = newOrder;
// Schedule save after 800ms of inactivity
saveTimeoutRef.current = setTimeout(async () => {
const orderToSave = pendingOrderRef.current;
if (!orderToSave) return;
setIsSaving(true);
try {
await setNavOrder(orderToSave);
notifications.show({
title: 'Navigation',
message: 'Order saved successfully',
color: 'green',
autoClose: 2000,
});
} catch {
// Revert on failure
const savedOrder = getNavOrder();
const orderedItems = getOrderedNavItems(savedOrder, isAdmin);
setItems(orderedItems);
notifications.show({
title: 'Error',
message: 'Failed to save navigation order',
color: 'red',
});
} finally {
setIsSaving(false);
pendingOrderRef.current = null;
}
}, 800);
},
[setNavOrder, getNavOrder, isAdmin]
);
const handleDragEnd = ({ active, over }) => {
if (!over || active.id === over.id) return;
@ -196,23 +201,26 @@ const NavOrderForm = ({ active }) => {
};
// Wrapped visibility toggle with error handling
const handleToggleVisibility = useCallback(async (itemId) => {
try {
await toggleNavVisibility(itemId);
notifications.show({
title: 'Navigation',
message: 'Visibility updated',
color: 'green',
autoClose: 2000,
});
} catch {
notifications.show({
title: 'Error',
message: 'Failed to update visibility',
color: 'red',
});
}
}, [toggleNavVisibility]);
const handleToggleVisibility = useCallback(
async (itemId) => {
try {
await toggleNavVisibility(itemId);
notifications.show({
title: 'Navigation',
message: 'Visibility updated',
color: 'green',
autoClose: 2000,
});
} catch {
notifications.show({
title: 'Error',
message: 'Failed to update visibility',
color: 'red',
});
}
},
[toggleNavVisibility]
);
const handleReset = async () => {
// Cancel any pending debounced save

View file

@ -14,7 +14,13 @@ import {
getNetworkAccessDefaults,
} from '../../../utils/forms/settings/NetworkAccessFormUtils.js';
const toTags = (str) => (str ? str.split(',').map((s) => s.trim()).filter(Boolean) : []);
const toTags = (str) =>
str
? str
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: [];
const toStr = (tags) => (tags || []).join(',');
const NetworkAccessForm = React.memo(({ active }) => {
@ -116,9 +122,14 @@ const NetworkAccessForm = React.memo(({ active }) => {
const saveNetworkAccess = async () => {
setSaved(false);
setSaving(true);
const values = pendingSaveValuesRef.current || Object.fromEntries(
Object.entries(networkAccessForm.getValues()).map(([k, v]) => [k, toStr(v)])
);
const values =
pendingSaveValuesRef.current ||
Object.fromEntries(
Object.entries(networkAccessForm.getValues()).map(([k, v]) => [
k,
toStr(v),
])
);
try {
await updateSetting({
...settings['network_access'],

View file

@ -5,7 +5,10 @@ import NetworkAccessForm from '../NetworkAccessForm';
// Constants mock
vi.mock('../../../../constants.js', () => ({
NETWORK_ACCESS_OPTIONS: {
M3U_EPG: { label: 'M3U / EPG Endpoints', description: 'Limit M3U/EPG access' },
M3U_EPG: {
label: 'M3U / EPG Endpoints',
description: 'Limit M3U/EPG access',
},
STREAMS: { label: 'Stream Endpoints', description: 'Limit stream access' },
XC_API: { label: 'XC API', description: 'Limit XC API access' },
UI: { label: 'UI', description: 'Limit UI access' },
@ -85,7 +88,12 @@ vi.mock('@mantine/core', () => ({
),
TagsInput: ({ label, placeholder, error, ...rest }) => (
<div>
<input aria-label={label} placeholder={placeholder} data-error={error} readOnly />
<input
aria-label={label}
placeholder={placeholder}
data-error={error}
readOnly
/>
{error && <span>{error}</span>}
</div>
),

View file

@ -0,0 +1,50 @@
import API from '../../api.js';
export const getEpgSourceValue = (cp) => {
// Show custom EPG if set
if (cp?.custom_epg_id !== undefined && cp?.custom_epg_id !== null) {
return cp.custom_epg_id.toString();
}
// Show "No EPG" if force_dummy_epg is set
if (cp?.force_dummy_epg) {
return '0';
}
// Otherwise show empty/placeholder
return null;
};
export const getEpgSourceData = (epgSources) => {
return [
{ value: '0', label: 'No EPG (Disabled)' },
...[...epgSources]
.sort((a, b) => a.name.localeCompare(b.name))
.map((source) => ({
value: source.id.toString(),
label: `${source.name} (${
source.source_type === 'dummy'
? 'Dummy'
: source.source_type === 'xmltv'
? 'XMLTV'
: source.source_type === 'schedules_direct'
? 'Schedules Direct'
: source.source_type
})`,
})),
];
};
export const repackGroupChannels = (playlist, group) => {
return API.repackGroupChannels(playlist.id, group.channel_group);
};
// Header line for the preview box. Adds a scan-cap suffix when the
// backend only scanned the first SCAN_CAP streams of the group.
export const formatPreviewSummary = (label, result) => {
if (!result) return null;
const { match_count, total_in_group, total_scanned, scan_limit_hit } = result;
const matchWord = `match${match_count === 1 ? '' : 'es'}`;
if (scan_limit_hit) {
return `${match_count} ${matchWord} in first ${total_scanned.toLocaleString()} streams scanned (of ${total_in_group.toLocaleString()} total)`;
}
return `${match_count} ${label} ${matchWord} in ${total_scanned.toLocaleString()} stream${total_scanned === 1 ? '' : 's'}`;
};

View file

@ -0,0 +1,25 @@
// Returns {name, start, end}[] for groups whose declared ranges
// intersect this group's range, or [] when there is no overlap.
import { getGroupReservation } from './GroupSyncUtils.js';
export const computeRangeOverlapsFor = (group, groupStates) => {
const myReservation = getGroupReservation(group);
if (!myReservation) return [];
const [myStart, myEnd] = myReservation;
const overlaps = [];
for (const other of groupStates) {
if (other.channel_group === group.channel_group) continue;
const otherReservation = getGroupReservation(other);
if (!otherReservation) continue;
const [oStart, oEnd] = otherReservation;
if (myStart <= oEnd && oStart <= myEnd) {
overlaps.push({ name: other.name, start: oStart, end: oEnd });
}
}
return overlaps;
};
const MAX_CHANNEL_NUMBER = 999999;
export const clampChannelNumber = (n) =>
Math.max(1, Math.min(MAX_CHANNEL_NUMBER, Math.floor(Number(n) || 1)));

View file

@ -191,7 +191,10 @@ export const buildSubmitValues = (
values.is_adult = values.is_adult === 'true';
}
if (values.hidden_from_output === '-1' || values.hidden_from_output === undefined) {
if (
values.hidden_from_output === '-1' ||
values.hidden_from_output === undefined
) {
delete values.hidden_from_output;
} else {
values.hidden_from_output = values.hidden_from_output === 'true';

View file

@ -0,0 +1,17 @@
import API from '../../api.js';
export const updateChannelGroup = (channelGroup, values) => {
return API.updateChannelGroup({
id: channelGroup.id,
...values,
});
};
export const addChannelGroup = (values) => {
return API.addChannelGroup(values);
};
export const deleteChannelGroup = (group) => {
return API.deleteChannelGroup(group.id);
};
export const cleanupUnusedChannelGroups = () => {
return API.cleanupUnusedChannelGroups();
};

View file

@ -0,0 +1,140 @@
import API from '../../api.js';
export const getEPGs = () => {
return API.getEPGs();
};
export const getChannelsInRange = (start, end, controller) => {
return API.getChannelsInRange(start, end, {
signal: controller.signal,
});
};
export const getStreamsRegexPreview = (
group,
find,
replace,
match,
exclude,
controller,
playlist
) => {
return API.getStreamsRegexPreview(group.name, {
find: find || undefined,
replace: find ? replace : undefined,
match: match || undefined,
exclude: exclude || undefined,
limit: 10,
signal: controller.signal,
m3uAccountId: playlist?.id,
});
};
// "Expected" occupants are this group's own auto-sync output:
// auto_created, in this group on this account, no channel_number
// override. Channels from any other provider, group, or with a user
// pin all surface as a warning so the user is aware their range
// overlaps with existing assignments. Sync still merges shared
// ranges across providers, so the warning is informational rather
// than blocking.
export const isExpectedOccupantForGroup = (
occupant,
groupChannelGroupId,
playlist
) => {
if (!occupant) return false;
if (!occupant.auto_created) return false;
if (occupant.has_channel_number_override) return false;
if (
occupant.channel_group_id !== undefined &&
occupant.channel_group_id !== groupChannelGroupId
)
return false;
return !(
occupant.auto_created_by_account_id !== undefined &&
playlist?.id !== undefined &&
occupant.auto_created_by_account_id !== playlist.id
);
};
export const rangeFor = (g) => {
if (!g.enabled || !g.auto_channel_sync) return null;
const mode = g.custom_properties?.channel_numbering_mode || 'fixed';
if (mode === 'next_available') return null;
const startRaw =
mode === 'provider'
? (g.custom_properties?.channel_numbering_fallback ?? 1)
: (g.auto_sync_channel_start ?? 1);
const start = Number(startRaw);
if (!Number.isFinite(start)) return null;
const endRaw = g.auto_sync_channel_end;
const end =
endRaw === null || endRaw === undefined || endRaw === ''
? start
: Number(endRaw);
return { start, end, startRaw };
};
export const abortTimers = (timerRef, abortRef) => {
Object.values(timerRef.current).forEach((t) => clearTimeout(t));
timerRef.current = {};
Object.values(abortRef.current).forEach((c) => {
try {
c.abort();
} catch {
// ignore
}
});
abortRef.current = {};
};
export const getRegexOptions = (
findValue,
replaceValue,
filterValue,
excludeValue
) => {
return {
find: findValue,
replace: replaceValue,
match: filterValue,
exclude: excludeValue,
};
};
export const computeAutoSyncStart = (prev, id) => {
let proposedStart = 1;
for (const other of prev) {
if (other.channel_group == id) continue;
if (!other.enabled || !other.auto_channel_sync) continue;
const otherMode =
other.custom_properties?.channel_numbering_mode || 'fixed';
if (otherMode === 'next_available') continue;
const otherStart = Number(
otherMode === 'provider'
? (other.custom_properties?.channel_numbering_fallback ?? 1)
: (other.auto_sync_channel_start ?? 1)
);
if (!Number.isFinite(otherStart)) continue;
const otherEnd =
other.auto_sync_channel_end === null ||
other.auto_sync_channel_end === undefined ||
other.auto_sync_channel_end === ''
? otherStart
: Number(other.auto_sync_channel_end);
const upper = Math.max(otherStart, otherEnd);
if (upper + 1 > proposedStart) proposedStart = upper + 1;
}
return proposedStart;
};
export const isGroupVisible = (group, groupFilter, statusFilter) => {
const matchesText = group.name
.toLowerCase()
.includes(groupFilter.toLowerCase());
const matchesStatus =
statusFilter === 'all' ||
(statusFilter === 'enabled' && group.enabled) ||
(statusFilter === 'disabled' && !group.enabled);
return matchesText && matchesStatus;
};

View file

@ -0,0 +1,78 @@
import API from '../../api.js';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
const schema = Yup.object({
name: Yup.string().required('Name is required'),
url: Yup.string()
.required('URL is required')
.test(
'valid-url-or-path',
'Must be a valid URL or local file path',
(value) => {
if (!value) return false;
// Allow local file paths starting with /data/logos/
if (value.startsWith('/data/logos/')) return true;
// Allow valid URLs
try {
new URL(value);
return true;
} catch {
return false;
}
}
),
});
export const uploadLogo = async (selectedFile, values) => {
return await API.uploadLogo(selectedFile, values.name);
};
export const createLogo = async (values) => {
return await API.createLogo(values);
};
export const updateLogo = async (logo, values) => {
return await API.updateLogo(logo.id, values);
};
export const getResolver = () => {
return yupResolver(schema);
};
export const getUploadErrorMessage = (uploadError) => {
if (
uploadError.code === 'NETWORK_ERROR' ||
uploadError.message?.includes('timeout')
) {
return 'Upload timed out. Please try again.';
} else if (uploadError.status === 413) {
return 'File too large. Please choose a smaller file.';
} else if (uploadError.body?.error) {
return uploadError.body.error;
}
return 'Failed to upload logo file';
};
export const getUpdateLogoErrorMessage = (logo, error) => {
if (error.code === 'NETWORK_ERROR' || error.message?.includes('timeout')) {
return 'Request timed out. Please try again.';
} else if (error.response?.data?.error) {
return error.response.data.error;
}
return logo ? 'Failed to update logo' : 'Failed to create logo';
};
export const validateFileSize = (file) => {
return file.size <= 5 * 1024 * 1024;
};
export const releaseUrl = (url) => {
if (url && url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
};
export const getFilenameWithoutExtension = (filename) => {
return filename.replace(/\.[^/.]+$/, '');
};

View file

@ -0,0 +1,11 @@
import API from '../../api.js';
export const addM3UFilter = async (m3u, values) => {
await API.addM3UFilter(m3u.id, values);
};
export const updateM3UFilter = (m3u, filter, values) => {
return API.updateM3UFilter(m3u.id, filter.id, values);
};
export const deleteM3UFilter = async (playlist, id) => {
await API.deleteM3UFilter(playlist.id, id);
};

View file

@ -0,0 +1,70 @@
import API from '../../api.js';
import { refreshPlaylist, updatePlaylist } from './M3uUtils.js';
const updateM3UGroupSettings = async (
playlist,
groupSettings,
categorySettings
) => {
await API.updateM3UGroupSettings(
playlist.id,
groupSettings,
categorySettings
);
};
export const buildGroupStates = (channelGroups, playlistChannelGroups) => {
return playlistChannelGroups
.filter((group) => channelGroups[group.channel_group])
.map((group) => ({
...group,
name: channelGroups[group.channel_group].name,
auto_channel_sync: group.auto_channel_sync || false,
auto_sync_channel_start: group.auto_sync_channel_start || 1.0,
auto_sync_channel_end: group.auto_sync_channel_end ?? null,
custom_properties: parseCustomProperties(group.custom_properties),
}));
};
const parseCustomProperties = (raw) => {
if (!raw) return {};
try {
return typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch {
return {};
}
};
export const saveAndRefreshPlaylist = async (
playlist,
groupStates,
movieCategoryStates,
seriesCategoryStates,
autoEnableSettings
) => {
const groupSettings = prepareGroupSettings(groupStates);
const categorySettings = prepareCategorySettings(
movieCategoryStates,
seriesCategoryStates
);
await updatePlaylist(playlist, autoEnableSettings);
await updateM3UGroupSettings(playlist, groupSettings, categorySettings);
await refreshPlaylist(playlist);
};
const prepareGroupSettings = (groupStates) => {
return groupStates.map((state) => ({
...state,
custom_properties: state.custom_properties || undefined,
}));
};
const prepareCategorySettings = (movieCategoryStates, seriesCategoryStates) => {
return [...movieCategoryStates, ...seriesCategoryStates]
.map((state) => ({
...state,
custom_properties: state.custom_properties || undefined,
}))
.filter((state) => state.enabled !== state.original_enabled);
};

View file

@ -0,0 +1,156 @@
import API from '../../api.js';
import * as Yup from 'yup';
export const updateM3UProfile = async (playlistId, submitValues) => {
await API.updateM3UProfile(playlistId, submitValues);
};
export const addM3UProfile = async (playlistId, submitValues) => {
await API.addM3UProfile(playlistId, submitValues);
};
export const deleteM3UProfile = async (playlistId, id) => {
await API.deleteM3UProfile(playlistId, id);
};
const queryStreams = async (params) => {
return await API.queryStreams(params);
};
export const getDetectedMode = (storedMode, profile, m3u) => {
if (storedMode) {
return storedMode;
} else if (
profile?.search_pattern &&
profile.search_pattern === `${m3u?.username}/${m3u?.password}`
) {
return 'simple';
} else if (profile?.search_pattern) {
return 'advanced';
}
return 'simple';
};
export const applyRegex = (input, pattern, replacer) => {
if (!pattern || !input) return input;
try {
const regex = new RegExp(pattern, 'g');
return input.replace(regex, replacer);
} catch {
return input;
}
};
/**
* Splits `input` into an array of { text, matched } segments using `pattern`.
* Returns null if the pattern is empty or the input is empty.
*/
export const splitByPattern = (input, pattern) => {
if (!pattern || !input) return null;
try {
const regex = new RegExp(pattern, 'g');
const segments = [];
let lastIndex = 0;
let m;
while ((m = regex.exec(input)) !== null) {
if (m.index > lastIndex) {
segments.push({
text: input.slice(lastIndex, m.index),
matched: false,
});
}
segments.push({ text: m[0], matched: true });
lastIndex = m.index + m[0].length;
if (m[0].length === 0) regex.lastIndex++;
}
if (lastIndex < input.length) {
segments.push({ text: input.slice(lastIndex), matched: false });
}
return segments;
} catch {
return null;
}
};
export const buildProfileSchema = (isDefaultProfile, isXC) => {
return Yup.object({
name: Yup.string().required('Name is required'),
search_pattern: Yup.string().when([], {
is: () => !isDefaultProfile && !isXC,
then: (schema) => schema.required('Search pattern is required'),
otherwise: (schema) => schema.notRequired(),
}),
replace_pattern: Yup.string().when([], {
is: () => !isDefaultProfile && !isXC,
then: (schema) => schema.required('Replace pattern is required'),
otherwise: (schema) => schema.notRequired(),
}),
notes: Yup.string(),
});
};
export const fetchFirstStreamUrl = async (m3uId) => {
const params = new URLSearchParams();
params.append('page', 1);
params.append('page_size', 1);
params.append('m3u_account', m3uId);
const response = await queryStreams(params);
return response?.results?.[0]?.url ?? null;
};
export const validateXcSimple = (newUsername, newPassword) => {
const errs = {};
if (!newUsername.trim()) errs.newUsername = 'New username is required';
if (!newPassword.trim()) errs.newPassword = 'New password is required';
return errs;
};
export const prepareExpDate = (expDateValue, isXC) => {
if (isXC) return undefined;
if (expDateValue instanceof Date) return expDateValue.toISOString();
return expDateValue || null;
};
export const applyXcSimplePatterns = (
values,
m3u,
newUsername,
newPassword
) => {
return {
...values,
search_pattern: `${m3u?.username || ''}/${m3u?.password || ''}`,
replace_pattern: `${newUsername.trim()}/${newPassword.trim()}`,
};
};
export const buildSubmitValues = (
values,
profile,
isDefaultProfile,
isXC,
xcMode
) => {
if (isDefaultProfile) {
return {
name: values.name,
search_pattern: values.search_pattern || '',
replace_pattern: values.replace_pattern || '',
custom_properties: {
...(profile?.custom_properties || {}),
notes: values.notes || '',
},
};
}
return {
name: values.name,
max_streams: values.max_streams,
search_pattern: values.search_pattern,
replace_pattern: values.replace_pattern,
custom_properties: {
...(profile?.custom_properties || {}),
notes: values.notes || '',
...(isXC ? { xcMode } : {}),
},
};
};

View file

@ -0,0 +1,37 @@
export const parseExpirationDate = (profile) => {
const expDate = profile?.custom_properties?.user_info?.exp_date;
if (!expDate) return null;
try {
return new Date(parseInt(expDate) * 1000);
} catch {
return null;
}
};
export const isAccountExpired = (profile) => {
const expDate = parseExpirationDate(profile);
if (!expDate) return false;
return expDate < new Date();
};
export const getExpirationInfo = (profile) => {
const expDate = parseExpirationDate(profile);
if (!expDate) return null;
const diffMs = expDate - new Date();
if (diffMs <= 0) return { text: 'Expired', color: 'red' };
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (days > 30) return { text: `${days} days`, color: 'green' };
if (days > 7) return { text: `${days} days`, color: 'yellow' };
if (days > 0) return { text: `${days} days`, color: 'orange' };
const hours = Math.floor(diffMs / (1000 * 60 * 60));
return { text: `${hours}h`, color: 'red' };
};
export const profileSortComparator = (a, b) => {
if (a.is_default) return -1;
if (b.is_default) return 1;
return a.name.localeCompare(b.name);
};

View file

@ -0,0 +1,54 @@
import API from '../../api.js';
export const updatePlaylist = (playlist, values, file) => {
return API.updatePlaylist({
id: playlist.id,
...values,
file,
});
};
export const addPlaylist = async (values, file) => {
return await API.addPlaylist({
...values,
file,
});
};
export const getPlaylist = async (newPlaylist) => {
return await API.getPlaylist(newPlaylist.id);
};
export const refreshPlaylist = async (playlist) => {
return await API.refreshPlaylist(playlist.id);
};
export const prepareSubmitValues = (values, expDate) => {
const prepared = { ...values };
if (prepared.account_type === 'XC') {
delete prepared.exp_date;
} else if (expDate instanceof Date) {
prepared.exp_date = expDate.toISOString();
} else {
prepared.exp_date = null;
}
const hasCron =
prepared.cron_expression && prepared.cron_expression.trim() !== '';
if (hasCron) {
prepared.refresh_interval = 0;
} else {
prepared.cron_expression = '';
}
if (prepared.account_type == 'XC' && prepared.password == '') {
delete prepared.password;
}
if (prepared.user_agent == '0') {
prepared.user_agent = null;
}
return prepared;
};

View file

@ -0,0 +1,257 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
getEpgSourceValue,
getEpgSourceData,
repackGroupChannels,
formatPreviewSummary,
} from '../AutoSyncAdvancedUtils.js';
vi.mock('../../../api.js', () => ({
default: {
repackGroupChannels: vi.fn(),
},
}));
import API from '../../../api.js';
const makeEpgSource = (overrides = {}) => ({
id: 1,
name: 'Source One',
source_type: 'xmltv',
...overrides,
});
const makeResult = (overrides = {}) => ({
match_count: 3,
total_in_group: 100,
total_scanned: 100,
scan_limit_hit: false,
...overrides,
});
describe('AutoSyncAdvancedUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── getEpgSourceValue ─────────────────────────────────────────────────────
describe('getEpgSourceValue', () => {
it('returns custom_epg_id as string when set', () => {
expect(getEpgSourceValue({ custom_epg_id: 5 })).toBe('5');
});
it('returns custom_epg_id as string when set to 0', () => {
expect(getEpgSourceValue({ custom_epg_id: 0 })).toBe('0');
});
it('returns "0" when force_dummy_epg is true and no custom_epg_id', () => {
expect(getEpgSourceValue({ force_dummy_epg: true })).toBe('0');
});
it('prefers custom_epg_id over force_dummy_epg', () => {
expect(
getEpgSourceValue({ custom_epg_id: 7, force_dummy_epg: true })
).toBe('7');
});
it('returns null when custom_epg_id is null', () => {
expect(getEpgSourceValue({ custom_epg_id: null })).toBe(null);
});
it('returns null when custom_epg_id is undefined and force_dummy_epg is false', () => {
expect(
getEpgSourceValue({ custom_epg_id: undefined, force_dummy_epg: false })
).toBe(null);
});
it('returns null for empty custom_properties object', () => {
expect(getEpgSourceValue({})).toBe(null);
});
it('returns null for null custom_properties', () => {
expect(getEpgSourceValue(null)).toBe(null);
});
it('returns null for undefined custom_properties', () => {
expect(getEpgSourceValue(undefined)).toBe(null);
});
});
// ── getEpgSourceData ──────────────────────────────────────────────────────
describe('getEpgSourceData', () => {
it('always includes "No EPG" as the first option', () => {
const result = getEpgSourceData([]);
expect(result[0]).toEqual({ value: '0', label: 'No EPG (Disabled)' });
});
it('returns only the No EPG option for an empty source list', () => {
expect(getEpgSourceData([])).toHaveLength(1);
});
it('labels xmltv sources correctly', () => {
const result = getEpgSourceData([
makeEpgSource({ source_type: 'xmltv' }),
]);
expect(result[1].label).toBe('Source One (XMLTV)');
});
it('labels dummy sources correctly', () => {
const result = getEpgSourceData([
makeEpgSource({ source_type: 'dummy' }),
]);
expect(result[1].label).toBe('Source One (Dummy)');
});
it('labels schedules_direct sources correctly', () => {
const result = getEpgSourceData([
makeEpgSource({ source_type: 'schedules_direct' }),
]);
expect(result[1].label).toBe('Source One (Schedules Direct)');
});
it('falls back to raw source_type for unknown types', () => {
const result = getEpgSourceData([
makeEpgSource({ source_type: 'custom_type' }),
]);
expect(result[1].label).toBe('Source One (custom_type)');
});
it('maps id to string value', () => {
const result = getEpgSourceData([makeEpgSource({ id: 42 })]);
expect(result[1].value).toBe('42');
});
it('sorts sources alphabetically by name', () => {
const sources = [
makeEpgSource({ id: 1, name: 'Zebra' }),
makeEpgSource({ id: 2, name: 'Alpha' }),
makeEpgSource({ id: 3, name: 'Mango' }),
];
const result = getEpgSourceData(sources);
expect(result.map((r) => r.label)).toEqual([
'No EPG (Disabled)',
'Alpha (XMLTV)',
'Mango (XMLTV)',
'Zebra (XMLTV)',
]);
});
it('does not mutate the original array', () => {
const sources = [
makeEpgSource({ id: 1, name: 'Zebra' }),
makeEpgSource({ id: 2, name: 'Alpha' }),
];
const original = [...sources];
getEpgSourceData(sources);
expect(sources).toEqual(original);
});
});
// ── repackGroupChannels ───────────────────────────────────────────────────
describe('repackGroupChannels', () => {
it('calls API.repackGroupChannels with playlist id and channel_group', () => {
const playlist = { id: 10 };
const group = { channel_group: 5 };
vi.mocked(API.repackGroupChannels).mockResolvedValue({ ok: true });
repackGroupChannels(playlist, group);
expect(API.repackGroupChannels).toHaveBeenCalledWith(10, 5);
});
it('returns the API promise', async () => {
const playlist = { id: 10 };
const group = { channel_group: 5 };
vi.mocked(API.repackGroupChannels).mockResolvedValue({ ok: true });
await expect(repackGroupChannels(playlist, group)).resolves.toEqual({
ok: true,
});
});
});
// ── formatPreviewSummary ──────────────────────────────────────────────────
describe('formatPreviewSummary', () => {
it('returns null when result is null', () => {
expect(formatPreviewSummary('streams', null)).toBeNull();
});
it('returns null when result is undefined', () => {
expect(formatPreviewSummary('streams', undefined)).toBeNull();
});
it('formats normal result with plural matches', () => {
const result = makeResult({ match_count: 3, total_scanned: 50 });
expect(formatPreviewSummary('streams', result)).toBe(
'3 streams matches in 50 streams'
);
});
it('formats normal result with singular match', () => {
const result = makeResult({ match_count: 1, total_scanned: 50 });
expect(formatPreviewSummary('streams', result)).toBe(
'1 streams match in 50 streams'
);
});
it('uses singular "stream" when total_scanned is 1', () => {
const result = makeResult({ match_count: 1, total_scanned: 1 });
expect(formatPreviewSummary('streams', result)).toBe(
'1 streams match in 1 stream'
);
});
it('formats scan_limit_hit result with plural matches', () => {
const result = makeResult({
match_count: 5,
total_scanned: 200,
total_in_group: 1000,
scan_limit_hit: true,
});
expect(formatPreviewSummary('streams', result)).toBe(
'5 matches in first 200 streams scanned (of 1,000 total)'
);
});
it('formats scan_limit_hit result with singular match', () => {
const result = makeResult({
match_count: 1,
total_scanned: 200,
total_in_group: 1000,
scan_limit_hit: true,
});
expect(formatPreviewSummary('streams', result)).toBe(
'1 match in first 200 streams scanned (of 1,000 total)'
);
});
it('uses toLocaleString formatting for large numbers in scan_limit_hit', () => {
const result = makeResult({
match_count: 2,
total_scanned: 5000,
total_in_group: 10000,
scan_limit_hit: true,
});
expect(formatPreviewSummary('streams', result)).toBe(
'2 matches in first 5,000 streams scanned (of 10,000 total)'
);
});
it('includes the label in normal result output', () => {
const result = makeResult({ match_count: 2, total_scanned: 10 });
expect(formatPreviewSummary('channels', result)).toContain('channels');
});
it('does not include the label in scan_limit_hit output', () => {
const result = makeResult({
scan_limit_hit: true,
match_count: 2,
total_scanned: 10,
total_in_group: 100,
});
expect(formatPreviewSummary('channels', result)).not.toContain(
'channels'
);
});
});
});

View file

@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
computeRangeOverlapsFor,
clampChannelNumber,
} from '../AutoSyncBasicUtils.js';
vi.mock('../GroupSyncUtils.js', () => ({
getGroupReservation: vi.fn(),
}));
import { getGroupReservation } from '../GroupSyncUtils.js';
const makeGroup = (overrides = {}) => ({
channel_group: 1,
name: 'Group A',
...overrides,
});
describe('AutoSyncBasicUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── computeRangeOverlapsFor ───────────────────────────────────────────────
describe('computeRangeOverlapsFor', () => {
it('returns [] when the target group has no reservation', () => {
getGroupReservation.mockReturnValue(null);
expect(computeRangeOverlapsFor(makeGroup(), [])).toEqual([]);
});
it('returns [] when there are no other groups', () => {
getGroupReservation.mockReturnValue([100, 200]);
expect(computeRangeOverlapsFor(makeGroup(), [])).toEqual([]);
});
it('skips groups with the same channel_group id', () => {
const group = makeGroup({ channel_group: 1 });
const same = makeGroup({ channel_group: 1, name: 'Same' });
getGroupReservation.mockReturnValue([100, 200]);
expect(computeRangeOverlapsFor(group, [same])).toEqual([]);
});
it('skips other groups with no reservation', () => {
const group = makeGroup({ channel_group: 1 });
const other = makeGroup({ channel_group: 2, name: 'Other' });
getGroupReservation
.mockReturnValueOnce([100, 200]) // target group
.mockReturnValueOnce(null); // other group
expect(computeRangeOverlapsFor(group, [other])).toEqual([]);
});
it('detects exact overlap', () => {
const group = makeGroup({ channel_group: 1 });
const other = makeGroup({ channel_group: 2, name: 'Other' });
getGroupReservation
.mockReturnValueOnce([100, 200])
.mockReturnValueOnce([100, 200]);
expect(computeRangeOverlapsFor(group, [other])).toEqual([
{ name: 'Other', start: 100, end: 200 },
]);
});
it('detects partial overlap at start boundary', () => {
const group = makeGroup({ channel_group: 1 });
const other = makeGroup({ channel_group: 2, name: 'Other' });
getGroupReservation
.mockReturnValueOnce([150, 250])
.mockReturnValueOnce([100, 150]);
expect(computeRangeOverlapsFor(group, [other])).toEqual([
{ name: 'Other', start: 100, end: 150 },
]);
});
it('detects partial overlap at end boundary', () => {
const group = makeGroup({ channel_group: 1 });
const other = makeGroup({ channel_group: 2, name: 'Other' });
getGroupReservation
.mockReturnValueOnce([100, 200])
.mockReturnValueOnce([200, 300]);
expect(computeRangeOverlapsFor(group, [other])).toEqual([
{ name: 'Other', start: 200, end: 300 },
]);
});
it('returns [] when other group is entirely before target', () => {
const group = makeGroup({ channel_group: 1 });
const other = makeGroup({ channel_group: 2, name: 'Other' });
getGroupReservation
.mockReturnValueOnce([200, 300])
.mockReturnValueOnce([100, 199]);
expect(computeRangeOverlapsFor(group, [other])).toEqual([]);
});
it('returns [] when other group is entirely after target', () => {
const group = makeGroup({ channel_group: 1 });
const other = makeGroup({ channel_group: 2, name: 'Other' });
getGroupReservation
.mockReturnValueOnce([100, 199])
.mockReturnValueOnce([200, 300]);
expect(computeRangeOverlapsFor(group, [other])).toEqual([]);
});
it('returns multiple overlapping groups', () => {
const group = makeGroup({ channel_group: 1 });
const other1 = makeGroup({ channel_group: 2, name: 'B' });
const other2 = makeGroup({ channel_group: 3, name: 'C' });
getGroupReservation
.mockReturnValueOnce([100, 300]) // target
.mockReturnValueOnce([150, 200]) // other1 overlaps
.mockReturnValueOnce([250, 350]); // other2 overlaps
expect(computeRangeOverlapsFor(group, [other1, other2])).toEqual([
{ name: 'B', start: 150, end: 200 },
{ name: 'C', start: 250, end: 350 },
]);
});
});
// ── clampChannelNumber ────────────────────────────────────────────────────
describe('clampChannelNumber', () => {
it('returns the value unchanged when within range', () => {
expect(clampChannelNumber(500)).toBe(500);
});
it('clamps below 1 to 1', () => {
expect(clampChannelNumber(0)).toBe(1);
expect(clampChannelNumber(-50)).toBe(1);
});
it('clamps above 999999 to 999999', () => {
expect(clampChannelNumber(1000000)).toBe(999999);
expect(clampChannelNumber(99999999)).toBe(999999);
});
it('floors decimal values', () => {
expect(clampChannelNumber(5.9)).toBe(5);
expect(clampChannelNumber(1.1)).toBe(1);
});
it('coerces numeric strings', () => {
expect(clampChannelNumber('42')).toBe(42);
});
it('falls back to 1 for NaN inputs', () => {
expect(clampChannelNumber('abc')).toBe(1);
expect(clampChannelNumber(NaN)).toBe(1);
expect(clampChannelNumber(undefined)).toBe(1);
expect(clampChannelNumber(null)).toBe(1);
});
it('returns 1 for boundary value 1', () => {
expect(clampChannelNumber(1)).toBe(1);
});
it('returns 999999 for boundary value 999999', () => {
expect(clampChannelNumber(999999)).toBe(999999);
});
});
});

View file

@ -30,7 +30,7 @@ describe('updateChannelsWithOverrideRouting (manual finding #4)', () => {
await updateChannelsWithOverrideRouting(
[1, 2],
{ name: 'BulkRename' },
channelsById,
channelsById
);
expect(API.bulkUpdateChannels).toHaveBeenCalledTimes(1);
@ -48,7 +48,7 @@ describe('updateChannelsWithOverrideRouting (manual finding #4)', () => {
await updateChannelsWithOverrideRouting(
[1],
{ name: 'ManualRename' },
channelsById,
channelsById
);
const body = API.bulkUpdateChannels.mock.calls[0][0];
expect(body).toEqual([{ id: 1, name: 'ManualRename' }]);
@ -63,7 +63,7 @@ describe('updateChannelsWithOverrideRouting (manual finding #4)', () => {
await updateChannelsWithOverrideRouting(
[1, 2, 3],
{ name: 'Mixed', tvg_id: 'mixed.tvg' },
channelsById,
channelsById
);
const body = API.bulkUpdateChannels.mock.calls[0][0];
expect(body).toEqual([
@ -82,7 +82,7 @@ describe('updateChannelsWithOverrideRouting (manual finding #4)', () => {
await updateChannelsWithOverrideRouting(
[1],
{ hidden_from_output: true, name: 'Renamed' },
channelsById,
channelsById
);
const body = API.bulkUpdateChannels.mock.calls[0][0];
expect(body).toEqual([
@ -97,7 +97,7 @@ describe('updateChannelsWithOverrideRouting (manual finding #4)', () => {
await updateChannelsWithOverrideRouting(
[99],
{ name: 'Defensive' },
{}, // empty lookup
{} // empty lookup
);
const body = API.bulkUpdateChannels.mock.calls[0][0];
expect(body).toEqual([{ id: 99, name: 'Defensive' }]);

View file

@ -0,0 +1,154 @@
// src/utils/forms/__tests__/ChannelGroupUtils.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
updateChannelGroup,
addChannelGroup,
deleteChannelGroup,
cleanupUnusedChannelGroups,
} from '../ChannelGroupUtils.js';
// ── API mock ───────────────────────────────────────────────────────────────────
vi.mock('../../../api.js', () => ({
default: {
updateChannelGroup: vi.fn(),
addChannelGroup: vi.fn(),
deleteChannelGroup: vi.fn(),
cleanupUnusedChannelGroups: vi.fn(),
},
}));
import API from '../../../api.js';
// ── Helpers ────────────────────────────────────────────────────────────────────
const makeChannelGroup = (overrides = {}) => ({
id: 'group-1',
name: 'Sports',
...overrides,
});
const makeValues = (overrides = {}) => ({
name: 'Updated Sports',
locked: false,
...overrides,
});
describe('ChannelGroupUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── updateChannelGroup ─────────────────────────────────────────────────────
describe('updateChannelGroup', () => {
it('calls API.updateChannelGroup with merged id and values', () => {
const group = makeChannelGroup();
const values = makeValues();
updateChannelGroup(group, values);
expect(API.updateChannelGroup).toHaveBeenCalledWith({
id: 'group-1',
name: 'Updated Sports',
locked: false,
});
});
it('returns the result of API.updateChannelGroup', () => {
const mockResult = { id: 'group-1', name: 'Updated Sports' };
vi.mocked(API.updateChannelGroup).mockReturnValue(mockResult);
const result = updateChannelGroup(makeChannelGroup(), makeValues());
expect(result).toBe(mockResult);
});
it('calls API.updateChannelGroup exactly once', () => {
updateChannelGroup(makeChannelGroup(), makeValues());
expect(API.updateChannelGroup).toHaveBeenCalledTimes(1);
});
});
// ── addChannelGroup ────────────────────────────────────────────────────────
describe('addChannelGroup', () => {
it('calls API.addChannelGroup with the provided values', () => {
const values = makeValues();
addChannelGroup(values);
expect(API.addChannelGroup).toHaveBeenCalledWith(values);
});
it('returns the result of API.addChannelGroup', () => {
const mockResult = { id: 'group-2', name: 'Updated Sports' };
vi.mocked(API.addChannelGroup).mockReturnValue(mockResult);
const result = addChannelGroup(makeValues());
expect(result).toBe(mockResult);
});
it('calls API.addChannelGroup exactly once', () => {
addChannelGroup(makeValues());
expect(API.addChannelGroup).toHaveBeenCalledTimes(1);
});
it('passes values through unmodified', () => {
const values = { name: 'News', locked: true, custom: 'data' };
addChannelGroup(values);
expect(API.addChannelGroup).toHaveBeenCalledWith(values);
});
});
// ── deleteChannelGroup ─────────────────────────────────────────────────────
describe('deleteChannelGroup', () => {
it('calls API.deleteChannelGroup with the group id', () => {
const group = makeChannelGroup();
deleteChannelGroup(group);
expect(API.deleteChannelGroup).toHaveBeenCalledWith('group-1');
});
it('returns the result of API.deleteChannelGroup', () => {
const mockResult = { success: true };
vi.mocked(API.deleteChannelGroup).mockReturnValue(mockResult);
const result = deleteChannelGroup(makeChannelGroup());
expect(result).toBe(mockResult);
});
it('calls API.deleteChannelGroup exactly once', () => {
deleteChannelGroup(makeChannelGroup());
expect(API.deleteChannelGroup).toHaveBeenCalledTimes(1);
});
it('uses only the id from the group object', () => {
const group = makeChannelGroup({ id: 'group-42', name: 'Movies' });
deleteChannelGroup(group);
expect(API.deleteChannelGroup).toHaveBeenCalledWith('group-42');
});
});
// ── cleanupUnusedChannelGroups ─────────────────────────────────────────────
describe('cleanupUnusedChannelGroups', () => {
it('calls API.cleanupUnusedChannelGroups', async () => {
vi.mocked(API.cleanupUnusedChannelGroups).mockResolvedValue(undefined);
await cleanupUnusedChannelGroups();
expect(API.cleanupUnusedChannelGroups).toHaveBeenCalled();
});
it('calls API.cleanupUnusedChannelGroups exactly once', async () => {
vi.mocked(API.cleanupUnusedChannelGroups).mockResolvedValue(undefined);
await cleanupUnusedChannelGroups();
expect(API.cleanupUnusedChannelGroups).toHaveBeenCalledTimes(1);
});
it('returns the resolved value from API.cleanupUnusedChannelGroups', async () => {
const mockResult = { removed: 5 };
vi.mocked(API.cleanupUnusedChannelGroups).mockResolvedValue(mockResult);
const result = await cleanupUnusedChannelGroups();
expect(result).toEqual(mockResult);
});
it('propagates rejection from API.cleanupUnusedChannelGroups', async () => {
vi.mocked(API.cleanupUnusedChannelGroups).mockRejectedValue(
new Error('Server error')
);
await expect(cleanupUnusedChannelGroups()).rejects.toThrow(
'Server error'
);
});
});
});

View file

@ -0,0 +1,498 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
getEPGs,
getChannelsInRange,
getStreamsRegexPreview,
isExpectedOccupantForGroup,
rangeFor,
abortTimers,
getRegexOptions,
computeAutoSyncStart,
isGroupVisible,
} from '../LiveGroupFilterUtils.js';
// ── API mock ─────────────────────────────────────────────────────────────────
vi.mock('../../../api.js', () => ({
default: {
getEPGs: vi.fn(),
getChannelsInRange: vi.fn(),
getStreamsRegexPreview: vi.fn(),
},
}));
import API from '../../../api.js';
// ── Helpers ──────────────────────────────────────────────────────────────────
const makeEpgSource = (overrides = {}) => ({
id: 1,
name: 'Source One',
source_type: 'xmltv',
...overrides,
});
const makeGroup = (overrides = {}) => ({
name: 'Group A',
enabled: true,
auto_channel_sync: true,
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
custom_properties: {},
channel_group: 1,
...overrides,
});
const makeOccupant = (overrides = {}) => ({
auto_created: true,
has_channel_number_override: false,
channel_group_id: 1,
auto_created_by_account_id: 42,
...overrides,
});
const makePlaylist = (overrides = {}) => ({
id: 42,
...overrides,
});
const makeController = () => {
const controller = { signal: {} };
return controller;
};
describe('LiveGroupFilterUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── getEPGs ────────────────────────────────────────────────────────────────
describe('getEPGs', () => {
it('delegates to API.getEPGs', () => {
const result = [makeEpgSource()];
vi.mocked(API.getEPGs).mockResolvedValue(result);
expect(getEPGs()).resolves.toEqual(result);
expect(API.getEPGs).toHaveBeenCalledOnce();
});
});
// ── getChannelsInRange ─────────────────────────────────────────────────────
describe('getChannelsInRange', () => {
it('calls API.getChannelsInRange with start, end, and signal', () => {
const controller = makeController();
const result = [{ id: 1 }];
vi.mocked(API.getChannelsInRange).mockResolvedValue(result);
getChannelsInRange(100, 200, controller);
expect(API.getChannelsInRange).toHaveBeenCalledWith(100, 200, {
signal: controller.signal,
});
});
it('returns the API promise', () => {
const controller = makeController();
const result = [{ id: 1 }];
vi.mocked(API.getChannelsInRange).mockResolvedValue(result);
expect(getChannelsInRange(100, 200, controller)).resolves.toEqual(result);
});
});
// ── getStreamsRegexPreview ─────────────────────────────────────────────────
describe('getStreamsRegexPreview', () => {
it('calls API with correct params when all values provided', () => {
const group = { name: 'Group A' };
const controller = makeController();
const playlist = makePlaylist();
vi.mocked(API.getStreamsRegexPreview).mockResolvedValue([]);
getStreamsRegexPreview(
group,
'find',
'replace',
'match',
'exclude',
controller,
playlist
);
expect(API.getStreamsRegexPreview).toHaveBeenCalledWith('Group A', {
find: 'find',
replace: 'replace',
match: 'match',
exclude: 'exclude',
limit: 10,
signal: controller.signal,
m3uAccountId: 42,
});
});
it('omits find/replace when find is falsy', () => {
const group = { name: 'Group A' };
const controller = makeController();
vi.mocked(API.getStreamsRegexPreview).mockResolvedValue([]);
getStreamsRegexPreview(group, '', 'replace', '', '', controller, null);
expect(API.getStreamsRegexPreview).toHaveBeenCalledWith('Group A', {
find: undefined,
replace: undefined,
match: undefined,
exclude: undefined,
limit: 10,
signal: controller.signal,
m3uAccountId: undefined,
});
});
it('omits replace when find is falsy but keeps match/exclude if truthy', () => {
const group = { name: 'Group A' };
const controller = makeController();
vi.mocked(API.getStreamsRegexPreview).mockResolvedValue([]);
getStreamsRegexPreview(
group,
'',
'',
'match',
'exclude',
controller,
null
);
expect(API.getStreamsRegexPreview).toHaveBeenCalledWith('Group A', {
find: undefined,
replace: undefined,
match: 'match',
exclude: 'exclude',
limit: 10,
signal: controller.signal,
m3uAccountId: undefined,
});
});
});
// ── isExpectedOccupantForGroup ─────────────────────────────────────────────
describe('isExpectedOccupantForGroup', () => {
it('returns false for null occupant', () => {
expect(isExpectedOccupantForGroup(null, 1, makePlaylist())).toBe(false);
});
it('returns false when occupant is not auto_created', () => {
const occupant = makeOccupant({ auto_created: false });
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
false
);
});
it('returns false when occupant has a channel number override', () => {
const occupant = makeOccupant({ has_channel_number_override: true });
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
false
);
});
it('returns false when occupant belongs to a different group', () => {
const occupant = makeOccupant({ channel_group_id: 99 });
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
false
);
});
it('returns false when occupant was created by a different account', () => {
const occupant = makeOccupant({ auto_created_by_account_id: 99 });
expect(
isExpectedOccupantForGroup(occupant, 1, makePlaylist({ id: 42 }))
).toBe(false);
});
it('returns true for a valid expected occupant', () => {
const occupant = makeOccupant();
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
true
);
});
it('returns true when channel_group_id is undefined', () => {
const occupant = makeOccupant({ channel_group_id: undefined });
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
true
);
});
it('returns true when auto_created_by_account_id is undefined', () => {
const occupant = makeOccupant({ auto_created_by_account_id: undefined });
expect(isExpectedOccupantForGroup(occupant, 1, makePlaylist())).toBe(
true
);
});
});
// ── rangeFor ──────────────────────────────────────────────────────────────
describe('rangeFor', () => {
it('returns null when group is disabled', () => {
expect(rangeFor(makeGroup({ enabled: false }))).toBeNull();
});
it('returns null when auto_channel_sync is off', () => {
expect(rangeFor(makeGroup({ auto_channel_sync: false }))).toBeNull();
});
it('returns null when mode is next_available', () => {
const group = makeGroup({
custom_properties: { channel_numbering_mode: 'next_available' },
});
expect(rangeFor(group)).toBeNull();
});
it('returns null when start is not finite', () => {
const group = makeGroup({ auto_sync_channel_start: 'abc' });
expect(rangeFor(group)).toBeNull();
});
it('returns correct range for fixed mode', () => {
const group = makeGroup({
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
custom_properties: { channel_numbering_mode: 'fixed' },
});
expect(rangeFor(group)).toEqual({ start: 100, end: 200, startRaw: 100 });
});
it('uses fallback start for provider mode', () => {
const group = makeGroup({
custom_properties: {
channel_numbering_mode: 'provider',
channel_numbering_fallback: 50,
},
auto_sync_channel_end: 150,
});
expect(rangeFor(group)).toEqual({ start: 50, end: 150, startRaw: 50 });
});
it('uses start as end when end is null', () => {
const group = makeGroup({
auto_sync_channel_start: 100,
auto_sync_channel_end: null,
});
expect(rangeFor(group)).toEqual({ start: 100, end: 100, startRaw: 100 });
});
it('uses start as end when end is empty string', () => {
const group = makeGroup({
auto_sync_channel_start: 100,
auto_sync_channel_end: '',
});
expect(rangeFor(group)).toEqual({ start: 100, end: 100, startRaw: 100 });
});
it('defaults start to 1 when auto_sync_channel_start is undefined', () => {
const group = makeGroup({
auto_sync_channel_start: undefined,
auto_sync_channel_end: 10,
});
expect(rangeFor(group)).toEqual({ start: 1, end: 10, startRaw: 1 });
});
});
// ── abortTimers ───────────────────────────────────────────────────────────
describe('abortTimers', () => {
it('clears all timeouts and aborts all controllers', () => {
const t1 = setTimeout(() => {}, 10000);
const clearSpy = vi.spyOn(globalThis, 'clearTimeout');
const abortFn = vi.fn();
const timerRef = { current: { a: t1 } };
const abortRef = { current: { b: { abort: abortFn } } };
abortTimers(timerRef, abortRef);
expect(clearSpy).toHaveBeenCalledWith(t1);
expect(abortFn).toHaveBeenCalledOnce();
expect(timerRef.current).toEqual({});
expect(abortRef.current).toEqual({});
clearSpy.mockRestore();
});
it('does not throw when abort throws', () => {
const timerRef = { current: {} };
const abortRef = {
current: {
a: {
abort: () => {
throw new Error('already aborted');
},
},
},
};
expect(() => abortTimers(timerRef, abortRef)).not.toThrow();
expect(abortRef.current).toEqual({});
});
});
// ── getRegexOptions ───────────────────────────────────────────────────────
describe('getRegexOptions', () => {
it('returns an object with the four regex fields', () => {
expect(getRegexOptions('find', 'replace', 'filter', 'exclude')).toEqual({
find: 'find',
replace: 'replace',
match: 'filter',
exclude: 'exclude',
});
});
it('preserves empty string values', () => {
expect(getRegexOptions('', '', '', '')).toEqual({
find: '',
replace: '',
match: '',
exclude: '',
});
});
});
// ── computeAutoSyncStart ──────────────────────────────────────────────────
describe('computeAutoSyncStart', () => {
it('returns 1 when no other groups are active', () => {
expect(computeAutoSyncStart([], 1)).toBe(1);
});
it('skips groups with the same id', () => {
const groups = [
makeGroup({
channel_group: 1,
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
}),
];
expect(computeAutoSyncStart(groups, 1)).toBe(1);
});
it('skips disabled groups', () => {
const groups = [
makeGroup({
channel_group: 2,
enabled: false,
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
}),
];
expect(computeAutoSyncStart(groups, 1)).toBe(1);
});
it('skips groups with auto_channel_sync off', () => {
const groups = [
makeGroup({
channel_group: 2,
auto_channel_sync: false,
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
}),
];
expect(computeAutoSyncStart(groups, 1)).toBe(1);
});
it('skips groups with next_available mode', () => {
const groups = [
makeGroup({
channel_group: 2,
custom_properties: { channel_numbering_mode: 'next_available' },
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
}),
];
expect(computeAutoSyncStart(groups, 1)).toBe(1);
});
it('returns upper + 1 of the highest active group range', () => {
const groups = [
makeGroup({
channel_group: 2,
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
}),
];
expect(computeAutoSyncStart(groups, 1)).toBe(201);
});
it('handles multiple groups and picks the highest upper bound', () => {
const groups = [
makeGroup({
channel_group: 2,
auto_sync_channel_start: 100,
auto_sync_channel_end: 200,
}),
makeGroup({
channel_group: 3,
auto_sync_channel_start: 300,
auto_sync_channel_end: 400,
}),
];
expect(computeAutoSyncStart(groups, 1)).toBe(401);
});
it('uses start as end when end is null', () => {
const groups = [
makeGroup({
channel_group: 2,
auto_sync_channel_start: 50,
auto_sync_channel_end: null,
}),
];
expect(computeAutoSyncStart(groups, 1)).toBe(51);
});
it('uses provider fallback for provider mode', () => {
const groups = [
makeGroup({
channel_group: 2,
custom_properties: {
channel_numbering_mode: 'provider',
channel_numbering_fallback: 500,
},
auto_sync_channel_end: 600,
}),
];
expect(computeAutoSyncStart(groups, 1)).toBe(601);
});
});
// ── isGroupVisible ────────────────────────────────────────────────────────
describe('isGroupVisible', () => {
it('returns true when text and status both match', () => {
const group = makeGroup({ name: 'Sports', enabled: true });
expect(isGroupVisible(group, 'sport', 'enabled')).toBe(true);
});
it('returns false when text does not match', () => {
const group = makeGroup({ name: 'Sports', enabled: true });
expect(isGroupVisible(group, 'news', 'all')).toBe(false);
});
it('returns false when status filter is enabled but group is disabled', () => {
const group = makeGroup({ name: 'Sports', enabled: false });
expect(isGroupVisible(group, 'sport', 'enabled')).toBe(false);
});
it('returns false when status filter is disabled but group is enabled', () => {
const group = makeGroup({ name: 'Sports', enabled: true });
expect(isGroupVisible(group, 'sport', 'disabled')).toBe(false);
});
it('returns true for disabled group with disabled filter', () => {
const group = makeGroup({ name: 'Sports', enabled: false });
expect(isGroupVisible(group, 'sport', 'disabled')).toBe(true);
});
it('matches regardless of case', () => {
const group = makeGroup({ name: 'Sports HD', enabled: true });
expect(isGroupVisible(group, 'SPORTS', 'all')).toBe(true);
});
it('returns true with empty text filter', () => {
const group = makeGroup({ name: 'Sports', enabled: true });
expect(isGroupVisible(group, '', 'all')).toBe(true);
});
});
});

View file

@ -0,0 +1,439 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
uploadLogo,
createLogo,
updateLogo,
getResolver,
getUploadErrorMessage,
getUpdateLogoErrorMessage,
validateFileSize,
releaseUrl,
getFilenameWithoutExtension,
} from '../LogoUtils.js';
// ── API mock ───────────────────────────────────────────────────────────────────
vi.mock('../../../api.js', () => ({
default: {
uploadLogo: vi.fn(),
createLogo: vi.fn(),
updateLogo: vi.fn(),
},
}));
// ── @hookform/resolvers/yup mock ───────────────────────────────────────────────
vi.mock('@hookform/resolvers/yup', () => ({
yupResolver: vi.fn((schema) => ({ __resolver: true, schema })),
}));
import API from '../../../api.js';
import { yupResolver } from '@hookform/resolvers/yup';
describe('LogoUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── uploadLogo ─────────────────────────────────────────────────────────────
describe('uploadLogo', () => {
const makeFile = () =>
new File(['content'], 'logo.png', { type: 'image/png' });
const makeValues = (overrides = {}) => ({ name: 'My Logo', ...overrides });
it('calls API.uploadLogo with the selected file and values.name', async () => {
const file = makeFile();
const values = makeValues();
vi.mocked(API.uploadLogo).mockResolvedValue({ id: '1' });
await uploadLogo(file, values);
expect(API.uploadLogo).toHaveBeenCalledWith(file, 'My Logo');
});
it('returns the result of API.uploadLogo', async () => {
const mockResult = { id: '1', name: 'My Logo' };
vi.mocked(API.uploadLogo).mockResolvedValue(mockResult);
const result = await uploadLogo(makeFile(), makeValues());
expect(result).toEqual(mockResult);
});
it('calls API.uploadLogo exactly once', async () => {
vi.mocked(API.uploadLogo).mockResolvedValue({});
await uploadLogo(makeFile(), makeValues());
expect(API.uploadLogo).toHaveBeenCalledTimes(1);
});
it('propagates rejection from API.uploadLogo', async () => {
vi.mocked(API.uploadLogo).mockRejectedValue(new Error('Upload failed'));
await expect(uploadLogo(makeFile(), makeValues())).rejects.toThrow(
'Upload failed'
);
});
});
// ── createLogo ─────────────────────────────────────────────────────────────
describe('createLogo', () => {
const makeValues = () => ({
name: 'New Logo',
url: 'https://example.com/logo.png',
});
it('calls API.createLogo with the provided values', async () => {
const values = makeValues();
vi.mocked(API.createLogo).mockResolvedValue({ id: '2' });
await createLogo(values);
expect(API.createLogo).toHaveBeenCalledWith(values);
});
it('returns the result of API.createLogo', async () => {
const mockResult = { id: '2', name: 'New Logo' };
vi.mocked(API.createLogo).mockResolvedValue(mockResult);
const result = await createLogo(makeValues());
expect(result).toEqual(mockResult);
});
it('calls API.createLogo exactly once', async () => {
vi.mocked(API.createLogo).mockResolvedValue({});
await createLogo(makeValues());
expect(API.createLogo).toHaveBeenCalledTimes(1);
});
it('propagates rejection from API.createLogo', async () => {
vi.mocked(API.createLogo).mockRejectedValue(new Error('Create failed'));
await expect(createLogo(makeValues())).rejects.toThrow('Create failed');
});
});
// ── updateLogo ─────────────────────────────────────────────────────────────
describe('updateLogo', () => {
const makeLogo = (overrides = {}) => ({
id: 'logo-1',
name: 'Old Logo',
...overrides,
});
const makeValues = () => ({
name: 'Updated Logo',
url: 'https://example.com/new.png',
});
it('calls API.updateLogo with the logo id and values', async () => {
const logo = makeLogo();
const values = makeValues();
vi.mocked(API.updateLogo).mockResolvedValue({ id: 'logo-1' });
await updateLogo(logo, values);
expect(API.updateLogo).toHaveBeenCalledWith('logo-1', values);
});
it('returns the result of API.updateLogo', async () => {
const mockResult = { id: 'logo-1', name: 'Updated Logo' };
vi.mocked(API.updateLogo).mockResolvedValue(mockResult);
const result = await updateLogo(makeLogo(), makeValues());
expect(result).toEqual(mockResult);
});
it('calls API.updateLogo exactly once', async () => {
vi.mocked(API.updateLogo).mockResolvedValue({});
await updateLogo(makeLogo(), makeValues());
expect(API.updateLogo).toHaveBeenCalledTimes(1);
});
it('uses only the id from the logo object', async () => {
const logo = makeLogo({ id: 'logo-99', name: 'Irrelevant' });
vi.mocked(API.updateLogo).mockResolvedValue({});
await updateLogo(logo, makeValues());
expect(API.updateLogo).toHaveBeenCalledWith('logo-99', expect.anything());
});
it('propagates rejection from API.updateLogo', async () => {
vi.mocked(API.updateLogo).mockRejectedValue(new Error('Update failed'));
await expect(updateLogo(makeLogo(), makeValues())).rejects.toThrow(
'Update failed'
);
});
});
// ── getResolver ────────────────────────────────────────────────────────────
describe('getResolver', () => {
it('returns the result of yupResolver', () => {
const resolver = getResolver();
expect(yupResolver).toHaveBeenCalledTimes(1);
expect(resolver).toEqual(expect.objectContaining({ __resolver: true }));
});
it('passes a Yup schema to yupResolver', () => {
getResolver();
const [schema] = vi.mocked(yupResolver).mock.calls[0];
expect(schema).toBeDefined();
expect(typeof schema.validate).toBe('function');
});
it('schema validates a valid name and URL', async () => {
getResolver();
const [schema] = vi.mocked(yupResolver).mock.calls[0];
await expect(
schema.validate({
name: 'My Logo',
url: 'https://example.com/logo.png',
})
).resolves.not.toThrow();
});
it('schema rejects missing name', async () => {
getResolver();
const [schema] = vi.mocked(yupResolver).mock.calls[0];
await expect(
schema.validate({ name: '', url: 'https://example.com/logo.png' })
).rejects.toThrow('Name is required');
});
it('schema rejects missing url', async () => {
getResolver();
const [schema] = vi.mocked(yupResolver).mock.calls[0];
await expect(
schema.validate({ name: 'My Logo', url: '' })
).rejects.toThrow('URL is required');
});
it('schema accepts a local /data/logos/ path as url', async () => {
getResolver();
const [schema] = vi.mocked(yupResolver).mock.calls[0];
await expect(
schema.validate({ name: 'Local Logo', url: '/data/logos/my-logo.png' })
).resolves.not.toThrow();
});
it('schema rejects an invalid url that is not a local path', async () => {
getResolver();
const [schema] = vi.mocked(yupResolver).mock.calls[0];
await expect(
schema.validate({ name: 'Bad Logo', url: 'not-a-url' })
).rejects.toThrow('Must be a valid URL or local file path');
});
});
// ── getUploadErrorMessage ──────────────────────────────────────────────────
describe('getUploadErrorMessage', () => {
it('returns timeout message for NETWORK_ERROR code', () => {
const result = getUploadErrorMessage({ code: 'NETWORK_ERROR' });
expect(result).toBe('Upload timed out. Please try again.');
});
it('returns timeout message when message includes "timeout"', () => {
const result = getUploadErrorMessage({
message: 'request timeout occurred',
});
expect(result).toBe('Upload timed out. Please try again.');
});
it('returns file too large message for status 413', () => {
const result = getUploadErrorMessage({ status: 413 });
expect(result).toBe('File too large. Please choose a smaller file.');
});
it('returns body error message when body.error is present', () => {
const result = getUploadErrorMessage({
body: { error: 'Unsupported format' },
});
expect(result).toBe('Unsupported format');
});
it('returns generic message when no specific condition matches', () => {
const result = getUploadErrorMessage({});
expect(result).toBe('Failed to upload logo file');
});
it('prioritizes NETWORK_ERROR over status 413', () => {
const result = getUploadErrorMessage({
code: 'NETWORK_ERROR',
status: 413,
});
expect(result).toBe('Upload timed out. Please try again.');
});
it('prioritizes status 413 over body.error', () => {
const result = getUploadErrorMessage({
status: 413,
body: { error: 'Custom error' },
});
expect(result).toBe('File too large. Please choose a smaller file.');
});
});
// ── getUpdateLogoErrorMessage ──────────────────────────────────────────────
describe('getUpdateLogoErrorMessage', () => {
const makeLogo = () => ({ id: 'logo-1', name: 'My Logo' });
it('returns timeout message for NETWORK_ERROR code', () => {
const result = getUpdateLogoErrorMessage(makeLogo(), {
code: 'NETWORK_ERROR',
});
expect(result).toBe('Request timed out. Please try again.');
});
it('returns timeout message when message includes "timeout"', () => {
const result = getUpdateLogoErrorMessage(makeLogo(), {
message: 'connection timeout',
});
expect(result).toBe('Request timed out. Please try again.');
});
it('returns response data error when present', () => {
const result = getUpdateLogoErrorMessage(makeLogo(), {
response: { data: { error: 'Duplicate name' } },
});
expect(result).toBe('Duplicate name');
});
it('returns "Failed to update logo" when logo is provided and no specific error', () => {
const result = getUpdateLogoErrorMessage(makeLogo(), {});
expect(result).toBe('Failed to update logo');
});
it('returns "Failed to create logo" when logo is null', () => {
const result = getUpdateLogoErrorMessage(null, {});
expect(result).toBe('Failed to create logo');
});
it('returns "Failed to create logo" when logo is undefined', () => {
const result = getUpdateLogoErrorMessage(undefined, {});
expect(result).toBe('Failed to create logo');
});
it('prioritizes NETWORK_ERROR over response.data.error', () => {
const result = getUpdateLogoErrorMessage(makeLogo(), {
code: 'NETWORK_ERROR',
response: { data: { error: 'Custom' } },
});
expect(result).toBe('Request timed out. Please try again.');
});
});
// ── validateFileSize ───────────────────────────────────────────────────────
describe('validateFileSize', () => {
const makeFile = (sizeBytes) => ({ size: sizeBytes });
it('returns true for a file exactly at the 5MB limit', () => {
const file = makeFile(5 * 1024 * 1024);
expect(validateFileSize(file)).toBe(true);
});
it('returns true for a file smaller than 5MB', () => {
const file = makeFile(1024 * 1024);
expect(validateFileSize(file)).toBe(true);
});
it('returns false for a file larger than 5MB', () => {
const file = makeFile(5 * 1024 * 1024 + 1);
expect(validateFileSize(file)).toBe(false);
});
it('returns true for a zero-byte file', () => {
const file = makeFile(0);
expect(validateFileSize(file)).toBe(true);
});
});
// ── releaseUrl ─────────────────────────────────────────────────────────────
describe('releaseUrl', () => {
beforeEach(() => {
URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
delete URL.revokeObjectURL;
});
it('calls URL.revokeObjectURL for a blob URL', () => {
releaseUrl('blob:https://example.com/1234');
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
'blob:https://example.com/1234'
);
});
it('does not call URL.revokeObjectURL for a non-blob URL', () => {
releaseUrl('https://example.com/logo.png');
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
});
it('does not throw when url is null', () => {
expect(() => releaseUrl(null)).not.toThrow();
});
it('does not throw when url is undefined', () => {
expect(() => releaseUrl(undefined)).not.toThrow();
});
it('does not throw when url is an empty string', () => {
expect(() => releaseUrl('')).not.toThrow();
});
});
// ── getFilenameWithoutExtension ────────────────────────────────────────────
describe('getFilenameWithoutExtension', () => {
it('removes a single extension from a filename', () => {
expect(getFilenameWithoutExtension('logo.png')).toBe('logo');
});
it('removes only the last extension from a filename with multiple dots', () => {
expect(getFilenameWithoutExtension('my.logo.png')).toBe('my.logo');
});
it('handles filenames with no extension', () => {
expect(getFilenameWithoutExtension('logo')).toBe('logo');
});
it('handles filenames with a .jpg extension', () => {
expect(getFilenameWithoutExtension('channel-logo.jpg')).toBe(
'channel-logo'
);
});
it('handles filenames with a .svg extension', () => {
expect(getFilenameWithoutExtension('icon.svg')).toBe('icon');
});
it('handles an empty string', () => {
expect(getFilenameWithoutExtension('')).toBe('');
});
it('handles a filename that is just an extension', () => {
expect(getFilenameWithoutExtension('.png')).toBe('');
});
});
});

View file

@ -0,0 +1,230 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
addM3UFilter,
updateM3UFilter,
deleteM3UFilter,
} from '../M3uFilterUtils.js';
// ── API mock ───────────────────────────────────────────────────────────────────
vi.mock('../../../api.js', () => ({
default: {
addM3UFilter: vi.fn(),
updateM3UFilter: vi.fn(),
deleteM3UFilter: vi.fn(),
},
}));
import API from '../../../api.js';
// ── Helpers ────────────────────────────────────────────────────────────────────
const makeM3U = (overrides = {}) => ({
id: 'm3u-1',
name: 'My Playlist',
...overrides,
});
const makeFilter = (overrides = {}) => ({
id: 'filter-1',
name: 'Filter A',
...overrides,
});
const makeValues = (overrides = {}) => ({
keyword: 'sports',
type: 'include',
...overrides,
});
describe('M3uFilterUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── addM3UFilter ───────────────────────────────────────────────────────────
describe('addM3UFilter', () => {
it('calls API.addM3UFilter with the m3u id and values', async () => {
vi.mocked(API.addM3UFilter).mockResolvedValue(undefined);
await addM3UFilter(makeM3U(), makeValues());
expect(API.addM3UFilter).toHaveBeenCalledWith('m3u-1', makeValues());
});
it('calls API.addM3UFilter exactly once', async () => {
vi.mocked(API.addM3UFilter).mockResolvedValue(undefined);
await addM3UFilter(makeM3U(), makeValues());
expect(API.addM3UFilter).toHaveBeenCalledTimes(1);
});
it('uses only the id from the m3u object', async () => {
vi.mocked(API.addM3UFilter).mockResolvedValue(undefined);
const m3u = makeM3U({ id: 'm3u-99', name: 'Irrelevant' });
await addM3UFilter(m3u, makeValues());
expect(API.addM3UFilter).toHaveBeenCalledWith(
'm3u-99',
expect.anything()
);
});
it('passes values through unmodified', async () => {
vi.mocked(API.addM3UFilter).mockResolvedValue(undefined);
const values = { keyword: 'news', type: 'exclude', extra: 'data' };
await addM3UFilter(makeM3U(), values);
expect(API.addM3UFilter).toHaveBeenCalledWith(expect.anything(), values);
});
it('propagates rejection from API.addM3UFilter', async () => {
vi.mocked(API.addM3UFilter).mockRejectedValue(new Error('Add failed'));
await expect(addM3UFilter(makeM3U(), makeValues())).rejects.toThrow(
'Add failed'
);
});
it('resolves without returning a value', async () => {
vi.mocked(API.addM3UFilter).mockResolvedValue({ id: 'filter-2' });
const result = await addM3UFilter(makeM3U(), makeValues());
expect(result).toBeUndefined();
});
});
// ── updateM3UFilter ────────────────────────────────────────────────────────
describe('updateM3UFilter', () => {
it('calls API.updateM3UFilter with the m3u id, filter id, and values', () => {
vi.mocked(API.updateM3UFilter).mockReturnValue({ id: 'filter-1' });
updateM3UFilter(makeM3U(), makeFilter(), makeValues());
expect(API.updateM3UFilter).toHaveBeenCalledWith(
'm3u-1',
'filter-1',
makeValues()
);
});
it('returns the result of API.updateM3UFilter', () => {
const mockResult = { id: 'filter-1', keyword: 'sports' };
vi.mocked(API.updateM3UFilter).mockReturnValue(mockResult);
const result = updateM3UFilter(makeM3U(), makeFilter(), makeValues());
expect(result).toBe(mockResult);
});
it('calls API.updateM3UFilter exactly once', () => {
vi.mocked(API.updateM3UFilter).mockReturnValue({});
updateM3UFilter(makeM3U(), makeFilter(), makeValues());
expect(API.updateM3UFilter).toHaveBeenCalledTimes(1);
});
it('uses only the id from the m3u object', () => {
vi.mocked(API.updateM3UFilter).mockReturnValue({});
const m3u = makeM3U({ id: 'm3u-42', name: 'Irrelevant' });
updateM3UFilter(m3u, makeFilter(), makeValues());
expect(API.updateM3UFilter).toHaveBeenCalledWith(
'm3u-42',
expect.anything(),
expect.anything()
);
});
it('uses only the id from the filter object', () => {
vi.mocked(API.updateM3UFilter).mockReturnValue({});
const filter = makeFilter({ id: 'filter-99', name: 'Irrelevant' });
updateM3UFilter(makeM3U(), filter, makeValues());
expect(API.updateM3UFilter).toHaveBeenCalledWith(
expect.anything(),
'filter-99',
expect.anything()
);
});
it('passes values through unmodified', () => {
vi.mocked(API.updateM3UFilter).mockReturnValue({});
const values = { keyword: 'movies', type: 'exclude', extra: 'data' };
updateM3UFilter(makeM3U(), makeFilter(), values);
expect(API.updateM3UFilter).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
values
);
});
});
// ── deleteM3UFilter ────────────────────────────────────────────────────────
describe('deleteM3UFilter', () => {
it('calls API.deleteM3UFilter with the playlist id and filter id', async () => {
vi.mocked(API.deleteM3UFilter).mockResolvedValue(undefined);
await deleteM3UFilter(makeM3U(), 'filter-1');
expect(API.deleteM3UFilter).toHaveBeenCalledWith('m3u-1', 'filter-1');
});
it('calls API.deleteM3UFilter exactly once', async () => {
vi.mocked(API.deleteM3UFilter).mockResolvedValue(undefined);
await deleteM3UFilter(makeM3U(), 'filter-1');
expect(API.deleteM3UFilter).toHaveBeenCalledTimes(1);
});
it('uses only the id from the playlist object', async () => {
vi.mocked(API.deleteM3UFilter).mockResolvedValue(undefined);
const playlist = makeM3U({ id: 'playlist-99', name: 'Irrelevant' });
await deleteM3UFilter(playlist, 'filter-1');
expect(API.deleteM3UFilter).toHaveBeenCalledWith(
'playlist-99',
expect.anything()
);
});
it('passes the filter id through unmodified', async () => {
vi.mocked(API.deleteM3UFilter).mockResolvedValue(undefined);
await deleteM3UFilter(makeM3U(), 'filter-42');
expect(API.deleteM3UFilter).toHaveBeenCalledWith(
expect.anything(),
'filter-42'
);
});
it('propagates rejection from API.deleteM3UFilter', async () => {
vi.mocked(API.deleteM3UFilter).mockRejectedValue(
new Error('Delete failed')
);
await expect(deleteM3UFilter(makeM3U(), 'filter-1')).rejects.toThrow(
'Delete failed'
);
});
it('resolves without returning a value', async () => {
vi.mocked(API.deleteM3UFilter).mockResolvedValue({ success: true });
const result = await deleteM3UFilter(makeM3U(), 'filter-1');
expect(result).toBeUndefined();
});
});
});

View file

@ -0,0 +1,479 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
buildGroupStates,
saveAndRefreshPlaylist,
} from '../M3uGroupFilterUtils.js';
// ── API mock ───────────────────────────────────────────────────────────────────
vi.mock('../../../api.js', () => ({
default: {
updateM3UGroupSettings: vi.fn(),
},
}));
// ── M3uUtils mock ──────────────────────────────────────────────────────────────
vi.mock('../M3uUtils.js', () => ({
refreshPlaylist: vi.fn(),
updatePlaylist: vi.fn(),
}));
import API from '../../../api.js';
import { refreshPlaylist, updatePlaylist } from '../M3uUtils.js';
// ── Helpers ────────────────────────────────────────────────────────────────────
const makePlaylist = (overrides = {}) => ({
id: 'playlist-1',
name: 'My Playlist',
...overrides,
});
const makeChannelGroups = () => ({
'group-1': { name: 'Sports' },
'group-2': { name: 'News' },
'group-3': { name: 'Movies' },
});
const makePlaylistChannelGroup = (overrides = {}) => ({
channel_group: 'group-1',
enabled: true,
original_enabled: false,
auto_channel_sync: false,
auto_sync_channel_start: 1.0,
custom_properties: null,
...overrides,
});
const makeCategoryState = (overrides = {}) => ({
id: 'cat-1',
enabled: true,
original_enabled: false,
custom_properties: null,
...overrides,
});
const makeAutoEnableSettings = () => ({ auto_enable: true });
describe('M3uGroupFilterUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(updatePlaylist).mockResolvedValue(undefined);
vi.mocked(API.updateM3UGroupSettings).mockResolvedValue(undefined);
vi.mocked(refreshPlaylist).mockResolvedValue(undefined);
});
// ── buildGroupStates ───────────────────────────────────────────────────────
describe('buildGroupStates', () => {
it('maps playlistChannelGroups to group states with channel group names', () => {
const channelGroups = makeChannelGroups();
const playlistChannelGroups = [makePlaylistChannelGroup()];
const result = buildGroupStates(channelGroups, playlistChannelGroups);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('Sports');
});
it('filters out groups whose channel_group key is not in channelGroups', () => {
const channelGroups = makeChannelGroups();
const playlistChannelGroups = [
makePlaylistChannelGroup({ channel_group: 'group-1' }),
makePlaylistChannelGroup({ channel_group: 'group-unknown' }),
];
const result = buildGroupStates(channelGroups, playlistChannelGroups);
expect(result).toHaveLength(1);
expect(result[0].channel_group).toBe('group-1');
});
it('returns an empty array when playlistChannelGroups is empty', () => {
const result = buildGroupStates(makeChannelGroups(), []);
expect(result).toEqual([]);
});
it('returns an empty array when no groups match channelGroups', () => {
const result = buildGroupStates(makeChannelGroups(), [
makePlaylistChannelGroup({ channel_group: 'group-unknown' }),
]);
expect(result).toEqual([]);
});
it('defaults auto_channel_sync to false when not set', () => {
const result = buildGroupStates(makeChannelGroups(), [
makePlaylistChannelGroup({ auto_channel_sync: undefined }),
]);
expect(result[0].auto_channel_sync).toBe(false);
});
it('preserves auto_channel_sync when set to true', () => {
const result = buildGroupStates(makeChannelGroups(), [
makePlaylistChannelGroup({ auto_channel_sync: true }),
]);
expect(result[0].auto_channel_sync).toBe(true);
});
it('defaults auto_sync_channel_start to 1.0 when not set', () => {
const result = buildGroupStates(makeChannelGroups(), [
makePlaylistChannelGroup({ auto_sync_channel_start: undefined }),
]);
expect(result[0].auto_sync_channel_start).toBe(1.0);
});
it('preserves auto_sync_channel_start when set', () => {
const result = buildGroupStates(makeChannelGroups(), [
makePlaylistChannelGroup({ auto_sync_channel_start: 5.0 }),
]);
expect(result[0].auto_sync_channel_start).toBe(5.0);
});
it('spreads all original group properties into the result', () => {
const group = makePlaylistChannelGroup({
enabled: true,
extra_prop: 'value',
});
const result = buildGroupStates(makeChannelGroups(), [group]);
expect(result[0].enabled).toBe(true);
expect(result[0].extra_prop).toBe('value');
});
it('maps multiple groups correctly', () => {
const channelGroups = makeChannelGroups();
const playlistChannelGroups = [
makePlaylistChannelGroup({ channel_group: 'group-1' }),
makePlaylistChannelGroup({ channel_group: 'group-2' }),
makePlaylistChannelGroup({ channel_group: 'group-3' }),
];
const result = buildGroupStates(channelGroups, playlistChannelGroups);
expect(result).toHaveLength(3);
expect(result.map((r) => r.name)).toEqual(['Sports', 'News', 'Movies']);
});
// ── parseCustomProperties (via buildGroupStates) ─────────────────────────
describe('parseCustomProperties (via custom_properties field)', () => {
it('returns {} when custom_properties is null', () => {
const result = buildGroupStates(makeChannelGroups(), [
makePlaylistChannelGroup({ custom_properties: null }),
]);
expect(result[0].custom_properties).toEqual({});
});
it('returns {} when custom_properties is undefined', () => {
const result = buildGroupStates(makeChannelGroups(), [
makePlaylistChannelGroup({ custom_properties: undefined }),
]);
expect(result[0].custom_properties).toEqual({});
});
it('parses a valid JSON string into an object', () => {
const result = buildGroupStates(makeChannelGroups(), [
makePlaylistChannelGroup({ custom_properties: '{"key":"value"}' }),
]);
expect(result[0].custom_properties).toEqual({ key: 'value' });
});
it('returns an already-parsed object as-is', () => {
const result = buildGroupStates(makeChannelGroups(), [
makePlaylistChannelGroup({ custom_properties: { key: 'value' } }),
]);
expect(result[0].custom_properties).toEqual({ key: 'value' });
});
it('returns {} for invalid JSON string', () => {
const result = buildGroupStates(makeChannelGroups(), [
makePlaylistChannelGroup({ custom_properties: '{invalid-json}' }),
]);
expect(result[0].custom_properties).toEqual({});
});
});
});
// ── saveAndRefreshPlaylist ─────────────────────────────────────────────────
describe('saveAndRefreshPlaylist', () => {
it('calls updatePlaylist with playlist and autoEnableSettings', async () => {
const playlist = makePlaylist();
const autoEnableSettings = makeAutoEnableSettings();
await saveAndRefreshPlaylist(playlist, [], [], [], autoEnableSettings);
expect(updatePlaylist).toHaveBeenCalledWith(playlist, autoEnableSettings);
});
it('calls API.updateM3UGroupSettings with playlist id, groupSettings, and categorySettings', async () => {
const playlist = makePlaylist();
await saveAndRefreshPlaylist(
playlist,
[],
[],
[],
makeAutoEnableSettings()
);
expect(API.updateM3UGroupSettings).toHaveBeenCalledWith(
'playlist-1',
expect.anything(),
expect.anything()
);
});
it('calls refreshPlaylist with playlist', async () => {
const playlist = makePlaylist();
await saveAndRefreshPlaylist(
playlist,
[],
[],
[],
makeAutoEnableSettings()
);
expect(refreshPlaylist).toHaveBeenCalledWith(playlist);
});
it('calls updatePlaylist, updateM3UGroupSettings, and refreshPlaylist exactly once each', async () => {
await saveAndRefreshPlaylist(
makePlaylist(),
[],
[],
[],
makeAutoEnableSettings()
);
expect(updatePlaylist).toHaveBeenCalledTimes(1);
expect(API.updateM3UGroupSettings).toHaveBeenCalledTimes(1);
expect(refreshPlaylist).toHaveBeenCalledTimes(1);
});
it('calls updatePlaylist before updateM3UGroupSettings', async () => {
const callOrder = [];
vi.mocked(updatePlaylist).mockImplementation(async () => {
callOrder.push('updatePlaylist');
});
vi.mocked(API.updateM3UGroupSettings).mockImplementation(async () => {
callOrder.push('updateM3UGroupSettings');
});
vi.mocked(refreshPlaylist).mockImplementation(async () => {
callOrder.push('refreshPlaylist');
});
await saveAndRefreshPlaylist(
makePlaylist(),
[],
[],
[],
makeAutoEnableSettings()
);
expect(callOrder.indexOf('updatePlaylist')).toBeLessThan(
callOrder.indexOf('updateM3UGroupSettings')
);
});
it('calls updateM3UGroupSettings before refreshPlaylist', async () => {
const callOrder = [];
vi.mocked(updatePlaylist).mockImplementation(async () => {
callOrder.push('updatePlaylist');
});
vi.mocked(API.updateM3UGroupSettings).mockImplementation(async () => {
callOrder.push('updateM3UGroupSettings');
});
vi.mocked(refreshPlaylist).mockImplementation(async () => {
callOrder.push('refreshPlaylist');
});
await saveAndRefreshPlaylist(
makePlaylist(),
[],
[],
[],
makeAutoEnableSettings()
);
expect(callOrder.indexOf('updateM3UGroupSettings')).toBeLessThan(
callOrder.indexOf('refreshPlaylist')
);
});
it('propagates rejection from updatePlaylist', async () => {
vi.mocked(updatePlaylist).mockRejectedValue(new Error('Update failed'));
await expect(
saveAndRefreshPlaylist(
makePlaylist(),
[],
[],
[],
makeAutoEnableSettings()
)
).rejects.toThrow('Update failed');
});
it('propagates rejection from API.updateM3UGroupSettings', async () => {
vi.mocked(API.updateM3UGroupSettings).mockRejectedValue(
new Error('Settings failed')
);
await expect(
saveAndRefreshPlaylist(
makePlaylist(),
[],
[],
[],
makeAutoEnableSettings()
)
).rejects.toThrow('Settings failed');
});
it('propagates rejection from refreshPlaylist', async () => {
vi.mocked(refreshPlaylist).mockRejectedValue(new Error('Refresh failed'));
await expect(
saveAndRefreshPlaylist(
makePlaylist(),
[],
[],
[],
makeAutoEnableSettings()
)
).rejects.toThrow('Refresh failed');
});
it('does not call refreshPlaylist when updateM3UGroupSettings rejects', async () => {
vi.mocked(API.updateM3UGroupSettings).mockRejectedValue(
new Error('fail')
);
await saveAndRefreshPlaylist(
makePlaylist(),
[],
[],
[],
makeAutoEnableSettings()
).catch(() => {});
expect(refreshPlaylist).not.toHaveBeenCalled();
});
// ── prepareCategorySettings (via categorySettings arg) ───────────────────
describe('prepareCategorySettings (via API.updateM3UGroupSettings call)', () => {
it('includes category states where enabled differs from original_enabled', async () => {
const changedMovie = makeCategoryState({
enabled: true,
original_enabled: false,
});
const unchangedMovie = makeCategoryState({
id: 'cat-2',
enabled: false,
original_enabled: false,
});
await saveAndRefreshPlaylist(
makePlaylist(),
[],
[changedMovie],
[unchangedMovie],
makeAutoEnableSettings()
);
const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings)
.mock.calls[0];
expect(categorySettings).toHaveLength(1);
expect(categorySettings[0].id).toBe('cat-1');
});
it('excludes category states where enabled equals original_enabled', async () => {
const unchanged = makeCategoryState({
enabled: true,
original_enabled: true,
});
await saveAndRefreshPlaylist(
makePlaylist(),
[],
[unchanged],
[],
makeAutoEnableSettings()
);
const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings)
.mock.calls[0];
expect(categorySettings).toHaveLength(0);
});
it('merges movie and series category states together', async () => {
const movieCat = makeCategoryState({
id: 'movie-1',
enabled: true,
original_enabled: false,
});
const seriesCat = makeCategoryState({
id: 'series-1',
enabled: false,
original_enabled: true,
});
await saveAndRefreshPlaylist(
makePlaylist(),
[],
[movieCat],
[seriesCat],
makeAutoEnableSettings()
);
const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings)
.mock.calls[0];
expect(categorySettings).toHaveLength(2);
expect(categorySettings.map((c) => c.id)).toEqual([
'movie-1',
'series-1',
]);
});
it('sets custom_properties to undefined when null', async () => {
const cat = makeCategoryState({
custom_properties: null,
enabled: true,
original_enabled: false,
});
await saveAndRefreshPlaylist(
makePlaylist(),
[],
[cat],
[],
makeAutoEnableSettings()
);
const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings)
.mock.calls[0];
expect(categorySettings[0].custom_properties).toBeUndefined();
});
it('preserves custom_properties when present', async () => {
const cat = makeCategoryState({
custom_properties: { key: 'value' },
enabled: true,
original_enabled: false,
});
await saveAndRefreshPlaylist(
makePlaylist(),
[],
[cat],
[],
makeAutoEnableSettings()
);
const [, , categorySettings] = vi.mocked(API.updateM3UGroupSettings)
.mock.calls[0];
expect(categorySettings[0].custom_properties).toEqual({ key: 'value' });
});
});
});
});

View file

@ -0,0 +1,615 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as Yup from 'yup';
import {
updateM3UProfile,
addM3UProfile,
deleteM3UProfile,
getDetectedMode,
applyRegex,
buildProfileSchema,
fetchFirstStreamUrl,
validateXcSimple,
prepareExpDate,
applyXcSimplePatterns,
buildSubmitValues,
splitByPattern,
} from '../M3uProfileUtils.js';
vi.mock('../../../api.js', () => ({
default: {
updateM3UProfile: vi.fn(),
addM3UProfile: vi.fn(),
deleteM3UProfile: vi.fn(),
queryStreams: vi.fn(),
},
}));
import API from '../../../api.js';
// ── Helpers ────────────────────────────────────────────────────────────────────
const makeM3U = (overrides = {}) => ({
id: 'm3u-1',
name: 'My Playlist',
...overrides,
});
const makeSubmitValues = (overrides = {}) => ({
name: 'Profile A',
xcMode: false,
...overrides,
});
describe('M3uProfileUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── updateM3UProfile ───────────────────────────────────────────────────────
describe('updateM3UProfile', () => {
it('calls API.updateM3UProfile with the m3u id and submitValues', async () => {
vi.mocked(API.updateM3UProfile).mockResolvedValue(undefined);
const m3u = makeM3U();
const values = makeSubmitValues();
await updateM3UProfile(m3u.id, values);
expect(API.updateM3UProfile).toHaveBeenCalledWith(m3u.id, values);
});
it('calls API.updateM3UProfile exactly once', async () => {
vi.mocked(API.updateM3UProfile).mockResolvedValue(undefined);
const m3u = makeM3U();
await updateM3UProfile(m3u.id, makeSubmitValues());
expect(API.updateM3UProfile).toHaveBeenCalledTimes(1);
});
it('uses only the id from the m3u object', async () => {
vi.mocked(API.updateM3UProfile).mockResolvedValue(undefined);
const m3u = makeM3U({ id: 'playlist-99', name: 'Irrelevant' });
await updateM3UProfile(m3u.id, makeSubmitValues());
expect(API.updateM3UProfile).toHaveBeenCalledWith(
m3u.id,
expect.anything()
);
});
it('passes submitValues through unmodified', async () => {
vi.mocked(API.updateM3UProfile).mockResolvedValue(undefined);
const values = makeSubmitValues({ xcMode: true, extra: 'data' });
const m3u = makeM3U();
await updateM3UProfile(m3u.id, values);
expect(API.updateM3UProfile).toHaveBeenCalledWith(
expect.anything(),
values
);
});
it('propagates rejection from API.updateM3UProfile', async () => {
vi.mocked(API.updateM3UProfile).mockRejectedValue(
new Error('Update failed')
);
const m3u = makeM3U();
await expect(
updateM3UProfile(m3u.id, makeSubmitValues())
).rejects.toThrow('Update failed');
});
it('resolves without returning a value', async () => {
vi.mocked(API.updateM3UProfile).mockResolvedValue({ success: true });
const m3u = makeM3U();
const result = await updateM3UProfile(m3u.id, makeSubmitValues());
expect(result).toBeUndefined();
});
});
// ── addM3UProfile ──────────────────────────────────────────────────────────
describe('addM3UProfile', () => {
it('calls API.addM3UProfile with the m3u id and submitValues', async () => {
vi.mocked(API.addM3UProfile).mockResolvedValue(undefined);
const m3u = makeM3U();
const values = makeSubmitValues();
await addM3UProfile(m3u.id, values);
expect(API.addM3UProfile).toHaveBeenCalledWith(m3u.id, values);
});
it('calls API.addM3UProfile exactly once', async () => {
vi.mocked(API.addM3UProfile).mockResolvedValue(undefined);
const m3u = makeM3U();
await addM3UProfile(m3u.id, makeSubmitValues());
expect(API.addM3UProfile).toHaveBeenCalledTimes(1);
});
it('uses only the id from the m3u object', async () => {
vi.mocked(API.addM3UProfile).mockResolvedValue(undefined);
const m3u = makeM3U({ id: 'playlist-42', name: 'Irrelevant' });
await addM3UProfile(m3u.id, makeSubmitValues());
expect(API.addM3UProfile).toHaveBeenCalledWith(m3u.id, expect.anything());
});
it('passes submitValues through unmodified', async () => {
vi.mocked(API.addM3UProfile).mockResolvedValue(undefined);
const values = makeSubmitValues({ xcMode: true, extra: 'data' });
const m3u = makeM3U();
await addM3UProfile(m3u.id, values);
expect(API.addM3UProfile).toHaveBeenCalledWith(expect.anything(), values);
});
it('propagates rejection from API.addM3UProfile', async () => {
vi.mocked(API.addM3UProfile).mockRejectedValue(new Error('Add failed'));
const m3u = makeM3U();
await expect(addM3UProfile(m3u.id, makeSubmitValues())).rejects.toThrow(
'Add failed'
);
});
it('resolves without returning a value', async () => {
vi.mocked(API.addM3UProfile).mockResolvedValue({ id: 'new-profile' });
const m3u = makeM3U();
const result = await addM3UProfile(m3u.id, makeSubmitValues());
expect(result).toBeUndefined();
});
});
// ── deleteM3UProfile ───────────────────────────────────────────────────────
describe('deleteM3UProfile', () => {
it('calls API.deleteM3UProfile with the playlist id and profile id', async () => {
vi.mocked(API.deleteM3UProfile).mockResolvedValue(undefined);
const m3u = makeM3U();
await deleteM3UProfile(m3u.id, 'profile-1');
expect(API.deleteM3UProfile).toHaveBeenCalledWith(m3u.id, 'profile-1');
});
it('calls API.deleteM3UProfile exactly once', async () => {
vi.mocked(API.deleteM3UProfile).mockResolvedValue(undefined);
const m3u = makeM3U();
await deleteM3UProfile(m3u.id, 'profile-1');
expect(API.deleteM3UProfile).toHaveBeenCalledTimes(1);
});
it('uses only the id from the playlist object', async () => {
vi.mocked(API.deleteM3UProfile).mockResolvedValue(undefined);
const playlist = makeM3U({ id: 'playlist-99', name: 'Irrelevant' });
await deleteM3UProfile(playlist.id, 'profile-1');
expect(API.deleteM3UProfile).toHaveBeenCalledWith(
playlist.id,
expect.anything()
);
});
it('passes the profile id through unmodified', async () => {
vi.mocked(API.deleteM3UProfile).mockResolvedValue(undefined);
const m3u = makeM3U();
await deleteM3UProfile(m3u.id, 'profile-42');
expect(API.deleteM3UProfile).toHaveBeenCalledWith(
expect.anything(),
'profile-42'
);
});
it('propagates rejection from API.deleteM3UProfile', async () => {
vi.mocked(API.deleteM3UProfile).mockRejectedValue(
new Error('Delete failed')
);
const m3u = makeM3U();
await expect(deleteM3UProfile(m3u.id, 'profile-1')).rejects.toThrow(
'Delete failed'
);
});
it('resolves without returning a value', async () => {
vi.mocked(API.deleteM3UProfile).mockResolvedValue({ success: true });
const m3u = makeM3U();
const result = await deleteM3UProfile(m3u.id, 'profile-1');
expect(result).toBeUndefined();
});
});
// ── getDetectedMode ───────────────────────────────────────────────────────
describe('getDetectedMode', () => {
it('returns storedMode when provided', () => {
expect(getDetectedMode('advanced', null, null)).toBe('advanced');
});
it('returns "simple" when search_pattern matches username/password', () => {
const m3u = { username: 'user', password: 'pass' };
const profile = { search_pattern: 'user/pass' };
expect(getDetectedMode(null, profile, m3u)).toBe('simple');
});
it('returns "advanced" when search_pattern exists but does not match username/password', () => {
const m3u = { username: 'user', password: 'pass' };
const profile = { search_pattern: 'other/pattern' };
expect(getDetectedMode(null, profile, m3u)).toBe('advanced');
});
it('returns "simple" when no storedMode and no profile', () => {
expect(getDetectedMode(null, null, null)).toBe('simple');
});
it('returns "simple" when profile has no search_pattern', () => {
const profile = {};
expect(
getDetectedMode(null, profile, { username: 'u', password: 'p' })
).toBe('simple');
});
});
// ── applyRegex ───────────────────────────────────────────────────────────
describe('applyRegex', () => {
it('returns input when pattern is empty', () => {
expect(applyRegex('hello world', '', 'X')).toBe('hello world');
});
it('returns input when input is empty/null', () => {
expect(applyRegex(null, 'pattern', 'X')).toBeNull();
expect(applyRegex('', 'pattern', 'X')).toBe('');
});
it('applies regex replacement globally', () => {
expect(applyRegex('aabaa', 'a', 'X')).toBe('XXbXX');
});
it('returns input when regex is invalid', () => {
expect(applyRegex('hello', '[invalid', 'X')).toBe('hello');
});
it('replaces matched groups', () => {
expect(applyRegex('foo/bar', '(foo)', 'baz')).toBe('baz/bar');
});
});
// ── buildProfileSchema ─────────────────────────────────────────────────
describe('buildProfileSchema', () => {
it('requires search_pattern and replace_pattern when not default and not XC', async () => {
const schema = buildProfileSchema(false, false);
await expect(
schema.validate({ name: 'Test' }, { abortEarly: false })
).rejects.toThrow();
const error = await schema
.validate({ name: 'Test' }, { abortEarly: false })
.catch((e) => e);
expect(error.errors).toContain('Search pattern is required');
expect(error.errors).toContain('Replace pattern is required');
});
it('does not require search_pattern and replace_pattern for default profile', async () => {
const schema = buildProfileSchema(true, false);
await expect(schema.validate({ name: 'Test' })).resolves.toBeTruthy();
});
it('does not require search_pattern and replace_pattern when isXC is true', async () => {
const schema = buildProfileSchema(false, true);
await expect(schema.validate({ name: 'Test' })).resolves.toBeTruthy();
});
it('requires name in all cases', async () => {
const schema = buildProfileSchema(true, true);
const error = await schema
.validate({}, { abortEarly: false })
.catch((e) => e);
expect(error.errors).toContain('Name is required');
});
it('validates successfully with all required fields', async () => {
const schema = buildProfileSchema(false, false);
await expect(
schema.validate({
name: 'Test',
search_pattern: 'foo',
replace_pattern: 'bar',
})
).resolves.toBeTruthy();
});
});
// ── fetchFirstStreamUrl ─────────────────────────────────────────────────
describe('fetchFirstStreamUrl', () => {
beforeEach(() => vi.clearAllMocks());
it('returns url from first result', async () => {
API.queryStreams.mockResolvedValue({
results: [{ url: 'http://stream.url' }],
});
const url = await fetchFirstStreamUrl(5);
expect(url).toBe('http://stream.url');
const params = API.queryStreams.mock.calls[0][0];
expect(params.get('page')).toBe('1');
expect(params.get('page_size')).toBe('1');
expect(params.get('m3u_account')).toBe('5');
});
it('returns null when results are empty', async () => {
API.queryStreams.mockResolvedValue({ results: [] });
const url = await fetchFirstStreamUrl(5);
expect(url).toBeNull();
});
it('returns null when response is null', async () => {
API.queryStreams.mockResolvedValue(null);
const url = await fetchFirstStreamUrl(5);
expect(url).toBeNull();
});
});
// ── validateXcSimple ─────────────────────────────────────────────────
describe('validateXcSimple', () => {
it('returns errors when both fields are empty', () => {
const errs = validateXcSimple('', '');
expect(errs.newUsername).toBe('New username is required');
expect(errs.newPassword).toBe('New password is required');
});
it('returns error only for empty username', () => {
const errs = validateXcSimple('', 'pass');
expect(errs.newUsername).toBe('New username is required');
expect(errs.newPassword).toBeUndefined();
});
it('returns error only for empty password', () => {
const errs = validateXcSimple('user', '');
expect(errs.newUsername).toBeUndefined();
expect(errs.newPassword).toBe('New password is required');
});
it('returns no errors when both fields are valid', () => {
const errs = validateXcSimple('user', 'pass');
expect(errs).toEqual({});
});
it('treats whitespace-only values as empty', () => {
const errs = validateXcSimple(' ', ' ');
expect(errs.newUsername).toBe('New username is required');
expect(errs.newPassword).toBe('New password is required');
});
});
// ── prepareExpDate ─────────────────────────────────────────────────
describe('prepareExpDate', () => {
it('returns undefined when isXC is true', () => {
expect(prepareExpDate('2025-01-01', true)).toBeUndefined();
});
it('returns ISO string when value is a Date', () => {
const date = new Date('2025-06-15T00:00:00.000Z');
expect(prepareExpDate(date, false)).toBe(date.toISOString());
});
it('returns the value as-is when it is a string', () => {
expect(prepareExpDate('2025-06-15', false)).toBe('2025-06-15');
});
it('returns null when value is falsy and not XC', () => {
expect(prepareExpDate(null, false)).toBeNull();
expect(prepareExpDate('', false)).toBeNull();
expect(prepareExpDate(undefined, false)).toBeNull();
});
});
// ── applyXcSimplePatterns ───────────────────────────────────────────────
describe('applyXcSimplePatterns', () => {
it('sets search_pattern and replace_pattern from m3u credentials and new credentials', () => {
const m3u = { username: 'oldUser', password: 'oldPass' };
const values = { name: 'Test', notes: 'some note' };
const result = applyXcSimplePatterns(values, m3u, 'newUser', 'newPass');
expect(result.search_pattern).toBe('oldUser/oldPass');
expect(result.replace_pattern).toBe('newUser/newPass');
expect(result.name).toBe('Test');
});
it('handles missing m3u username and password gracefully', () => {
const result = applyXcSimplePatterns({}, null, 'newUser', 'newPass');
expect(result.search_pattern).toBe('/');
expect(result.replace_pattern).toBe('newUser/newPass');
});
it('trims whitespace from newUsername and newPassword', () => {
const m3u = { username: 'u', password: 'p' };
const result = applyXcSimplePatterns(
{},
m3u,
' newUser ',
' newPass '
);
expect(result.replace_pattern).toBe('newUser/newPass');
});
});
// ── buildSubmitValues ─────────────────────────────────────────────────
describe('buildSubmitValues', () => {
const baseValues = {
name: 'Profile Name',
max_streams: 5,
search_pattern: 'foo',
replace_pattern: 'bar',
notes: 'some notes',
};
it('returns name, search_pattern, replace_pattern and custom_properties for default profile', () => {
const profile = { custom_properties: { existing: 'prop' } };
const result = buildSubmitValues(baseValues, profile, true, false, null);
expect(result).toEqual({
name: 'Profile Name',
search_pattern: 'foo',
replace_pattern: 'bar',
custom_properties: { existing: 'prop', notes: 'some notes' },
});
expect(result.max_streams).toBeUndefined();
});
it('returns full values for non-default, non-XC profile', () => {
const profile = { custom_properties: {} };
const result = buildSubmitValues(baseValues, profile, false, false, null);
expect(result).toEqual({
name: 'Profile Name',
max_streams: 5,
search_pattern: 'foo',
replace_pattern: 'bar',
custom_properties: { notes: 'some notes' },
});
});
it('includes xcMode in custom_properties when isXC is true', () => {
const profile = { custom_properties: {} };
const result = buildSubmitValues(
baseValues,
profile,
false,
true,
'simple'
);
expect(result.custom_properties.xcMode).toBe('simple');
});
it('does not include xcMode when isXC is false', () => {
const profile = { custom_properties: {} };
const result = buildSubmitValues(
baseValues,
profile,
false,
false,
'simple'
);
expect(result.custom_properties.xcMode).toBeUndefined();
});
it('handles null or undefined profile custom_properties', () => {
const result = buildSubmitValues(baseValues, null, true, false, null);
expect(result.custom_properties).toEqual({ notes: 'some notes' });
});
it('uses empty string for notes when notes is not provided', () => {
const values = { ...baseValues, notes: undefined };
const result = buildSubmitValues(values, null, true, false, null);
expect(result.custom_properties.notes).toBe('');
});
});
// ── splitByPattern ─────────────────────────────────────────────────────────
describe('splitByPattern', () => {
it('returns null when pattern is empty', () => {
expect(splitByPattern('hello world', '')).toBeNull();
});
it('returns null when input is empty', () => {
expect(splitByPattern('', 'hello')).toBeNull();
});
it('returns null when input is null', () => {
expect(splitByPattern(null, 'hello')).toBeNull();
});
it('returns null when pattern is null', () => {
expect(splitByPattern('hello', null)).toBeNull();
});
it('returns null for an invalid regex pattern', () => {
expect(splitByPattern('hello', '[invalid')).toBeNull();
});
it('returns a single unmatched segment when pattern does not match', () => {
expect(splitByPattern('hello world', 'xyz')).toEqual([
{ text: 'hello world', matched: false },
]);
});
it('returns a single matched segment when entire input matches', () => {
expect(splitByPattern('hello', 'hello')).toEqual([
{ text: 'hello', matched: true },
]);
});
it('splits into unmatched/matched/unmatched segments', () => {
expect(splitByPattern('say hello there', 'hello')).toEqual([
{ text: 'say ', matched: false },
{ text: 'hello', matched: true },
{ text: ' there', matched: false },
]);
});
it('handles multiple matches in the input', () => {
expect(splitByPattern('aXbXc', 'X')).toEqual([
{ text: 'a', matched: false },
{ text: 'X', matched: true },
{ text: 'b', matched: false },
{ text: 'X', matched: true },
{ text: 'c', matched: false },
]);
});
it('handles a match at the start of the input', () => {
expect(splitByPattern('helloworld', 'hello')).toEqual([
{ text: 'hello', matched: true },
{ text: 'world', matched: false },
]);
});
it('handles a match at the end of the input', () => {
expect(splitByPattern('worldhello', 'hello')).toEqual([
{ text: 'world', matched: false },
{ text: 'hello', matched: true },
]);
});
it('handles capture-group patterns', () => {
const result = splitByPattern('foo/bar', '(foo)');
expect(result).toEqual([
{ text: 'foo', matched: true },
{ text: '/bar', matched: false },
]);
});
it('handles case-insensitive flag in pattern', () => {
const result = splitByPattern('Hello World', '(?i)hello');
// invalid flag combo — regex throws — should return null
expect(result).toBeNull();
});
it('does not loop infinitely on zero-length matches', () => {
// zero-length match: pattern matches between every character
const result = splitByPattern('abc', 'x*');
expect(Array.isArray(result)).toBe(true);
});
});
});

View file

@ -0,0 +1,287 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
parseExpirationDate,
isAccountExpired,
getExpirationInfo,
profileSortComparator,
} from '../M3uProfilesUtils.js';
// ── Fixed "now" for deterministic tests ───────────────────────────────────────
const NOW = new Date('2024-06-01T12:00:00Z');
// Unix timestamp helpers
const unixSecondsFromNow = (offsetMs) =>
String(Math.floor((NOW.getTime() + offsetMs) / 1000));
const MS = {
hour: 1000 * 60 * 60,
day: 1000 * 60 * 60 * 24,
};
describe('M3uProfilesUtils', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
// ── parseExpirationDate ────────────────────────────────────────────────────
describe('parseExpirationDate', () => {
const makeProfile = (expDate) => ({
custom_properties: { user_info: { exp_date: expDate } },
});
it('returns a Date object for a valid unix timestamp string', () => {
const expUnix = unixSecondsFromNow(MS.day);
const result = parseExpirationDate(makeProfile(expUnix));
expect(result).toBeInstanceOf(Date);
});
it('returns the correct date for a known unix timestamp', () => {
// 2024-06-02T12:00:00Z = NOW + 1 day
const expUnix = unixSecondsFromNow(MS.day);
const result = parseExpirationDate(makeProfile(expUnix));
expect(result.getTime()).toBeCloseTo(NOW.getTime() + MS.day, -3);
});
it('returns null when profile is null', () => {
expect(parseExpirationDate(null)).toBeNull();
});
it('returns null when profile is undefined', () => {
expect(parseExpirationDate(undefined)).toBeNull();
});
it('returns null when custom_properties is missing', () => {
expect(parseExpirationDate({})).toBeNull();
});
it('returns null when user_info is missing', () => {
expect(parseExpirationDate({ custom_properties: {} })).toBeNull();
});
it('returns null when exp_date is missing', () => {
expect(
parseExpirationDate({ custom_properties: { user_info: {} } })
).toBeNull();
});
it('returns null when exp_date is null', () => {
expect(parseExpirationDate(makeProfile(null))).toBeNull();
});
it('returns null when exp_date is an empty string', () => {
expect(parseExpirationDate(makeProfile(''))).toBeNull();
});
it('returns epoch start when exp_date is zero', () => {
expect(parseExpirationDate(makeProfile('0'))).toEqual(new Date(0));
});
});
// ── isAccountExpired ───────────────────────────────────────────────────────
describe('isAccountExpired', () => {
const makeProfile = (expDate) => ({
custom_properties: { user_info: { exp_date: expDate } },
});
it('returns false when expiration date is in the future', () => {
const expUnix = unixSecondsFromNow(MS.day);
expect(isAccountExpired(makeProfile(expUnix))).toBe(false);
});
it('returns true when expiration date is in the past', () => {
const expUnix = unixSecondsFromNow(-MS.day);
expect(isAccountExpired(makeProfile(expUnix))).toBe(true);
});
it('returns true when expiration date is exactly now (expired at boundary)', () => {
// The exp date equals NOW exactly; NOW < NOW is false, so not expired
// Using -1ms to ensure it's strictly in the past
const pastUnix = unixSecondsFromNow(-1000);
expect(isAccountExpired(makeProfile(pastUnix))).toBe(true);
});
it('returns false when no expiration date is present', () => {
expect(isAccountExpired({})).toBe(false);
});
it('returns false when profile is null', () => {
expect(isAccountExpired(null)).toBe(false);
});
it('returns false when profile is undefined', () => {
expect(isAccountExpired(undefined)).toBe(false);
});
it('returns false when exp_date is empty string', () => {
expect(isAccountExpired(makeProfile(''))).toBe(false);
});
});
// ── getExpirationInfo ──────────────────────────────────────────────────────
describe('getExpirationInfo', () => {
const makeProfile = (expDate) => ({
custom_properties: { user_info: { exp_date: expDate } },
});
it('returns null when no expiration date is present', () => {
expect(getExpirationInfo({})).toBeNull();
});
it('returns null when profile is null', () => {
expect(getExpirationInfo(null)).toBeNull();
});
it('returns { text: "Expired", color: "red" } when date is in the past', () => {
const expUnix = unixSecondsFromNow(-MS.day);
expect(getExpirationInfo(makeProfile(expUnix))).toEqual({
text: 'Expired',
color: 'red',
});
});
it('returns green color when more than 30 days remain', () => {
const expUnix = unixSecondsFromNow(31 * MS.day);
const result = getExpirationInfo(makeProfile(expUnix));
expect(result.color).toBe('green');
expect(result.text).toMatch(/days/);
});
it('returns correct day count for 60 days remaining', () => {
const expUnix = unixSecondsFromNow(60 * MS.day);
const result = getExpirationInfo(makeProfile(expUnix));
expect(result.text).toBe('60 days');
expect(result.color).toBe('green');
});
it('returns yellow color when 830 days remain', () => {
const expUnix = unixSecondsFromNow(15 * MS.day);
const result = getExpirationInfo(makeProfile(expUnix));
expect(result.color).toBe('yellow');
expect(result.text).toBe('15 days');
});
it('returns yellow for exactly 8 days remaining', () => {
const expUnix = unixSecondsFromNow(8 * MS.day);
const result = getExpirationInfo(makeProfile(expUnix));
expect(result.color).toBe('yellow');
});
it('returns orange color when 17 days remain', () => {
const expUnix = unixSecondsFromNow(3 * MS.day);
const result = getExpirationInfo(makeProfile(expUnix));
expect(result.color).toBe('orange');
expect(result.text).toBe('3 days');
});
it('returns orange for exactly 1 day remaining', () => {
const expUnix = unixSecondsFromNow(1 * MS.day);
const result = getExpirationInfo(makeProfile(expUnix));
expect(result.color).toBe('orange');
expect(result.text).toBe('1 days');
});
it('returns red color with hours when less than 1 day remains', () => {
const expUnix = unixSecondsFromNow(5 * MS.hour);
const result = getExpirationInfo(makeProfile(expUnix));
expect(result.color).toBe('red');
expect(result.text).toBe('5h');
});
it('returns "0h" when expiration is imminent (minutes away)', () => {
const expUnix = unixSecondsFromNow(30 * 60 * 1000); // 30 minutes
const result = getExpirationInfo(makeProfile(expUnix));
expect(result.color).toBe('red');
expect(result.text).toBe('0h');
});
it('returns correct hours text for 23 hours remaining', () => {
const expUnix = unixSecondsFromNow(23 * MS.hour);
const result = getExpirationInfo(makeProfile(expUnix));
expect(result.text).toBe('23h');
expect(result.color).toBe('red');
});
});
// ── profileSortComparator ──────────────────────────────────────────────────
describe('profileSortComparator', () => {
const makeProfile = (name, isDefault = false) => ({
name,
is_default: isDefault,
});
it('sorts default profile before non-default', () => {
const defaultProfile = makeProfile('Zebra', true);
const normal = makeProfile('Alpha');
expect(profileSortComparator(defaultProfile, normal)).toBe(-1);
});
it('sorts non-default after default profile', () => {
const normal = makeProfile('Alpha');
const defaultProfile = makeProfile('Zebra', true);
expect(profileSortComparator(normal, defaultProfile)).toBe(1);
});
it('sorts two non-default profiles alphabetically ascending', () => {
const a = makeProfile('Alpha');
const b = makeProfile('Beta');
expect(profileSortComparator(a, b)).toBeLessThan(0);
});
it('sorts two non-default profiles alphabetically descending', () => {
const a = makeProfile('Zebra');
const b = makeProfile('Alpha');
expect(profileSortComparator(a, b)).toBeGreaterThan(0);
});
it('returns 0 for two non-default profiles with the same name', () => {
const a = makeProfile('Same');
const b = makeProfile('Same');
expect(profileSortComparator(a, b)).toBe(0);
});
it('places default profile first when sorting an array', () => {
const profiles = [
makeProfile('Charlie'),
makeProfile('Alpha'),
makeProfile('Default Profile', true),
makeProfile('Beta'),
];
const sorted = [...profiles].sort(profileSortComparator);
expect(sorted[0].is_default).toBe(true);
});
it('sorts remaining profiles alphabetically after default', () => {
const profiles = [
makeProfile('Charlie'),
makeProfile('Alpha'),
makeProfile('Default Profile', true),
makeProfile('Beta'),
];
const sorted = [...profiles].sort(profileSortComparator);
expect(sorted.slice(1).map((p) => p.name)).toEqual([
'Alpha',
'Beta',
'Charlie',
]);
});
it('is stable when no profile is default (pure alphabetical)', () => {
const profiles = [
makeProfile('Zeta'),
makeProfile('Alpha'),
makeProfile('Mu'),
];
const sorted = [...profiles].sort(profileSortComparator);
expect(sorted.map((p) => p.name)).toEqual(['Alpha', 'Mu', 'Zeta']);
});
});
});

View file

@ -0,0 +1,237 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
updatePlaylist,
addPlaylist,
getPlaylist,
refreshPlaylist,
prepareSubmitValues,
} from '../M3uUtils.js';
vi.mock('../../../api.js', () => ({
default: {
updatePlaylist: vi.fn(),
addPlaylist: vi.fn(),
getPlaylist: vi.fn(),
refreshPlaylist: vi.fn(),
},
}));
import API from '../../../api.js';
describe('M3uUtils', () => {
beforeEach(() => vi.clearAllMocks());
// ── updatePlaylist ─────────────────────────────────────────────────────────────
describe('updatePlaylist', () => {
it('calls API.updatePlaylist with merged id, values, and file', () => {
const playlist = { id: 1 };
const values = { name: 'Test' };
const file = new File([''], 'test.m3u');
updatePlaylist(playlist, values, file);
expect(API.updatePlaylist).toHaveBeenCalledWith({
id: 1,
name: 'Test',
file,
});
});
it('returns the result of API.updatePlaylist', async () => {
API.updatePlaylist.mockResolvedValue({ id: 1, name: 'Updated' });
const result = await updatePlaylist({ id: 1 }, { name: 'Updated' }, null);
expect(result).toEqual({ id: 1, name: 'Updated' });
});
it('passes null file when no file provided', () => {
updatePlaylist({ id: 2 }, { name: 'No File' }, null);
expect(API.updatePlaylist).toHaveBeenCalledWith({
id: 2,
name: 'No File',
file: null,
});
});
});
// ── addPlaylist ────────────────────────────────────────────────────────────────
describe('addPlaylist', () => {
it('calls API.addPlaylist with merged values and file', async () => {
const file = new File([''], 'playlist.m3u');
await addPlaylist({ name: 'New' }, file);
expect(API.addPlaylist).toHaveBeenCalledWith({ name: 'New', file });
});
it('returns the result of API.addPlaylist', async () => {
API.addPlaylist.mockResolvedValue({ id: 5, name: 'New' });
const result = await addPlaylist({ name: 'New' }, null);
expect(result).toEqual({ id: 5, name: 'New' });
});
it('passes null file when no file provided', async () => {
await addPlaylist({ name: 'No File' }, null);
expect(API.addPlaylist).toHaveBeenCalledWith({
name: 'No File',
file: null,
});
});
});
// ── getPlaylist ────────────────────────────────────────────────────────────────
describe('getPlaylist', () => {
it('calls API.getPlaylist with the playlist id', async () => {
API.getPlaylist.mockResolvedValue({ id: 3 });
await getPlaylist({ id: 3 });
expect(API.getPlaylist).toHaveBeenCalledWith(3);
});
it('returns the result of API.getPlaylist', async () => {
API.getPlaylist.mockResolvedValue({ id: 3, name: 'Fetched' });
const result = await getPlaylist({ id: 3 });
expect(result).toEqual({ id: 3, name: 'Fetched' });
});
});
// ── refreshPlaylist ────────────────────────────────────────────────────────────
describe('refreshPlaylist', () => {
it('calls API.refreshPlaylist with the playlist id', async () => {
API.refreshPlaylist.mockResolvedValue(undefined);
await refreshPlaylist({ id: 7 });
expect(API.refreshPlaylist).toHaveBeenCalledWith(7);
});
it('returns the result of API.refreshPlaylist', async () => {
API.refreshPlaylist.mockResolvedValue(undefined);
const result = await refreshPlaylist({ id: 7 });
expect(result).toEqual(undefined);
});
});
// ── prepareSubmitValues ────────────────────────────────────────────────────────
describe('prepareSubmitValues', () => {
describe('exp_date handling', () => {
it('deletes exp_date when account_type is XC', () => {
const values = { account_type: 'XC', exp_date: '2025-01-01' };
const result = prepareSubmitValues(values, null);
expect(result.exp_date).toBeUndefined();
});
it('converts Date to ISO string when expDate is a Date instance', () => {
const date = new Date('2025-06-15T00:00:00.000Z');
const values = { account_type: 'M3U', exp_date: null };
const result = prepareSubmitValues(values, date);
expect(result.exp_date).toBe(date.toISOString());
});
it('sets exp_date to null when expDate is not a Date and not XC', () => {
const values = { account_type: 'M3U', exp_date: '2025-01-01' };
const result = prepareSubmitValues(values, null);
expect(result.exp_date).toBeNull();
});
it('sets exp_date to null when expDate is a string', () => {
const values = { account_type: 'M3U' };
const result = prepareSubmitValues(values, '2025-01-01');
expect(result.exp_date).toBeNull();
});
});
describe('cron_expression / refresh_interval handling', () => {
it('sets refresh_interval to 0 when cron_expression is non-empty', () => {
const values = {
account_type: 'M3U',
cron_expression: '0 * * * *',
refresh_interval: 30,
};
const result = prepareSubmitValues(values, null);
expect(result.refresh_interval).toBe(0);
expect(result.cron_expression).toBe('0 * * * *');
});
it('sets cron_expression to empty string when it is blank', () => {
const values = {
account_type: 'M3U',
cron_expression: ' ',
refresh_interval: 30,
};
const result = prepareSubmitValues(values, null);
expect(result.cron_expression).toBe('');
expect(result.refresh_interval).toBe(30);
});
it('sets cron_expression to empty string when it is empty', () => {
const values = {
account_type: 'M3U',
cron_expression: '',
refresh_interval: 60,
};
const result = prepareSubmitValues(values, null);
expect(result.cron_expression).toBe('');
expect(result.refresh_interval).toBe(60);
});
it('handles undefined cron_expression', () => {
const values = { account_type: 'M3U', refresh_interval: 30 };
const result = prepareSubmitValues(values, null);
expect(result.cron_expression).toBe('');
expect(result.refresh_interval).toBe(30);
});
});
describe('password handling for XC', () => {
it('deletes password when account_type is XC and password is empty string', () => {
const values = { account_type: 'XC', password: '' };
const result = prepareSubmitValues(values, null);
expect(result.password).toBeUndefined();
});
it('keeps password when account_type is XC and password is non-empty', () => {
const values = { account_type: 'XC', password: 'secret' };
const result = prepareSubmitValues(values, null);
expect(result.password).toBe('secret');
});
it('keeps password when account_type is not XC and password is empty', () => {
const values = { account_type: 'M3U', password: '' };
const result = prepareSubmitValues(values, null);
expect(result.password).toBe('');
});
});
describe('user_agent handling', () => {
it('sets user_agent to null when value is "0"', () => {
const values = { account_type: 'M3U', user_agent: '0' };
const result = prepareSubmitValues(values, null);
expect(result.user_agent).toBeNull();
});
it('keeps user_agent when it is not "0"', () => {
const values = { account_type: 'M3U', user_agent: 'Mozilla/5.0' };
const result = prepareSubmitValues(values, null);
expect(result.user_agent).toBe('Mozilla/5.0');
});
it('keeps user_agent when it is undefined', () => {
const values = { account_type: 'M3U' };
const result = prepareSubmitValues(values, null);
expect(result.user_agent).toBeUndefined();
});
});
describe('does not mutate original values', () => {
it('returns a new object and does not mutate the input', () => {
const values = {
account_type: 'M3U',
cron_expression: '',
refresh_interval: 30,
user_agent: '0',
};
const original = { ...values };
prepareSubmitValues(values, null);
expect(values).toEqual(original);
});
});
});
});

View file

@ -2,7 +2,15 @@ import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js';
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../networkUtils.js';
// Default CIDR ranges for M3U/EPG endpoints (local networks only)
const M3U_EPG_DEFAULTS = ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '::1/128', 'fc00::/7', 'fe80::/10'];
const M3U_EPG_DEFAULTS = [
'127.0.0.0/8',
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'::1/128',
'fc00::/7',
'fe80::/10',
];
const OPEN_DEFAULTS = ['0.0.0.0/0', '::/0'];
const isValidEntry = (entry) =>
@ -22,7 +30,9 @@ export const getNetworkAccessFormValidation = () =>
Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
acc[key] = (tags) => {
if (!tags || tags.length === 0) return null;
return tags.some((t) => !isValidEntry(t)) ? 'Invalid IP address or CIDR range' : null;
return tags.some((t) => !isValidEntry(t))
? 'Invalid IP address or CIDR range'
: null;
};
return acc;
}, {});

View file

@ -109,9 +109,13 @@ describe('NetworkAccessFormUtils', () => {
NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator(['192.168.1.256.1/24'])).toBe('Invalid IP address or CIDR range');
expect(validator(['192.168.1.256.1/24'])).toBe(
'Invalid IP address or CIDR range'
);
expect(validator(['invalid'])).toBe('Invalid IP address or CIDR range');
expect(validator(['192.168.1.0/256'])).toBe('Invalid IP address or CIDR range');
expect(validator(['192.168.1.0/256'])).toBe(
'Invalid IP address or CIDR range'
);
});
it('should return error when any entry in the list is invalid', () => {
@ -119,8 +123,12 @@ describe('NetworkAccessFormUtils', () => {
NetworkAccessFormUtils.getNetworkAccessFormValidation();
const validator = validation['network-access-admin'];
expect(validator(['192.168.1.0/24', 'invalid'])).toBe('Invalid IP address or CIDR range');
expect(validator(['invalid', '192.168.1.0/24'])).toBe('Invalid IP address or CIDR range');
expect(validator(['192.168.1.0/24', 'invalid'])).toBe(
'Invalid IP address or CIDR range'
);
expect(validator(['invalid', '192.168.1.0/24'])).toBe(
'Invalid IP address or CIDR range'
);
expect(validator(['192.168.1.0/24', '10.0.0.0/8', 'invalid'])).toBe(
'Invalid IP address or CIDR range'
);