Dispatcharr/apps/channels/tests/test_validate_url.py
None 759688390b fix(dvr): fix series episode list, card layout, rule cleanup, and recording resilience
Backend:
- Add terminal status guard to stop() endpoint -prevents overwriting completed/interrupted/failed with "stopped" (returns 409 Conflict)
- Include currently-airing programs in series rule evaluation by filtering on end_time instead of start_time
- Clean up future recordings when deleting a series rule while preserving previously recorded episodes
- Add logo proxy negative cache to prevent repeated requests to unreachable hosts from blocking Daphne workers
- Add URL validation with HEAD-then-GET fallback and per-worker cache
- Add EPG time-slot matching (80% overlap threshold) to enrich manual recordings with program metadata
- Add multi-source poster resolution pipeline (EPG, VOD, TMDB, OMDb, TVMaze, iTunes) with URL validation at each stage
- Add editable recording metadata endpoint with user_edited flag to prevent EPG auto-overwrite
- Add refresh-artwork endpoint to re-run poster resolution on demand
- Batch-fetch PeriodicTask names in schedule_upcoming to avoid N+1

Frontend:
- Fix series modal showing only one episode — preserve _group_count from categorized prop when merging with store version so the episode list renders correctly
- Fix recording card button positioning — use flex column layout with marginTop auto so buttons align at the bottom regardless of content
- Add delete fallback in RecurringRuleModal for orphaned recordings from deleted rules
- Use end_time filter in upcoming episodes list to include currently-airing episodes
- Add inline metadata editing (title/description) in details modal
- Add refresh artwork button in details modal
- Replace hardcoded /logo.png fallbacks with imported default logo
- Add React.memo with custom comparator to RecordingCard

Tests:
- Add 21 EPG time-slot matching tests (overlap threshold, edge cases)
- Add 16 recording metadata tests (validation, artwork, logo cache)
- Add 17 URL validation tests (HEAD/GET fallback, caching, eviction)
2026-03-04 10:21:03 -06:00

169 lines
6.9 KiB
Python

"""Tests for the _validate_url() helper in tasks.py.
Covers:
- Rejection of None, empty, and non-string inputs
- Non-HTTP URLs pass through without network requests
- HTTP(S) URLs validated via HEAD request (2xx/3xx pass, 4xx/5xx fail)
- Network errors (timeout, connection) treated as failures
- Per-worker result cache: hits, expiry, eviction
"""
from unittest.mock import patch, MagicMock
from django.test import TestCase
from apps.channels.tasks import _validate_url, _url_validation_cache, _URL_CACHE_TTL
class ValidateUrlInputTests(TestCase):
"""Input validation — no network requests should be made."""
def setUp(self):
_url_validation_cache.clear()
def test_none_returns_false(self):
self.assertFalse(_validate_url(None))
def test_empty_string_returns_false(self):
self.assertFalse(_validate_url(""))
def test_non_string_returns_false(self):
self.assertFalse(_validate_url(123))
self.assertFalse(_validate_url(["http://example.com"]))
@patch("apps.channels.tasks.requests.head")
def test_non_http_url_returns_true_without_request(self, mock_head):
"""file:// and other non-HTTP schemes skip validation."""
self.assertTrue(_validate_url("file:///local/path.jpg"))
self.assertTrue(_validate_url("/data/images/poster.jpg"))
mock_head.assert_not_called()
class ValidateUrlNetworkTests(TestCase):
"""HTTP(S) URL validation via HEAD request."""
def setUp(self):
_url_validation_cache.clear()
@patch("apps.channels.tasks.requests.head")
def test_200_returns_true(self, mock_head):
mock_head.return_value = MagicMock(status_code=200)
self.assertTrue(_validate_url("https://example.com/poster.jpg"))
@patch("apps.channels.tasks.requests.head")
def test_302_redirect_returns_true(self, mock_head):
mock_head.return_value = MagicMock(status_code=302)
self.assertTrue(_validate_url("https://example.com/redirect"))
@patch("apps.channels.tasks.requests.head")
def test_404_returns_false(self, mock_head):
mock_head.return_value = MagicMock(status_code=404)
self.assertFalse(_validate_url("https://dead-cdn.com/missing.jpg"))
@patch("apps.channels.tasks.requests.head")
def test_500_returns_false(self, mock_head):
mock_head.return_value = MagicMock(status_code=500)
self.assertFalse(_validate_url("https://broken.com/error"))
@patch("apps.channels.tasks.requests.head")
def test_timeout_returns_false(self, mock_head):
import requests
mock_head.side_effect = requests.Timeout("timed out")
self.assertFalse(_validate_url("https://slow-cdn.com/poster.jpg"))
@patch("apps.channels.tasks.requests.head")
def test_connection_error_returns_false(self, mock_head):
import requests
mock_head.side_effect = requests.ConnectionError("refused")
self.assertFalse(_validate_url("https://unreachable.com/poster.jpg"))
@patch("apps.channels.tasks.requests.head")
def test_custom_timeout_passed_to_head(self, mock_head):
mock_head.return_value = MagicMock(status_code=200)
_validate_url("https://example.com/img.jpg", timeout=10)
mock_head.assert_called_once_with(
"https://example.com/img.jpg", timeout=10, allow_redirects=True
)
@patch("apps.channels.tasks.requests.get")
@patch("apps.channels.tasks.requests.head")
def test_405_falls_back_to_get(self, mock_head, mock_get):
"""When HEAD returns 405, fall back to a ranged GET request."""
mock_head.return_value = MagicMock(status_code=405)
mock_resp = MagicMock(status_code=200)
mock_get.return_value = mock_resp
self.assertTrue(_validate_url("https://no-head.com/poster.jpg"))
mock_get.assert_called_once()
mock_resp.close.assert_called_once()
@patch("apps.channels.tasks.requests.get")
@patch("apps.channels.tasks.requests.head")
def test_405_fallback_get_also_fails(self, mock_head, mock_get):
"""When HEAD returns 405 and GET also fails, return False."""
mock_head.return_value = MagicMock(status_code=405)
mock_get.return_value = MagicMock(status_code=403)
self.assertFalse(_validate_url("https://blocked.com/poster.jpg"))
class ValidateUrlCacheTests(TestCase):
"""Per-worker result caching."""
def setUp(self):
_url_validation_cache.clear()
@patch("apps.channels.tasks.requests.head")
def test_cache_hit_avoids_second_request(self, mock_head):
mock_head.return_value = MagicMock(status_code=200)
url = "https://cached.com/poster.jpg"
self.assertTrue(_validate_url(url))
self.assertTrue(_validate_url(url))
mock_head.assert_called_once()
@patch("apps.channels.tasks.requests.head")
def test_cache_hit_returns_false_for_failed_url(self, mock_head):
mock_head.return_value = MagicMock(status_code=404)
url = "https://dead.com/missing.jpg"
self.assertFalse(_validate_url(url))
self.assertFalse(_validate_url(url))
mock_head.assert_called_once()
@patch("apps.channels.tasks.time.monotonic")
@patch("apps.channels.tasks.requests.head")
def test_cache_expiry_triggers_new_request(self, mock_head, mock_time):
"""After TTL expires, a new HEAD request is made."""
mock_head.return_value = MagicMock(status_code=200)
url = "https://expiring.com/poster.jpg"
mock_time.return_value = 1000.0
self.assertTrue(_validate_url(url))
self.assertEqual(mock_head.call_count, 1)
# Within TTL — cache hit
mock_time.return_value = 1000.0 + _URL_CACHE_TTL - 1
self.assertTrue(_validate_url(url))
self.assertEqual(mock_head.call_count, 1)
# Past TTL — new request
mock_time.return_value = 1000.0 + _URL_CACHE_TTL + 1
self.assertTrue(_validate_url(url))
self.assertEqual(mock_head.call_count, 2)
@patch("apps.channels.tasks.time.monotonic")
@patch("apps.channels.tasks.requests.head")
def test_eviction_when_cache_exceeds_limit(self, mock_head, mock_time):
"""Expired entries are evicted when cache grows past 512 entries."""
mock_head.return_value = MagicMock(status_code=200)
# Fill cache with 513 entries at time 0
mock_time.return_value = 0.0
for i in range(513):
_url_validation_cache[f"https://fill-{i}.com/img.jpg"] = (True, 0.0)
# Advance past TTL and add one more — triggers eviction
mock_time.return_value = _URL_CACHE_TTL + 1
_validate_url("https://trigger-eviction.com/img.jpg")
# All 513 old entries expired and should be evicted
remaining = [k for k in _url_validation_cache if k.startswith("https://fill-")]
self.assertEqual(len(remaining), 0)
# The new entry should remain
self.assertIn("https://trigger-eviction.com/img.jpg", _url_validation_cache)