mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Merge pull request #1219 from nick4810/tests/frontend-unit-tests
Tests/frontend unit tests
This commit is contained in:
commit
21b32f302c
56 changed files with 13262 additions and 2288 deletions
756
frontend/src/components/forms/AutoSyncAdvanced.jsx
Normal file
756
frontend/src/components/forms/AutoSyncAdvanced.jsx
Normal 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;
|
||||
181
frontend/src/components/forms/AutoSyncBasic.jsx
Normal file
181
frontend/src/components/forms/AutoSyncBasic.jsx
Normal 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;
|
||||
77
frontend/src/components/forms/AutoSyncOrphanCleanup.jsx
Normal file
77
frontend/src/components/forms/AutoSyncOrphanCleanup.jsx
Normal 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;
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
429
frontend/src/components/forms/__tests__/AutoSyncBasic.test.jsx
Normal file
429
frontend/src/components/forms/__tests__/AutoSyncBasic.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
811
frontend/src/components/forms/__tests__/GroupManager.test.jsx
Normal file
811
frontend/src/components/forms/__tests__/GroupManager.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
475
frontend/src/components/forms/__tests__/LoginForm.test.jsx
Normal file
475
frontend/src/components/forms/__tests__/LoginForm.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
518
frontend/src/components/forms/__tests__/Logo.test.jsx
Normal file
518
frontend/src/components/forms/__tests__/Logo.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
708
frontend/src/components/forms/__tests__/M3U.test.jsx
Normal file
708
frontend/src/components/forms/__tests__/M3U.test.jsx
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
410
frontend/src/components/forms/__tests__/M3UFilter.test.jsx
Normal file
410
frontend/src/components/forms/__tests__/M3UFilter.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
594
frontend/src/components/forms/__tests__/M3UFilters.test.jsx
Normal file
594
frontend/src/components/forms/__tests__/M3UFilters.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
441
frontend/src/components/forms/__tests__/M3UGroupFilter.test.jsx
Normal file
441
frontend/src/components/forms/__tests__/M3UGroupFilter.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
824
frontend/src/components/forms/__tests__/M3UProfile.test.jsx
Normal file
824
frontend/src/components/forms/__tests__/M3UProfile.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
559
frontend/src/components/forms/__tests__/M3UProfiles.test.jsx
Normal file
559
frontend/src/components/forms/__tests__/M3UProfiles.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
50
frontend/src/utils/forms/AutoSyncAdvancedUtils.js
Normal file
50
frontend/src/utils/forms/AutoSyncAdvancedUtils.js
Normal 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'}`;
|
||||
};
|
||||
25
frontend/src/utils/forms/AutoSyncBasicUtils.js
Normal file
25
frontend/src/utils/forms/AutoSyncBasicUtils.js
Normal 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)));
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
17
frontend/src/utils/forms/ChannelGroupUtils.js
Normal file
17
frontend/src/utils/forms/ChannelGroupUtils.js
Normal 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();
|
||||
};
|
||||
140
frontend/src/utils/forms/LiveGroupFilterUtils.js
Normal file
140
frontend/src/utils/forms/LiveGroupFilterUtils.js
Normal 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;
|
||||
};
|
||||
78
frontend/src/utils/forms/LogoUtils.js
Normal file
78
frontend/src/utils/forms/LogoUtils.js
Normal 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(/\.[^/.]+$/, '');
|
||||
};
|
||||
11
frontend/src/utils/forms/M3uFilterUtils.js
Normal file
11
frontend/src/utils/forms/M3uFilterUtils.js
Normal 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);
|
||||
};
|
||||
70
frontend/src/utils/forms/M3uGroupFilterUtils.js
Normal file
70
frontend/src/utils/forms/M3uGroupFilterUtils.js
Normal 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);
|
||||
};
|
||||
156
frontend/src/utils/forms/M3uProfileUtils.js
Normal file
156
frontend/src/utils/forms/M3uProfileUtils.js
Normal 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 } : {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
37
frontend/src/utils/forms/M3uProfilesUtils.js
Normal file
37
frontend/src/utils/forms/M3uProfilesUtils.js
Normal 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);
|
||||
};
|
||||
54
frontend/src/utils/forms/M3uUtils.js
Normal file
54
frontend/src/utils/forms/M3uUtils.js
Normal 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;
|
||||
};
|
||||
257
frontend/src/utils/forms/__tests__/AutoSyncAdvancedUtils.test.js
Normal file
257
frontend/src/utils/forms/__tests__/AutoSyncAdvancedUtils.test.js
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
158
frontend/src/utils/forms/__tests__/AutoSyncBasicUtils.test.js
Normal file
158
frontend/src/utils/forms/__tests__/AutoSyncBasicUtils.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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' }]);
|
||||
|
|
|
|||
154
frontend/src/utils/forms/__tests__/ChannelGroupUtils.test.js
Normal file
154
frontend/src/utils/forms/__tests__/ChannelGroupUtils.test.js
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
498
frontend/src/utils/forms/__tests__/LiveGroupFilterUtils.test.js
Normal file
498
frontend/src/utils/forms/__tests__/LiveGroupFilterUtils.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
439
frontend/src/utils/forms/__tests__/LogoUtils.test.js
Normal file
439
frontend/src/utils/forms/__tests__/LogoUtils.test.js
Normal 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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
230
frontend/src/utils/forms/__tests__/M3uFilterUtils.test.js
Normal file
230
frontend/src/utils/forms/__tests__/M3uFilterUtils.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
479
frontend/src/utils/forms/__tests__/M3uGroupFilterUtils.test.js
Normal file
479
frontend/src/utils/forms/__tests__/M3uGroupFilterUtils.test.js
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
615
frontend/src/utils/forms/__tests__/M3uProfileUtils.test.js
Normal file
615
frontend/src/utils/forms/__tests__/M3uProfileUtils.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
287
frontend/src/utils/forms/__tests__/M3uProfilesUtils.test.js
Normal file
287
frontend/src/utils/forms/__tests__/M3uProfilesUtils.test.js
Normal 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 8–30 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 1–7 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
237
frontend/src/utils/forms/__tests__/M3uUtils.test.js
Normal file
237
frontend/src/utils/forms/__tests__/M3uUtils.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}, {});
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue