Dispatcharr/apps/m3u/tests/test_xc_empty_fetch_guard.py
Jacob Lasky 808fadfef2 fix(m3u): abort XC refresh on empty provider fetch to prevent channel wipe
When an Xtream Codes provider returns no live streams on a routine refresh
(a transient upstream failure, a fetch exception, or no enabled category
matching), collect_xc_streams() returns an empty list. The refresh then fell
through to stale-marking and sync_auto_channels, which -- with nothing seen
this refresh -- deletes the account's entire auto-created channel lineup via
its per-group "no streams remaining" branch.

Guard the XC branch of _refresh_single_m3u_account_impl: on an empty result,
set the account to ERROR and return before stale-marking and auto-sync,
mirroring the standard-path empty/failed-download guards already in the
function. A transient empty fetch can no longer destroy channels.

Adds apps/m3u/tests/test_xc_empty_fetch_guard.py covering both the abort
(sync not called, channels preserved, streams not marked stale, status ERROR)
and the healthy path (non-empty fetch still runs sync).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:02:01 -04:00

131 lines
5.1 KiB
Python

"""
Regression test for the Xtream Codes empty-fetch channel wipe.
Bug: when an XC provider returns no live streams on a routine refresh (a
transient upstream failure, a fetch exception, or no enabled category
matching), ``collect_xc_streams`` returns ``[]`` and the refresh used to fall
through to stale-marking and ``sync_auto_channels``. With nothing "seen" this
refresh, auto-sync deletes the account's entire auto-created channel lineup.
Fix: ``_refresh_single_m3u_account_impl`` aborts the XC branch when
``collect_xc_streams`` returns empty, setting the account to ERROR before any
stale-marking or auto-sync runs, mirroring the standard-path empty guards.
"""
from unittest.mock import MagicMock, patch
from django.test import TransactionTestCase
from django.utils import timezone
from apps.channels.models import (
Channel,
ChannelGroup,
ChannelGroupM3UAccount,
Stream,
)
from apps.m3u.models import M3UAccount
from apps.m3u.tasks import _refresh_single_m3u_account_impl
class XCEmptyFetchGuardTests(TransactionTestCase):
def _setup_xc_account_with_auto_channel(self):
account = M3UAccount.objects.create(
name="Test XC Provider",
server_url="http://example.com",
username="user",
password="pass",
account_type=M3UAccount.Types.XC,
is_active=True,
)
group = ChannelGroup.objects.create(name="Sports")
ChannelGroupM3UAccount.objects.create(
m3u_account=account,
channel_group=group,
enabled=True,
auto_channel_sync=True,
auto_sync_channel_start=100,
custom_properties={"xc_id": "123"},
)
# A pre-existing stream and the auto-created channel built from it on a
# prior healthy refresh -- this is exactly what the bug deletes.
stream = Stream.objects.create(
name="ESPN",
url="http://example.com/espn.m3u8",
m3u_account=account,
channel_group=group,
last_seen=timezone.now(),
is_stale=False,
)
channel = Channel.objects.create(
channel_number=100,
name="ESPN",
channel_group=group,
auto_created=True,
auto_created_by=account,
)
return account, group, stream, channel
@patch("apps.m3u.tasks.sync_auto_channels")
@patch("apps.m3u.tasks.collect_xc_streams", return_value=[])
@patch("apps.m3u.tasks.refresh_m3u_groups")
def test_empty_xc_fetch_aborts_before_sync_and_preserves_channels(
self, mock_refresh_groups, _mock_collect, mock_sync
):
account, group, stream, channel = self._setup_xc_account_with_auto_channel()
# XC refresh: empty extinf_data is normal, groups must be present.
mock_refresh_groups.return_value = ([], {"Sports": group.id})
result = _refresh_single_m3u_account_impl(account.id)
# The refresh aborts, so auto channel sync never runs.
mock_sync.assert_not_called()
# The auto-created channel survives the empty fetch.
self.assertTrue(Channel.objects.filter(pk=channel.pk).exists())
# The stream is not marked stale (stale-marking is skipped on abort).
stream.refresh_from_db()
self.assertFalse(stream.is_stale)
# The account is surfaced as errored, not silently "successful".
account.refresh_from_db()
self.assertEqual(account.status, M3UAccount.Status.ERROR)
self.assertIn("no streams returned from provider", result)
@patch("apps.m3u.tasks.log_system_event")
@patch("apps.m3u.tasks.send_m3u_update")
@patch("apps.m3u.tasks.cleanup_stale_group_relationships")
@patch("apps.m3u.tasks.cleanup_streams", return_value=0)
@patch("apps.m3u.tasks.process_m3u_batch_direct", return_value="1 created, 0 updated")
@patch("apps.m3u.tasks.sync_auto_channels")
@patch("apps.m3u.tasks.refresh_m3u_groups")
def test_non_empty_xc_fetch_still_runs_sync(
self,
mock_refresh_groups,
mock_sync,
_mock_process,
_mock_cleanup_streams,
_mock_cleanup_groups,
_mock_ws,
_mock_log,
):
# The guard must not fire on a healthy refresh: a non-empty fetch
# proceeds to auto channel sync as before.
account, group, _stream, _channel = self._setup_xc_account_with_auto_channel()
mock_refresh_groups.return_value = ([], {"Sports": group.id})
mock_sync.return_value = {
"status": "ok",
"channels_created": 1,
"channels_updated": 0,
"channels_deleted": 0,
"channels_failed": 0,
"failed_stream_details": [],
}
xc_stream = {
"name": "ESPN",
"url": "http://example.com/espn.m3u8",
"attributes": {"group-title": "Sports", "stream_id": "1"},
}
with patch("apps.m3u.tasks.collect_xc_streams", return_value=[xc_stream]):
_refresh_single_m3u_account_impl(account.id)
mock_sync.assert_called_once()
account.refresh_from_db()
self.assertEqual(account.status, M3UAccount.Status.SUCCESS)