"""Tests for CSFD.cz scraper module."""
import pytest
from unittest.mock import patch, MagicMock
from src.core.csfd import (
CSFDMovie,
fetch_movie,
search_movies,
fetch_movie_by_id,
_extract_csfd_id,
_parse_duration,
_extract_json_ld,
_extract_rating,
_extract_poster,
_extract_plot,
_extract_genres,
_extract_origin_info,
_check_dependencies,
_solve_anubis_pow,
_split_countries,
rating_band,
clean_filename_to_query,
find_csfd_url,
)
def _mock_session(mock_requests):
"""Wire ``mock_requests`` so ``requests.Session()`` (also as a context
manager) yields a single configurable session mock and return it."""
session = MagicMock()
session.__enter__.return_value = session
mock_requests.Session.return_value = session
return session
# Sample HTML for testing
SAMPLE_JSON_LD = """
{
"@type": "Movie",
"name": "Test Movie",
"director": [{"@type": "Person", "name": "Test Director"}],
"actor": [{"@type": "Person", "name": "Actor 1"}, {"@type": "Person", "name": "Actor 2"}],
"aggregateRating": {"ratingValue": 85.5, "ratingCount": 1000},
"duration": "PT120M",
"description": "A test movie description."
}
"""
SAMPLE_HTML = """
85%%
Česko, 2020, 120 min
""" % SAMPLE_JSON_LD
class TestCSFDMovie:
"""Tests for CSFDMovie dataclass."""
def test_csfd_movie_basic(self):
"""Test basic CSFDMovie creation."""
movie = CSFDMovie(title="Test", url="https://csfd.cz/film/123/")
assert movie.title == "Test"
assert movie.url == "https://csfd.cz/film/123/"
assert movie.year is None
assert movie.genres == []
assert movie.rating is None
def test_csfd_movie_full(self):
"""Test CSFDMovie with all fields."""
movie = CSFDMovie(
title="Test Movie",
url="https://csfd.cz/film/123/",
year=2020,
genres=["Drama", "Thriller"],
directors=["Director 1"],
actors=["Actor 1", "Actor 2"],
rating=85,
rating_count=1000,
duration=120,
countries=["Česko"],
poster_url="https://image.example.com/poster.jpg",
plot="A test movie.",
csfd_id=123
)
assert movie.year == 2020
assert movie.genres == ["Drama", "Thriller"]
assert movie.rating == 85
assert movie.duration == 120
assert movie.countries == ["Česko"]
assert movie.csfd_id == 123
def test_csfd_movie_str(self):
"""Test CSFDMovie string representation."""
movie = CSFDMovie(
title="Test Movie",
url="https://csfd.cz/film/123/",
year=2020,
genres=["Drama"],
directors=["Director 1"],
rating=85
)
s = str(movie)
assert "Test Movie (2020)" in s
assert "85%" in s
assert "Drama" in s
assert "Director 1" in s
def test_csfd_movie_str_minimal(self):
"""Test CSFDMovie string with minimal data."""
movie = CSFDMovie(title="Test", url="https://csfd.cz/film/123/")
s = str(movie)
assert "Test" in s
class TestHelperFunctions:
"""Tests for helper functions."""
def test_extract_csfd_id_valid(self):
"""Test extracting CSFD ID from valid URL."""
assert _extract_csfd_id("https://www.csfd.cz/film/9423-pane-vy-jste-vdova/") == 9423
assert _extract_csfd_id("https://www.csfd.cz/film/123456/") == 123456
assert _extract_csfd_id("/film/999/prehled/") == 999
def test_extract_csfd_id_invalid(self):
"""Test extracting CSFD ID from invalid URL."""
assert _extract_csfd_id("https://www.csfd.cz/") is None
assert _extract_csfd_id("not-a-url") is None
def test_parse_duration_valid(self):
"""Test parsing ISO 8601 duration."""
assert _parse_duration("PT97M") == 97
assert _parse_duration("PT120M") == 120
assert _parse_duration("PT60M") == 60
def test_parse_duration_invalid(self):
"""Test parsing invalid duration."""
assert _parse_duration("") is None
assert _parse_duration("invalid") is None
def test_split_countries_single(self):
"""A single country yields a one-item list."""
assert _split_countries("USA") == ["USA"]
def test_split_countries_multiple(self):
"""Slash-separated co-production countries are split and trimmed."""
assert _split_countries("USA / Velká Británie") == ["USA", "Velká Británie"]
assert _split_countries("Japonsko/USA") == ["Japonsko", "USA"]
def test_split_countries_empty(self):
"""None/empty yields an empty list."""
assert _split_countries(None) == []
assert _split_countries("") == []
def test_from_dict_migrates_legacy_country(self):
"""Legacy cache with a single 'country' string maps to countries list."""
movie = CSFDMovie.from_dict({"title": "X", "country": "USA / Kanada"})
assert movie.countries == ["USA", "Kanada"]
def test_from_dict_uses_countries_when_present(self):
"""New cache with 'countries' is used verbatim."""
movie = CSFDMovie.from_dict({"title": "X", "countries": ["Japonsko", "USA"]})
assert movie.countries == ["Japonsko", "USA"]
def test_rating_band_buckets(self):
"""Rating is bucketed into ten-point bands, top band spans 90–100 %."""
assert rating_band(0) == "0–9 %"
assert rating_band(86) == "80–89 %"
assert rating_band(90) == "90–100 %"
assert rating_band(95) == "90–100 %"
assert rating_band(100) == "90–100 %"
def test_csfd_field_values_are_exact_no_transform(self):
from src.core.csfd import csfd_field_values
movie = CSFDMovie(title="X", url="u", year=1999, rating=86,
genres=["Akční", "Sci-Fi"], countries=["USA", "Kanada"])
assert csfd_field_values(movie, "genres") == ["Akční", "Sci-Fi"]
assert csfd_field_values(movie, "countries") == ["USA", "Kanada"]
assert csfd_field_values(movie, "year") == ["1999"]
# rating tag carries the EXACT value (transform happens only for folders)
assert csfd_field_values(movie, "rating") == ["86"]
# missing field / value → empty
assert csfd_field_values(CSFDMovie(title="X", url="u"), "rating") == []
assert csfd_field_values(movie, "genres") == csfd_field_values(movie, "genres")
def test_apply_transform_decade_band(self):
from src.core.csfd import apply_transform
assert apply_transform("86", "decade_band") == "80–89 %"
assert apply_transform("90", "decade_band") == "90–100 %"
assert apply_transform("Akční", None) == "Akční" # identity for non-rating
assert apply_transform("USA", "identity") == "USA"
assert _parse_duration("PT") is None
class TestHTMLExtraction:
"""Tests for HTML extraction functions."""
@pytest.fixture
def soup(self):
"""Create BeautifulSoup object from sample HTML."""
from bs4 import BeautifulSoup
return BeautifulSoup(SAMPLE_HTML, "html.parser")
def test_extract_json_ld(self, soup):
"""Test extracting data from JSON-LD."""
data = _extract_json_ld(soup)
assert data["title"] == "Test Movie"
assert data["directors"] == ["Test Director"]
assert data["actors"] == ["Actor 1", "Actor 2"]
assert data["rating"] == 86 # Rounded from 85.5
assert data["rating_count"] == 1000
assert data["duration"] == 120
def test_extract_rating(self, soup):
"""Test extracting rating from HTML."""
rating = _extract_rating(soup)
assert rating == 85
def test_extract_genres(self, soup):
"""Test extracting genres from HTML."""
genres = _extract_genres(soup)
assert "Drama" in genres
assert "Thriller" in genres
def test_extract_poster(self, soup):
"""Test extracting poster URL."""
poster = _extract_poster(soup)
assert poster == "https://image.example.com/poster.jpg"
def test_extract_plot(self, soup):
"""Test extracting plot."""
plot = _extract_plot(soup)
assert plot == "Full plot description."
def test_extract_origin_info(self, soup):
"""Test extracting origin info (comma-separated legacy format)."""
info = _extract_origin_info(soup)
assert info["countries"] == ["Česko"]
assert info["year"] == 2020
assert info["duration"] == 120
def test_extract_origin_info_bullet_format(self):
"""Test current CSFD format with inline bullet spans (no commas)."""
from bs4 import BeautifulSoup
html = (
'USA '
'1999 '
'136 min (Alternativní 131 min)
'
)
info = _extract_origin_info(BeautifulSoup(html, "html.parser"))
assert info["countries"] == ["USA"]
assert info["year"] == 1999
assert info["duration"] == 136
def test_extract_origin_info_multiple_countries(self):
"""A co-production lists several slash-separated countries."""
from bs4 import BeautifulSoup
html = (
'USA / Velká Británie '
'2009 '
' 166 min
'
)
info = _extract_origin_info(BeautifulSoup(html, "html.parser"))
assert info["countries"] == ["USA", "Velká Británie"]
assert info["year"] == 2009
assert info["duration"] == 166
def test_extract_json_ld_year_from_date_created(self):
"""Year is taken from JSON-LD dateCreated when present."""
from bs4 import BeautifulSoup
html = (
''
)
data = _extract_json_ld(BeautifulSoup(html, "html.parser"))
assert data["year"] == 1999
class TestCleanFilenameToQuery:
"""Tests for turning a filename into a ČSFD search query."""
def test_strips_release_tags_and_keeps_year(self):
assert clean_filename_to_query(
"Matrix.1999.1080p.BluRay.x264-GROUP.mkv") == "Matrix 1999"
def test_handles_spaces_and_parens_year(self):
assert clean_filename_to_query(
"Forrest Gump (1994) 2160p HDR.mkv") == "Forrest Gump 1994"
def test_no_year_no_markers(self):
assert clean_filename_to_query("Amelie.mkv") == "Amelie"
def test_underscores_and_resolution(self):
assert clean_filename_to_query("Sam_doma_720p.mkv") == "Sam doma"
def test_falls_back_to_stem_when_starting_with_marker(self):
# No real title words before the marker → fall back to the cleaned stem
assert clean_filename_to_query("1080p.mkv") == "1080p"
class TestFindCsfdUrl:
"""Tests for find_csfd_url (search is mocked)."""
def test_returns_first_result_url(self):
from unittest.mock import patch
movies = [
CSFDMovie(title="Matrix", url="https://www.csfd.cz/film/9499-matrix/"),
CSFDMovie(title="Matrix Reloaded", url="https://www.csfd.cz/film/9497-x/"),
]
with patch("src.core.csfd.search_movies", return_value=movies):
assert find_csfd_url("Matrix 1999") == "https://www.csfd.cz/film/9499-matrix/"
def test_returns_none_for_empty_query(self):
assert find_csfd_url(" ") is None
def test_returns_none_when_no_results(self):
from unittest.mock import patch
with patch("src.core.csfd.search_movies", return_value=[]):
assert find_csfd_url("nonexistent film") is None
class TestFetchMovie:
"""Tests for fetch_movie function."""
@patch("src.core.csfd.requests")
def test_fetch_movie_success(self, mock_requests):
"""Test successful movie fetch."""
mock_response = MagicMock()
mock_response.text = SAMPLE_HTML
mock_response.raise_for_status = MagicMock()
session = _mock_session(mock_requests)
session.get.return_value = mock_response
movie = fetch_movie("https://www.csfd.cz/film/123-test/")
assert movie.title == "Test Movie"
assert movie.csfd_id == 123
assert movie.rating == 86
assert "Drama" in movie.genres
session.get.assert_called_once()
@patch("src.core.csfd.requests")
def test_fetch_movie_caps_actors_at_ten(self, mock_requests):
"""Only the first MAX_ACTORS (10) of a long cast are kept."""
import json as _json
actors = [{"@type": "Person", "name": f"Actor {i}"} for i in range(25)]
json_ld = _json.dumps({
"@type": "Movie", "name": "Crowded", "actor": actors,
"director": [{"@type": "Person", "name": "Dir"}],
"aggregateRating": {"ratingValue": 70, "ratingCount": 5},
})
html = f''
mock_response = MagicMock()
mock_response.text = html
mock_response.raise_for_status = MagicMock()
session = _mock_session(mock_requests)
session.get.return_value = mock_response
movie = fetch_movie("https://www.csfd.cz/film/1-crowded/")
assert movie.directors == ["Dir"]
assert movie.rating == 70
assert len(movie.actors) == 10
assert movie.actors[0] == "Actor 0"
assert movie.actors[-1] == "Actor 9"
@patch("src.core.csfd.requests")
def test_fetch_movie_network_error(self, mock_requests):
"""Test network error handling."""
import requests as real_requests
session = _mock_session(mock_requests)
session.get.side_effect = real_requests.RequestException("Network error")
with pytest.raises(real_requests.RequestException):
fetch_movie("https://www.csfd.cz/film/123/")
class TestSearchMovies:
"""Tests for search_movies function."""
@patch("src.core.csfd.requests")
def test_search_movies(self, mock_requests):
"""Test movie search."""
search_html = """
Test Movie
Another Movie
"""
mock_response = MagicMock()
mock_response.text = search_html
mock_response.raise_for_status = MagicMock()
session = _mock_session(mock_requests)
session.get.return_value = mock_response
mock_requests.utils.quote = lambda x: x
results = search_movies("test", limit=10)
assert len(results) >= 1
assert any(m.csfd_id == 123 for m in results)
class TestFetchMovieById:
"""Tests for fetch_movie_by_id function."""
@patch("src.core.csfd.fetch_movie")
def test_fetch_by_id(self, mock_fetch):
"""Test fetching movie by ID."""
mock_fetch.return_value = CSFDMovie(title="Test", url="https://csfd.cz/film/9423/")
movie = fetch_movie_by_id(9423)
mock_fetch.assert_called_once_with("https://www.csfd.cz/film/9423/")
assert movie.title == "Test"
class TestAnubisPoW:
"""Tests for the Anubis proof-of-work solver."""
def test_solve_pow_difficulty_one(self):
"""Difficulty 1 requires a single leading zero nibble in the hash."""
import hashlib
random_data = "abc123"
hash_hex, nonce, _ = _solve_anubis_pow(random_data, difficulty=1)
assert hash_hex[0] == "0"
assert hashlib.sha256(f"{random_data}{nonce}".encode()).hexdigest() == hash_hex
def test_solve_pow_difficulty_two(self):
"""Difficulty 2 requires two leading zero nibbles (one zero byte)."""
hash_hex, _, _ = _solve_anubis_pow("seed", difficulty=2)
assert hash_hex[:2] == "00"
class TestDependencyCheck:
"""Tests for dependency checking."""
def test_dependencies_available(self):
"""Test that dependencies are available (they should be in test env)."""
# Should not raise
_check_dependencies()