Proyecto LCX Dispatcharr multicuenta
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Tests for evaluate_profile_expiration_notification.
|
||||
|
||||
Covers all four branches:
|
||||
- no exp_date → returns None, touches nothing
|
||||
- already expired → creates/updates expired notification, removes warning
|
||||
- expiring within 7d → creates/updates warning notification, removes expired
|
||||
- not expiring soon → removes any stale notifications, returns None
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def _make_profile(exp_date, profile_id=1, profile_name="Test Profile",
|
||||
account_id=10, account_name="Test Account"):
|
||||
"""Return a minimal mock M3UAccountProfile."""
|
||||
profile = MagicMock()
|
||||
profile.id = profile_id
|
||||
profile.name = profile_name
|
||||
profile.exp_date = exp_date
|
||||
profile.m3u_account.id = account_id
|
||||
profile.m3u_account.name = account_name
|
||||
return profile
|
||||
|
||||
|
||||
class EvaluateProfileExpirationNotificationTests(SimpleTestCase):
|
||||
|
||||
def setUp(self):
|
||||
# These three names are local imports inside evaluate_profile_expiration_notification,
|
||||
# so we must patch them at their source modules rather than on apps.m3u.tasks.
|
||||
self.mock_sn = MagicMock()
|
||||
self.mock_send_ws = patch("core.utils.send_websocket_notification").start()
|
||||
self.mock_dismissed = patch("core.utils.send_notification_dismissed").start()
|
||||
patch("core.models.SystemNotification", self.mock_sn).start()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
|
||||
def _run(self, profile):
|
||||
from apps.m3u.tasks import evaluate_profile_expiration_notification
|
||||
return evaluate_profile_expiration_notification(profile)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# No expiration date
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_no_exp_date_returns_none(self):
|
||||
profile = _make_profile(exp_date=None)
|
||||
result = self._run(profile)
|
||||
self.assertIsNone(result)
|
||||
self.mock_sn.objects.update_or_create.assert_not_called()
|
||||
self.mock_send_ws.assert_not_called()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Already expired
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@patch("apps.m3u.tasks.timezone")
|
||||
def test_expired_creates_expired_notification(self, mock_tz):
|
||||
now = timezone.now()
|
||||
mock_tz.now.return_value = now
|
||||
mock_tz.timedelta = timedelta
|
||||
|
||||
profile = _make_profile(exp_date=now - timedelta(days=1))
|
||||
# No existing warning notification to delete
|
||||
self.mock_sn.objects.filter.return_value.values_list.return_value = []
|
||||
notification = MagicMock()
|
||||
self.mock_sn.objects.update_or_create.return_value = (notification, True)
|
||||
|
||||
result = self._run(profile)
|
||||
|
||||
self.assertEqual(result, f"xc-exp-expired-{profile.id}")
|
||||
self.mock_sn.objects.update_or_create.assert_called_once()
|
||||
call_kwargs = self.mock_sn.objects.update_or_create.call_args
|
||||
self.assertEqual(call_kwargs.kwargs["notification_key"], f"xc-exp-expired-{profile.id}")
|
||||
self.assertTrue(call_kwargs.kwargs["defaults"]["admin_only"])
|
||||
self.mock_send_ws.assert_called_once_with(notification)
|
||||
|
||||
@patch("apps.m3u.tasks.timezone")
|
||||
def test_expired_removes_stale_warning_notification(self, mock_tz):
|
||||
now = timezone.now()
|
||||
mock_tz.now.return_value = now
|
||||
mock_tz.timedelta = timedelta
|
||||
|
||||
profile = _make_profile(exp_date=now - timedelta(hours=1))
|
||||
warning_key = f"xc-exp-warning-{profile.id}"
|
||||
# Simulate an existing warning notification
|
||||
self.mock_sn.objects.filter.return_value.values_list.return_value = [warning_key]
|
||||
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), False)
|
||||
|
||||
self._run(profile)
|
||||
|
||||
self.mock_dismissed.assert_any_call(warning_key)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Expiring within 7 days
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@patch("apps.m3u.tasks.timezone")
|
||||
def test_warning_window_creates_warning_notification(self, mock_tz):
|
||||
now = timezone.now()
|
||||
mock_tz.now.return_value = now
|
||||
mock_tz.timedelta = timedelta
|
||||
|
||||
profile = _make_profile(exp_date=now + timedelta(days=3))
|
||||
self.mock_sn.objects.filter.return_value.values_list.return_value = []
|
||||
notification = MagicMock()
|
||||
self.mock_sn.objects.update_or_create.return_value = (notification, True)
|
||||
|
||||
result = self._run(profile)
|
||||
|
||||
self.assertEqual(result, f"xc-exp-warning-{profile.id}")
|
||||
call_kwargs = self.mock_sn.objects.update_or_create.call_args
|
||||
self.assertEqual(call_kwargs.kwargs["notification_key"], f"xc-exp-warning-{profile.id}")
|
||||
self.assertTrue(call_kwargs.kwargs["defaults"]["admin_only"])
|
||||
self.mock_send_ws.assert_called_once_with(notification)
|
||||
|
||||
@patch("apps.m3u.tasks.timezone")
|
||||
def test_warning_message_says_today_when_same_day(self, mock_tz):
|
||||
now = timezone.now()
|
||||
mock_tz.now.return_value = now
|
||||
mock_tz.timedelta = timedelta
|
||||
|
||||
profile = _make_profile(exp_date=now + timedelta(hours=2))
|
||||
self.mock_sn.objects.filter.return_value.values_list.return_value = []
|
||||
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True)
|
||||
|
||||
self._run(profile)
|
||||
|
||||
defaults = self.mock_sn.objects.update_or_create.call_args.kwargs["defaults"]
|
||||
self.assertIn("today", defaults["message"])
|
||||
|
||||
@patch("apps.m3u.tasks.timezone")
|
||||
def test_warning_message_says_1_day(self, mock_tz):
|
||||
now = timezone.now()
|
||||
mock_tz.now.return_value = now
|
||||
mock_tz.timedelta = timedelta
|
||||
|
||||
profile = _make_profile(exp_date=now + timedelta(hours=30))
|
||||
self.mock_sn.objects.filter.return_value.values_list.return_value = []
|
||||
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True)
|
||||
|
||||
self._run(profile)
|
||||
|
||||
defaults = self.mock_sn.objects.update_or_create.call_args.kwargs["defaults"]
|
||||
self.assertIn("in 1 day", defaults["message"])
|
||||
|
||||
@patch("apps.m3u.tasks.timezone")
|
||||
def test_warning_removes_stale_expired_notification(self, mock_tz):
|
||||
now = timezone.now()
|
||||
mock_tz.now.return_value = now
|
||||
mock_tz.timedelta = timedelta
|
||||
|
||||
profile = _make_profile(exp_date=now + timedelta(days=5))
|
||||
expired_key = f"xc-exp-expired-{profile.id}"
|
||||
self.mock_sn.objects.filter.return_value.values_list.return_value = [expired_key]
|
||||
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), False)
|
||||
|
||||
self._run(profile)
|
||||
|
||||
self.mock_dismissed.assert_any_call(expired_key)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Not expiring soon (> 7 days away)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@patch("apps.m3u.tasks.timezone")
|
||||
def test_not_expiring_soon_returns_none(self, mock_tz):
|
||||
now = timezone.now()
|
||||
mock_tz.now.return_value = now
|
||||
mock_tz.timedelta = timedelta
|
||||
|
||||
profile = _make_profile(exp_date=now + timedelta(days=30))
|
||||
self.mock_sn.objects.filter.return_value.values_list.return_value = []
|
||||
|
||||
result = self._run(profile)
|
||||
|
||||
self.assertIsNone(result)
|
||||
self.mock_sn.objects.update_or_create.assert_not_called()
|
||||
self.mock_send_ws.assert_not_called()
|
||||
|
||||
@patch("apps.m3u.tasks.timezone")
|
||||
def test_not_expiring_soon_removes_stale_notifications(self, mock_tz):
|
||||
now = timezone.now()
|
||||
mock_tz.now.return_value = now
|
||||
mock_tz.timedelta = timedelta
|
||||
|
||||
profile = _make_profile(exp_date=now + timedelta(days=30))
|
||||
warning_key = f"xc-exp-warning-{profile.id}"
|
||||
self.mock_sn.objects.filter.return_value.values_list.return_value = [warning_key]
|
||||
|
||||
self._run(profile)
|
||||
|
||||
self.mock_dismissed.assert_called_once_with(warning_key)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Boundary: exactly at the 7-day warning threshold
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@patch("apps.m3u.tasks.timezone")
|
||||
def test_exactly_7_days_away_triggers_warning(self, mock_tz):
|
||||
now = timezone.now()
|
||||
mock_tz.now.return_value = now
|
||||
mock_tz.timedelta = timedelta
|
||||
|
||||
# exp_date == now + 7 days → exp <= warning_threshold → warning
|
||||
profile = _make_profile(exp_date=now + timedelta(days=7))
|
||||
self.mock_sn.objects.filter.return_value.values_list.return_value = []
|
||||
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True)
|
||||
|
||||
result = self._run(profile)
|
||||
|
||||
self.assertEqual(result, f"xc-exp-warning-{profile.id}")
|
||||
@@ -0,0 +1,41 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.m3u.tasks import parse_extinf_line
|
||||
|
||||
|
||||
class ParseExtinfLineTests(SimpleTestCase):
|
||||
def test_preserves_equals_padding_in_tvg_logo(self):
|
||||
line = (
|
||||
'#EXTINF:-1 tvg-id="cp_891ee08a2cdfde210ec2c9137127103b" '
|
||||
'tvg-chno="1001" '
|
||||
'tvg-name="UK Sky Sports Premier League" '
|
||||
'tvg-logo="https://e3.365dm.com/tvlogos/channels/1303-Logo.png?'
|
||||
'U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==" '
|
||||
'group-title="Team Games",UK Sky Sports Premier League'
|
||||
)
|
||||
|
||||
parsed = parse_extinf_line(line)
|
||||
|
||||
self.assertIsNotNone(parsed)
|
||||
self.assertEqual(
|
||||
parsed["attributes"]["tvg-logo"],
|
||||
"https://e3.365dm.com/tvlogos/channels/1303-Logo.png?U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==",
|
||||
)
|
||||
self.assertEqual(parsed["attributes"]["group-title"], "Team Games")
|
||||
self.assertEqual(parsed["name"], "UK Sky Sports Premier League")
|
||||
|
||||
def test_supports_single_quoted_attributes(self):
|
||||
line = (
|
||||
"#EXTINF:-1 tvg-name='Channel One' tvg-logo='https://example.com/logo==.png' "
|
||||
"group-title='Sports',Channel One"
|
||||
)
|
||||
|
||||
parsed = parse_extinf_line(line)
|
||||
|
||||
self.assertIsNotNone(parsed)
|
||||
self.assertEqual(
|
||||
parsed["attributes"]["tvg-logo"],
|
||||
"https://example.com/logo==.png",
|
||||
)
|
||||
self.assertEqual(parsed["attributes"]["group-title"], "Sports")
|
||||
self.assertEqual(parsed["display_name"], "Channel One")
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Tests for memory cleanup behavior in M3U refresh tasks.
|
||||
|
||||
Verifies that database connections are properly closed, task locks are
|
||||
released on all exit paths, and garbage collection runs where expected.
|
||||
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.m3u.models import M3UAccount
|
||||
|
||||
|
||||
class ProcessM3UBatchCleanupTests(SimpleTestCase):
|
||||
"""Verify process_m3u_batch_direct cleans up after processing."""
|
||||
|
||||
@patch("apps.m3u.tasks.Stream")
|
||||
@patch("apps.m3u.tasks.M3UAccount")
|
||||
def test_connections_closed_after_batch(self, mock_account_cls, mock_stream_cls):
|
||||
"""Database connections must be closed after batch processing (thread safety)."""
|
||||
from apps.m3u.tasks import process_m3u_batch_direct
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_account.filters.order_by.return_value = []
|
||||
mock_account_cls.objects.get.return_value = mock_account
|
||||
mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = (
|
||||
[]
|
||||
)
|
||||
mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123")
|
||||
|
||||
with patch("django.db.connections") as mock_connections:
|
||||
process_m3u_batch_direct(1, [], {}, ["name", "url"])
|
||||
mock_connections.close_all.assert_called()
|
||||
|
||||
|
||||
class LockReleaseTests(SimpleTestCase):
|
||||
"""Verify task lock is released on all exit paths."""
|
||||
|
||||
@patch("apps.m3u.tasks.delete_m3u_refresh_task_by_id", return_value=False)
|
||||
def test_lock_released_on_account_not_found(self, mock_delete):
|
||||
"""release_task_lock must be called when account does not exist."""
|
||||
with patch(
|
||||
"apps.m3u.tasks.acquire_task_lock", return_value=True
|
||||
), patch("apps.m3u.tasks.release_task_lock") as mock_release, patch(
|
||||
"apps.m3u.tasks.TaskLockRenewer"
|
||||
):
|
||||
with patch(
|
||||
"apps.m3u.tasks.M3UAccount.objects.get",
|
||||
side_effect=M3UAccount.DoesNotExist,
|
||||
):
|
||||
from apps.m3u.tasks import refresh_single_m3u_account
|
||||
|
||||
refresh_single_m3u_account(99999)
|
||||
|
||||
mock_release.assert_called_once_with(
|
||||
"refresh_single_m3u_account", 99999
|
||||
)
|
||||
|
||||
def test_lock_released_on_exception(self):
|
||||
"""release_task_lock must be called when an exception is raised."""
|
||||
mock_account = MagicMock()
|
||||
mock_account.is_active = True
|
||||
mock_account.account_type = "STD"
|
||||
mock_account.custom_properties = {}
|
||||
mock_account.filters.all.return_value = []
|
||||
mock_account.status = MagicMock()
|
||||
|
||||
with patch(
|
||||
"apps.m3u.tasks.acquire_task_lock", return_value=True
|
||||
), patch("apps.m3u.tasks.release_task_lock") as mock_release, patch(
|
||||
"apps.m3u.tasks.TaskLockRenewer"
|
||||
):
|
||||
with patch(
|
||||
"apps.m3u.tasks.M3UAccount.objects.get", return_value=mock_account
|
||||
):
|
||||
with patch("os.path.exists", return_value=False):
|
||||
with patch(
|
||||
"apps.m3u.tasks.refresh_m3u_groups",
|
||||
side_effect=RuntimeError("test"),
|
||||
):
|
||||
from apps.m3u.tasks import refresh_single_m3u_account
|
||||
|
||||
try:
|
||||
refresh_single_m3u_account(1)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
mock_release.assert_called_once_with("refresh_single_m3u_account", 1)
|
||||
|
||||
|
||||
class XCCategoryCleanupTests(SimpleTestCase):
|
||||
"""Regression guard: process_xc_category_direct must continue to clean up."""
|
||||
|
||||
@patch("apps.m3u.tasks.XCClient")
|
||||
@patch("apps.m3u.tasks.M3UAccount")
|
||||
def test_xc_category_calls_gc_collect(self, mock_account_cls, mock_xc_client):
|
||||
"""gc.collect() must be called after XC category processing."""
|
||||
from apps.m3u.tasks import process_xc_category_direct
|
||||
|
||||
mock_account = MagicMock()
|
||||
mock_account_cls.objects.get.return_value = mock_account
|
||||
|
||||
with patch("gc.collect") as mock_gc, patch("django.db.connections"):
|
||||
process_xc_category_direct(1, {}, {}, ["name", "url"])
|
||||
mock_gc.assert_called()
|
||||
Reference in New Issue
Block a user