Add provider system tests: 211 tests covering base, factory, config, monitoring, failover, and selection
This commit is contained in:
474
tests/unit/test_aniworld_provider.py
Normal file
474
tests/unit/test_aniworld_provider.py
Normal file
@@ -0,0 +1,474 @@
|
||||
"""Unit tests for aniworld_provider.py - Anime catalog scraping, episode listing, streaming link extraction."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.providers.aniworld_provider import AniworldLoader
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loader():
|
||||
"""Create AniworldLoader with mocked session to prevent real HTTP calls."""
|
||||
with patch("src.core.providers.aniworld_provider.UserAgent") as mock_ua:
|
||||
mock_ua.return_value.random = "MockUserAgent/1.0"
|
||||
instance = AniworldLoader()
|
||||
instance.session = MagicMock()
|
||||
return instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_search_response():
|
||||
"""Sample JSON response for anime search."""
|
||||
return json.dumps([
|
||||
{"link": "/anime/stream/naruto", "title": "Naruto"},
|
||||
{"link": "/anime/stream/one-piece", "title": "One Piece"},
|
||||
])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_episode_html():
|
||||
"""Sample HTML for an episode page with language info and providers."""
|
||||
return """
|
||||
<html>
|
||||
<body>
|
||||
<div class="changeLanguageBox">
|
||||
<img data-lang-key="1" src="/flags/de.png" />
|
||||
<img data-lang-key="2" src="/flags/en.png" />
|
||||
</div>
|
||||
<li class="episodeLink1">
|
||||
<h4>VOE</h4>
|
||||
<a class="watchEpisode" href="/redirect/12345"></a>
|
||||
<span data-lang-key="1"></span>
|
||||
</li>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_series_html():
|
||||
"""Sample HTML for a series main page."""
|
||||
return """
|
||||
<html>
|
||||
<body>
|
||||
<div class="series-title">
|
||||
<h1><span>Naruto Shippuden</span></h1>
|
||||
</div>
|
||||
<p>Jahr: 2007</p>
|
||||
<div class="series-info">Aired: 2007-2017</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_season_html():
|
||||
"""Sample HTML for a season page with episode links."""
|
||||
return """
|
||||
<html>
|
||||
<body>
|
||||
<meta itemprop="numberOfSeasons" content="2" />
|
||||
<a href="/anime/stream/naruto/staffel-1/episode-1">Ep 1</a>
|
||||
<a href="/anime/stream/naruto/staffel-1/episode-2">Ep 2</a>
|
||||
<a href="/anime/stream/naruto/staffel-1/episode-3">Ep 3</a>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class TestAniworldLoaderInit:
|
||||
"""Test AniworldLoader initialization."""
|
||||
|
||||
def test_loader_initializes(self, loader):
|
||||
"""Loader should initialize with expected attributes."""
|
||||
assert loader.ANIWORLD_TO == "https://aniworld.to"
|
||||
assert isinstance(loader.SUPPORTED_PROVIDERS, list)
|
||||
assert len(loader.SUPPORTED_PROVIDERS) > 0
|
||||
|
||||
def test_loader_has_session(self, loader):
|
||||
"""Loader should have a requests session."""
|
||||
assert loader.session is not None
|
||||
|
||||
def test_loader_has_caches(self, loader):
|
||||
"""Loader should initialize empty caches."""
|
||||
assert isinstance(loader._KeyHTMLDict, dict)
|
||||
assert isinstance(loader._EpisodeHTMLDict, dict)
|
||||
|
||||
def test_loader_site_key(self, loader):
|
||||
"""get_site_key should return 'aniworld.to'."""
|
||||
assert loader.get_site_key() == "aniworld.to"
|
||||
|
||||
def test_loader_provider_headers_initialized(self, loader):
|
||||
"""Provider-specific headers should be initialized."""
|
||||
assert isinstance(loader.PROVIDER_HEADERS, dict)
|
||||
assert "VOE" in loader.PROVIDER_HEADERS
|
||||
|
||||
|
||||
class TestAniworldSearch:
|
||||
"""Test anime search functionality."""
|
||||
|
||||
def test_search_parses_json_response(self, loader, sample_search_response):
|
||||
"""search() should parse JSON response into list."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = sample_search_response
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
loader.session.get.return_value = mock_response
|
||||
|
||||
result = loader.search("naruto")
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
assert result[0]["title"] == "Naruto"
|
||||
|
||||
def test_search_calls_correct_url(self, loader, sample_search_response):
|
||||
"""search() should call the correct search URL."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = sample_search_response
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
loader.session.get.return_value = mock_response
|
||||
|
||||
loader.search("naruto")
|
||||
call_args = loader.session.get.call_args
|
||||
assert "seriesSearch" in call_args[0][0]
|
||||
assert "naruto" in call_args[0][0]
|
||||
|
||||
def test_search_handles_empty_response(self, loader):
|
||||
"""search() with empty JSON array should return empty list."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "[]"
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
loader.session.get.return_value = mock_response
|
||||
|
||||
result = loader.search("nonexistent")
|
||||
assert result == []
|
||||
|
||||
def test_search_handles_html_escaped_json(self, loader):
|
||||
"""search() should handle HTML-escaped JSON response."""
|
||||
escaped_json = '[{"title": "Naruto & Friends"}]'
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = escaped_json
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
loader.session.get.return_value = mock_response
|
||||
|
||||
result = loader.search("naruto")
|
||||
assert len(result) == 1
|
||||
assert result[0]["title"] == "Naruto & Friends"
|
||||
|
||||
def test_search_url_encodes_special_characters(self, loader, sample_search_response):
|
||||
"""search() should URL-encode special characters in search term."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = sample_search_response
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
loader.session.get.return_value = mock_response
|
||||
|
||||
loader.search("attack on titan")
|
||||
call_url = loader.session.get.call_args[0][0]
|
||||
assert "attack" in call_url
|
||||
|
||||
def test_search_raises_on_invalid_json(self, loader):
|
||||
"""search() should raise when response is not valid JSON."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "<html>Not JSON</html>"
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
loader.session.get.return_value = mock_response
|
||||
|
||||
with pytest.raises((ValueError, json.JSONDecodeError)):
|
||||
loader.search("naruto")
|
||||
|
||||
|
||||
class TestAniworldLanguageCheck:
|
||||
"""Test language availability checking."""
|
||||
|
||||
def test_get_language_key_german_dub(self, loader):
|
||||
"""_get_language_key should return 1 for 'German Dub'."""
|
||||
assert loader._get_language_key("German Dub") == 1
|
||||
|
||||
def test_get_language_key_english_sub(self, loader):
|
||||
"""_get_language_key should return 2 for 'English Sub'."""
|
||||
assert loader._get_language_key("English Sub") == 2
|
||||
|
||||
def test_get_language_key_german_sub(self, loader):
|
||||
"""_get_language_key should return 3 for 'German Sub'."""
|
||||
assert loader._get_language_key("German Sub") == 3
|
||||
|
||||
def test_get_language_key_unknown(self, loader):
|
||||
"""_get_language_key should return 0 for unknown language."""
|
||||
assert loader._get_language_key("French Dub") == 0
|
||||
|
||||
def test_is_language_with_available_language(self, loader, sample_episode_html):
|
||||
"""is_language should return True when language is available."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = sample_episode_html.encode("utf-8")
|
||||
loader.session.get.return_value = mock_response
|
||||
|
||||
result = loader.is_language(1, 1, "naruto", "German Dub")
|
||||
assert result is True
|
||||
|
||||
def test_is_language_english_sub_available(self, loader, sample_episode_html):
|
||||
"""is_language should return True for English Sub when available."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = sample_episode_html.encode("utf-8")
|
||||
loader.session.get.return_value = mock_response
|
||||
|
||||
result = loader.is_language(1, 1, "naruto", "English Sub")
|
||||
assert result is True
|
||||
|
||||
def test_is_language_unavailable_language(self, loader, sample_episode_html):
|
||||
"""is_language should return False when language is not available."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = sample_episode_html.encode("utf-8")
|
||||
loader.session.get.return_value = mock_response
|
||||
|
||||
result = loader.is_language(1, 1, "naruto", "German Sub")
|
||||
assert result is False
|
||||
|
||||
def test_is_language_no_language_box(self, loader):
|
||||
"""is_language should return False when no language box exists."""
|
||||
html = "<html><body><div></div></body></html>"
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = html.encode("utf-8")
|
||||
loader.session.get.return_value = mock_response
|
||||
|
||||
result = loader.is_language(1, 1, "naruto", "German Dub")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestAniworldTitle:
|
||||
"""Test title extraction."""
|
||||
|
||||
def test_get_title_extracts_correctly(self, loader, sample_series_html):
|
||||
"""get_title should extract title from HTML."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = sample_series_html.encode("utf-8")
|
||||
loader._KeyHTMLDict["naruto"] = mock_response
|
||||
|
||||
result = loader.get_title("naruto")
|
||||
assert result == "Naruto Shippuden"
|
||||
|
||||
def test_get_title_missing_title_div(self, loader):
|
||||
"""get_title should return empty string when title div is missing."""
|
||||
html = "<html><body></body></html>"
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = html.encode("utf-8")
|
||||
loader._KeyHTMLDict["unknown"] = mock_response
|
||||
|
||||
result = loader.get_title("unknown")
|
||||
assert result == ""
|
||||
|
||||
def test_get_title_caches_html(self, loader, sample_series_html):
|
||||
"""get_title should use cached HTML on second call."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = sample_series_html.encode("utf-8")
|
||||
loader._KeyHTMLDict["naruto"] = mock_response
|
||||
|
||||
loader.get_title("naruto")
|
||||
loader.get_title("naruto")
|
||||
# Session should not be called since HTML is cached
|
||||
loader.session.get.assert_not_called()
|
||||
|
||||
|
||||
class TestAniworldYear:
|
||||
"""Test year extraction."""
|
||||
|
||||
def test_get_year_extracts_from_metadata(self, loader, sample_series_html):
|
||||
"""get_year should extract year from 'Jahr:' text."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = sample_series_html.encode("utf-8")
|
||||
loader._KeyHTMLDict["naruto"] = mock_response
|
||||
|
||||
result = loader.get_year("naruto")
|
||||
assert result == 2007
|
||||
|
||||
def test_get_year_returns_none_when_not_found(self, loader):
|
||||
"""get_year should return None when no year info exists."""
|
||||
html = "<html><body><div class='series-title'></div></body></html>"
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = html.encode("utf-8")
|
||||
loader._KeyHTMLDict["unknown"] = mock_response
|
||||
|
||||
result = loader.get_year("unknown")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestAniworldEpisodeHtml:
|
||||
"""Test episode HTML fetching and caching."""
|
||||
|
||||
def test_get_episode_html_fetches_from_session(self, loader):
|
||||
"""_get_episode_html should fetch from session and cache."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = b"<html></html>"
|
||||
loader.session.get.return_value = mock_response
|
||||
|
||||
result = loader._get_episode_html(1, 1, "naruto")
|
||||
assert result is mock_response
|
||||
loader.session.get.assert_called_once()
|
||||
|
||||
def test_get_episode_html_invalid_season(self, loader):
|
||||
"""_get_episode_html should raise ValueError for invalid season."""
|
||||
with pytest.raises(ValueError, match="Invalid season number"):
|
||||
loader._get_episode_html(0, 1, "naruto")
|
||||
|
||||
def test_get_episode_html_invalid_episode(self, loader):
|
||||
"""_get_episode_html should raise ValueError for invalid episode."""
|
||||
with pytest.raises(ValueError, match="Invalid episode number"):
|
||||
loader._get_episode_html(1, 0, "naruto")
|
||||
|
||||
def test_get_episode_html_season_too_large(self, loader):
|
||||
"""_get_episode_html should raise ValueError for season > 999."""
|
||||
with pytest.raises(ValueError, match="Invalid season number"):
|
||||
loader._get_episode_html(1000, 1, "naruto")
|
||||
|
||||
def test_get_episode_html_episode_too_large(self, loader):
|
||||
"""_get_episode_html should raise ValueError for episode > 9999."""
|
||||
with pytest.raises(ValueError, match="Invalid episode number"):
|
||||
loader._get_episode_html(1, 10000, "naruto")
|
||||
|
||||
|
||||
class TestAniworldProviderParsing:
|
||||
"""Test provider extraction from HTML."""
|
||||
|
||||
def test_parse_providers_from_html(self, loader):
|
||||
"""_get_provider_from_html should extract available providers."""
|
||||
html = """
|
||||
<html><body>
|
||||
<li class="episodeLink1" data-lang-key="1">
|
||||
<h4>VOE</h4>
|
||||
<a class="watchEpisode" href="/redirect/111"></a>
|
||||
</li>
|
||||
<li class="episodeLink2" data-lang-key="2">
|
||||
<h4>Vidmoly</h4>
|
||||
<a class="watchEpisode" href="/redirect/222"></a>
|
||||
</li>
|
||||
</body></html>
|
||||
"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = html.encode("utf-8")
|
||||
loader.session.get.return_value = mock_response
|
||||
|
||||
result = loader._get_provider_from_html(1, 1, "naruto")
|
||||
assert "VOE" in result
|
||||
assert "Vidmoly" in result
|
||||
assert 1 in result["VOE"]
|
||||
assert 2 in result["Vidmoly"]
|
||||
|
||||
def test_parse_providers_empty_html(self, loader):
|
||||
"""_get_provider_from_html should return empty dict for no providers."""
|
||||
html = "<html><body></body></html>"
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = html.encode("utf-8")
|
||||
loader.session.get.return_value = mock_response
|
||||
|
||||
result = loader._get_provider_from_html(1, 1, "naruto")
|
||||
assert result == {}
|
||||
|
||||
def test_parse_providers_missing_lang_key(self, loader):
|
||||
"""Providers without data-lang-key should be skipped."""
|
||||
html = """
|
||||
<html><body>
|
||||
<li class="episodeLink1">
|
||||
<h4>VOE</h4>
|
||||
<a class="watchEpisode" href="/redirect/111"></a>
|
||||
</li>
|
||||
</body></html>
|
||||
"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = html.encode("utf-8")
|
||||
loader.session.get.return_value = mock_response
|
||||
|
||||
result = loader._get_provider_from_html(1, 1, "naruto")
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestAniworldSeasonEpisodeCount:
|
||||
"""Test season and episode count retrieval."""
|
||||
|
||||
@patch("src.core.providers.aniworld_provider.requests.get")
|
||||
def test_get_season_episode_count(self, mock_get, loader):
|
||||
"""get_season_episode_count should return correct counts."""
|
||||
# Main page with 2 seasons
|
||||
main_html = '<html><body><meta itemprop="numberOfSeasons" content="2" /></body></html>'
|
||||
# Season 1 with 3 episodes
|
||||
s1_html = """
|
||||
<html><body>
|
||||
<a href="/anime/stream/naruto/staffel-1/episode-1">Ep1</a>
|
||||
<a href="/anime/stream/naruto/staffel-1/episode-2">Ep2</a>
|
||||
<a href="/anime/stream/naruto/staffel-1/episode-3">Ep3</a>
|
||||
</body></html>
|
||||
"""
|
||||
# Season 2 with 2 episodes
|
||||
s2_html = """
|
||||
<html><body>
|
||||
<a href="/anime/stream/naruto/staffel-2/episode-1">Ep1</a>
|
||||
<a href="/anime/stream/naruto/staffel-2/episode-2">Ep2</a>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
responses = [
|
||||
MagicMock(content=main_html.encode()),
|
||||
MagicMock(content=s1_html.encode()),
|
||||
MagicMock(content=s2_html.encode()),
|
||||
]
|
||||
mock_get.side_effect = responses
|
||||
|
||||
result = loader.get_season_episode_count("naruto")
|
||||
assert result == {1: 3, 2: 2}
|
||||
|
||||
@patch("src.core.providers.aniworld_provider.requests.get")
|
||||
def test_get_season_episode_count_no_seasons(self, mock_get, loader):
|
||||
"""get_season_episode_count should return empty dict when no seasons."""
|
||||
html = "<html><body></body></html>"
|
||||
mock_get.return_value = MagicMock(content=html.encode())
|
||||
|
||||
result = loader.get_season_episode_count("nonexistent")
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestAniworldCache:
|
||||
"""Test cache operations."""
|
||||
|
||||
def test_clear_cache(self, loader):
|
||||
"""clear_cache should empty both caches."""
|
||||
loader._KeyHTMLDict["key1"] = "data"
|
||||
loader._EpisodeHTMLDict[("key1", 1, 1)] = "data"
|
||||
|
||||
loader.clear_cache()
|
||||
|
||||
assert len(loader._KeyHTMLDict) == 0
|
||||
assert len(loader._EpisodeHTMLDict) == 0
|
||||
|
||||
def test_remove_from_cache(self, loader):
|
||||
"""remove_from_cache should only clear episode cache."""
|
||||
loader._KeyHTMLDict["key1"] = "data"
|
||||
loader._EpisodeHTMLDict[("key1", 1, 1)] = "data"
|
||||
|
||||
loader.remove_from_cache()
|
||||
|
||||
assert len(loader._KeyHTMLDict) == 1
|
||||
assert len(loader._EpisodeHTMLDict) == 0
|
||||
|
||||
|
||||
class TestAniworldEvents:
|
||||
"""Test event subscription for download progress."""
|
||||
|
||||
def test_subscribe_download_progress(self, loader):
|
||||
"""subscribe_download_progress should register handler."""
|
||||
handler = MagicMock()
|
||||
loader.subscribe_download_progress(handler)
|
||||
# Fire event to verify handler was registered
|
||||
loader.events.download_progress({"status": "downloading"})
|
||||
handler.assert_called_once_with({"status": "downloading"})
|
||||
|
||||
def test_unsubscribe_download_progress(self, loader):
|
||||
"""unsubscribe_download_progress should remove handler."""
|
||||
handler = MagicMock()
|
||||
loader.subscribe_download_progress(handler)
|
||||
loader.unsubscribe_download_progress(handler)
|
||||
# Fire event - handler should NOT be called
|
||||
loader.events.download_progress({"status": "downloading"})
|
||||
handler.assert_not_called()
|
||||
Reference in New Issue
Block a user