Files
root f56b088643
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
Proyecto LCX Dispatcharr multicuenta
2026-05-09 21:24:50 +02:00

380 lines
18 KiB
Python

"""Tests for recording metadata endpoints and logo proxy negative cache.
Covers:
- update_metadata endpoint: title/description, user_edited flag, validation
- refresh_artwork endpoint: returns immediately, background thread behavior
- Logo proxy negative cache: cache hit/miss, expiry, eviction, success clears
"""
import time as time_mod
from datetime import timedelta
from unittest.mock import MagicMock, patch
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIRequestFactory, force_authenticate
from apps.channels.models import Channel, Recording, Logo
from apps.channels.api_views import RecordingViewSet, LogoViewSet
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_admin():
from django.contrib.auth import get_user_model
User = get_user_model()
u, _ = User.objects.get_or_create(
username="metadata_test_admin",
defaults={"user_level": User.UserLevel.ADMIN},
)
u.set_password("pass")
u.save()
return u
# ---------------------------------------------------------------------------
# update_metadata endpoint
# ---------------------------------------------------------------------------
class UpdateMetadataTests(TestCase):
"""Tests for POST /api/channels/recordings/{id}/update-metadata/"""
def setUp(self):
self.channel = Channel.objects.create(channel_number=70, name="Meta Test Channel")
self.user = _make_admin()
self.factory = APIRequestFactory()
def _update(self, rec, data):
request = self.factory.post(
f"/api/channels/recordings/{rec.id}/update-metadata/",
data, format="json",
)
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "update_metadata"})
return view(request, pk=rec.id)
def _make_rec(self, custom_properties=None):
now = timezone.now()
return Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
custom_properties=custom_properties or {},
)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_update_title_only(self, _ws):
rec = self._make_rec()
response = self._update(rec, {"title": "My Show"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
program = rec.custom_properties["program"]
self.assertEqual(program["title"], "My Show")
self.assertTrue(program["user_edited"])
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_update_description_only(self, _ws):
rec = self._make_rec({"program": {"title": "Existing Title"}})
response = self._update(rec, {"description": "A great episode"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
program = rec.custom_properties["program"]
self.assertEqual(program["description"], "A great episode")
self.assertEqual(program["title"], "Existing Title")
self.assertTrue(program["user_edited"])
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_update_both_fields(self, _ws):
rec = self._make_rec()
response = self._update(rec, {"title": "New Title", "description": "New Desc"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
program = rec.custom_properties["program"]
self.assertEqual(program["title"], "New Title")
self.assertEqual(program["description"], "New Desc")
self.assertTrue(program["user_edited"])
def test_no_fields_returns_400(self):
rec = self._make_rec()
response = self._update(rec, {})
self.assertEqual(response.status_code, 400)
self.assertFalse(response.data.get("success"))
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_whitespace_trimmed(self, _ws):
rec = self._make_rec()
response = self._update(rec, {"title": " Padded Title "})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
self.assertEqual(rec.custom_properties["program"]["title"], "Padded Title")
def test_whitespace_only_title_returns_400(self):
"""Whitespace-only title and description should be rejected."""
rec = self._make_rec()
response = self._update(rec, {"title": " ", "description": " "})
self.assertEqual(response.status_code, 400)
self.assertFalse(response.data.get("success"))
def test_whitespace_only_title_with_valid_description(self):
"""Whitespace-only title is ignored; valid description is accepted."""
rec = self._make_rec({"program": {"title": "Original"}})
with patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None):
response = self._update(rec, {"title": " ", "description": "Valid desc"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
# Title should remain unchanged since the whitespace-only value is not applied
self.assertEqual(rec.custom_properties["program"]["title"], "Original")
self.assertEqual(rec.custom_properties["program"]["description"], "Valid desc")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_creates_program_dict_when_absent(self, _ws):
"""Recording with no program dict gets one created."""
rec = self._make_rec({"status": "completed"})
response = self._update(rec, {"title": "Brand New"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
self.assertIn("program", rec.custom_properties)
self.assertEqual(rec.custom_properties["program"]["title"], "Brand New")
def test_returns_404_for_nonexistent(self):
request = self.factory.post(
"/api/channels/recordings/99999/update-metadata/",
{"title": "Ghost"}, format="json",
)
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "update_metadata"})
self.assertEqual(view(request, pk=99999).status_code, 404)
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_sends_websocket_event(self, mock_ws):
rec = self._make_rec()
self._update(rec, {"title": "WS Test"})
mock_ws.assert_called_once()
payload = mock_ws.call_args[0][2]
self.assertEqual(payload["type"], "recording_updated")
self.assertEqual(payload["recording_id"], rec.id)
@patch("core.utils.send_websocket_update", side_effect=Exception("WS down"))
def test_ws_failure_does_not_fail_request(self, _ws):
"""WebSocket errors are silenced — the save still succeeds."""
rec = self._make_rec()
response = self._update(rec, {"title": "Resilient"})
self.assertEqual(response.status_code, 200)
rec.refresh_from_db()
self.assertEqual(rec.custom_properties["program"]["title"], "Resilient")
# ---------------------------------------------------------------------------
# refresh_artwork endpoint
# ---------------------------------------------------------------------------
class RefreshArtworkTests(TestCase):
"""Tests for POST /api/channels/recordings/{id}/refresh-artwork/"""
def setUp(self):
self.channel = Channel.objects.create(channel_number=71, name="Artwork Test Channel")
self.user = _make_admin()
self.factory = APIRequestFactory()
def _refresh(self, rec):
request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
return view(request, pk=rec.id)
def _make_rec(self, custom_properties=None):
now = timezone.now()
return Recording.objects.create(
channel=self.channel,
start_time=now - timedelta(hours=1),
end_time=now + timedelta(hours=1),
custom_properties=custom_properties or {},
)
@patch("threading.Thread")
def test_returns_200_immediately(self, mock_thread):
mock_thread.return_value.start = MagicMock()
rec = self._make_rec()
response = self._refresh(rec)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.data.get("success"))
@patch("threading.Thread")
def test_spawns_background_thread(self, mock_thread):
mock_thread.return_value.start = MagicMock()
rec = self._make_rec()
self._refresh(rec)
mock_thread.assert_called_once()
self.assertTrue(mock_thread.call_args[1].get("daemon", False))
mock_thread.return_value.start.assert_called_once()
def test_returns_404_for_nonexistent(self):
request = self.factory.post("/api/channels/recordings/99999/refresh-artwork/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
self.assertEqual(view(request, pk=99999).status_code, 404)
@patch("django.db.close_old_connections")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_no_downgrade_to_channel_logo(self, _ws, _close):
"""When the pipeline returns the channel's own logo, existing poster is preserved."""
logo = Logo.objects.create(name="Channel Logo", url="https://example.com/ch.png")
self.channel.logo = logo
self.channel.save()
rec = self._make_rec({
"poster_logo_id": 999, # existing real poster
"poster_url": "https://tmdb.com/real-poster.jpg",
})
with patch("apps.channels.tasks._resolve_poster_for_program",
return_value=(logo.id, None)):
request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/")
force_authenticate(request, user=self.user)
# Run synchronously by intercepting the thread
captured_fn = None
def capture_thread(*args, **kwargs):
nonlocal captured_fn
captured_fn = kwargs.get("target") or args[0]
mock = MagicMock()
mock.start = lambda: captured_fn(*kwargs.get("args", ()))
return mock
with patch("threading.Thread", side_effect=capture_thread):
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
view(request, pk=rec.id)
rec.refresh_from_db()
# Existing poster should be preserved — not downgraded to channel logo
self.assertEqual(rec.custom_properties.get("poster_logo_id"), 999)
self.assertEqual(rec.custom_properties.get("poster_url"), "https://tmdb.com/real-poster.jpg")
@patch("django.db.close_old_connections")
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
def test_upgrade_from_no_poster(self, _ws, _close):
"""When a recording has no poster and the pipeline finds one, it gets updated."""
rec = self._make_rec({"program": {"title": "Some Show", "id": 42}})
with patch("apps.channels.tasks._resolve_poster_for_program",
return_value=(555, "https://tmdb.com/new-poster.jpg")):
captured_fn = None
def capture_thread(*args, **kwargs):
nonlocal captured_fn
captured_fn = kwargs.get("target") or args[0]
mock = MagicMock()
mock.start = lambda: captured_fn(*kwargs.get("args", ()))
return mock
with patch("threading.Thread", side_effect=capture_thread):
request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/")
force_authenticate(request, user=self.user)
view = RecordingViewSet.as_view({"post": "refresh_artwork"})
view(request, pk=rec.id)
rec.refresh_from_db()
self.assertEqual(rec.custom_properties.get("poster_logo_id"), 555)
self.assertEqual(rec.custom_properties.get("poster_url"), "https://tmdb.com/new-poster.jpg")
# ---------------------------------------------------------------------------
# Logo proxy negative cache
# ---------------------------------------------------------------------------
class LogoNegativeCacheTests(TestCase):
"""Tests for the _logo_fetch_failures negative cache in LogoViewSet.cache()."""
def setUp(self):
from apps.channels import api_views
self._failures = api_views._logo_fetch_failures
self._failures.clear()
self.factory = APIRequestFactory()
self.user = _make_admin()
def _fetch_logo(self, logo):
request = self.factory.get(f"/api/channels/logos/{logo.id}/cache/")
force_authenticate(request, user=self.user)
view = LogoViewSet.as_view({"get": "cache"})
return view(request, pk=logo.id)
def test_failed_url_cached_on_non_200(self):
"""Non-200 response adds URL to negative cache."""
logo = Logo.objects.create(name="Dead Logo", url="https://dead-cdn.com/logo.png")
mock_resp = MagicMock(status_code=404)
with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
response = self._fetch_logo(logo)
self.assertEqual(response.status_code, 404)
self.assertIn("https://dead-cdn.com/logo.png", self._failures)
def test_cached_failure_returns_404_immediately(self):
"""Subsequent request for a cached-failed URL returns 404 without making a request."""
logo = Logo.objects.create(name="Cached Fail", url="https://cached-fail.com/logo.png")
self._failures["https://cached-fail.com/logo.png"] = time_mod.monotonic() + 300
with patch("apps.channels.api_views.requests.get") as mock_get:
response = self._fetch_logo(logo)
self.assertEqual(response.status_code, 404)
mock_get.assert_not_called()
def test_expired_cache_entry_allows_retry(self):
"""After TTL expires, a new request is made."""
logo = Logo.objects.create(name="Expired", url="https://expired.com/logo.png")
self._failures["https://expired.com/logo.png"] = time_mod.monotonic() - 1 # already expired
mock_resp = MagicMock(status_code=200)
mock_resp.headers = {"Content-Type": "image/png"}
mock_resp.iter_content = MagicMock(return_value=[b"img"])
with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
response = self._fetch_logo(logo)
self.assertEqual(response.status_code, 200)
def test_success_clears_previous_failure(self):
"""A successful fetch removes the URL from the failure cache."""
url = "https://recovered.com/logo.png"
logo = Logo.objects.create(name="Recovered", url=url)
self._failures[url] = time_mod.monotonic() - 1 # expired
mock_resp = MagicMock(status_code=200)
mock_resp.headers = {"Content-Type": "image/png"}
mock_resp.iter_content = MagicMock(return_value=[b"img"])
with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
self._fetch_logo(logo)
self.assertNotIn(url, self._failures)
def test_request_exception_cached(self):
"""Network errors are cached the same as non-200 responses."""
import requests
logo = Logo.objects.create(name="Timeout", url="https://timeout.com/logo.png")
with patch("apps.channels.api_views.requests.get", side_effect=requests.Timeout("timed out")), \
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
response = self._fetch_logo(logo)
self.assertEqual(response.status_code, 404)
self.assertIn("https://timeout.com/logo.png", self._failures)
def test_eviction_when_cache_exceeds_256(self):
"""Stale entries are evicted when the cache grows past 256."""
now = time_mod.monotonic()
# Fill with 257 expired entries
for i in range(257):
self._failures[f"https://old-{i}.com/x.png"] = now - 1 # already expired
logo = Logo.objects.create(name="Trigger", url="https://trigger-evict.com/logo.png")
import requests
with patch("apps.channels.api_views.requests.get", side_effect=requests.ConnectionError("fail")), \
patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \
patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")):
self._fetch_logo(logo)
# Expired entries should be evicted
old_entries = [k for k in self._failures if k.startswith("https://old-")]
self.assertEqual(len(old_entries), 0)
# New failure entry should exist
self.assertIn("https://trigger-evict.com/logo.png", self._failures)