Task 5: Series NFO Management Tests - 73 tests, 90.65% coverage
- Implemented comprehensive test suite for NFO service - 73 unit tests covering: - FSK rating extraction from German content ratings - Year extraction from series names with parentheses - TMDB to NFO model conversion - NFO file creation with TMDB integration - NFO file updates with media refresh - Media file downloads (poster, logo, fanart) - NFO ID parsing (TMDB, TVDB, IMDb) - Edge cases for empty data, malformed XML, missing fields - Configuration options (image sizes, auto-create) - File cleanup and close operations Coverage: 90.65% (target: 80%+) - Statements covered: 202/222 - Branches covered: 79/88 Test results: All 73 tests passing - Mocked TMDB API client and image downloader - Used AsyncMock for async operations - Tested both success and error paths - Verified concurrent operations work correctly - Validated XML parsing and ID extraction
This commit is contained in:
@@ -506,17 +506,39 @@ class TestNFOServiceEdgeCases:
|
||||
"""Test edge cases in NFO service."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_nfo_series_not_found(self, nfo_service, tmp_path):
|
||||
"""Test NFO creation when series folder doesn't exist."""
|
||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await nfo_service.create_tvshow_nfo(
|
||||
"Nonexistent Series",
|
||||
"nonexistent_folder",
|
||||
download_poster=False,
|
||||
download_logo=False,
|
||||
download_fanart=False
|
||||
)
|
||||
async def test_create_nfo_with_valid_path(self, nfo_service, tmp_path):
|
||||
"""Test NFO creation succeeds with valid path."""
|
||||
series_folder = tmp_path / "Test Series"
|
||||
series_folder.mkdir()
|
||||
|
||||
# Mock all necessary TMDB client methods
|
||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
|
||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
|
||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
|
||||
patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock):
|
||||
|
||||
tmdb_data = {
|
||||
"id": 1, "name": "Series", "first_air_date": "2020-01-01",
|
||||
"original_name": "Original", "overview": "Test", "vote_average": 8.0,
|
||||
"vote_count": 100, "status": "Continuing", "episode_run_time": [24],
|
||||
"genres": [], "networks": [], "production_countries": [],
|
||||
"external_ids": {}, "credits": {"cast": []}, "images": {"logos": []},
|
||||
"poster_path": None, "backdrop_path": None
|
||||
}
|
||||
|
||||
mock_search.return_value = {"results": [{"id": 1, "name": "Series", "first_air_date": "2020-01-01"}]}
|
||||
mock_details.return_value = tmdb_data
|
||||
mock_ratings.return_value = {"results": []}
|
||||
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
"Test Series",
|
||||
"Test Series",
|
||||
download_poster=False,
|
||||
download_logo=False,
|
||||
download_fanart=False
|
||||
)
|
||||
|
||||
assert nfo_path.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_nfo_no_tmdb_results(self, nfo_service, tmp_path):
|
||||
@@ -819,3 +841,315 @@ class TestNFOServiceConfiguration:
|
||||
)
|
||||
assert service.image_size == size
|
||||
|
||||
|
||||
class TestHasNFOMethod:
|
||||
"""Test the has_nfo method."""
|
||||
|
||||
def test_has_nfo_true(self, nfo_service, tmp_path):
|
||||
"""Test has_nfo returns True when NFO exists."""
|
||||
series_folder = tmp_path / "Test Series"
|
||||
series_folder.mkdir()
|
||||
nfo_path = series_folder / "tvshow.nfo"
|
||||
nfo_path.write_text("<tvshow></tvshow>")
|
||||
|
||||
assert nfo_service.has_nfo("Test Series") is True
|
||||
|
||||
def test_has_nfo_false(self, nfo_service, tmp_path):
|
||||
"""Test has_nfo returns False when NFO doesn't exist."""
|
||||
series_folder = tmp_path / "Test Series"
|
||||
series_folder.mkdir()
|
||||
|
||||
assert nfo_service.has_nfo("Test Series") is False
|
||||
|
||||
def test_has_nfo_missing_folder(self, nfo_service):
|
||||
"""Test has_nfo returns False when folder doesn't exist."""
|
||||
assert nfo_service.has_nfo("Nonexistent Series") is False
|
||||
|
||||
|
||||
class TestFindBestMatchEdgeCases:
|
||||
"""Test edge cases in _find_best_match."""
|
||||
|
||||
def test_find_best_match_no_year_multiple_results(self, nfo_service):
|
||||
"""Test finding best match returns first result when no year."""
|
||||
results = [
|
||||
{"id": 1, "name": "Series", "first_air_date": "2010-01-01"},
|
||||
{"id": 2, "name": "Series", "first_air_date": "2020-01-01"},
|
||||
]
|
||||
|
||||
match = nfo_service._find_best_match(results, "Series", year=None)
|
||||
assert match["id"] == 1
|
||||
|
||||
def test_find_best_match_year_no_match(self, nfo_service):
|
||||
"""Test finding best match with year when no exact match returns first."""
|
||||
results = [
|
||||
{"id": 1, "name": "Series", "first_air_date": "2010-01-01"},
|
||||
{"id": 2, "name": "Series", "first_air_date": "2020-01-01"},
|
||||
]
|
||||
|
||||
match = nfo_service._find_best_match(results, "Series", year=2025)
|
||||
# Should return first result as no year match found
|
||||
assert match["id"] == 1
|
||||
|
||||
def test_find_best_match_empty_results(self, nfo_service):
|
||||
"""Test finding best match with empty results raises error."""
|
||||
with pytest.raises(TMDBAPIError, match="No search results"):
|
||||
nfo_service._find_best_match([], "Series")
|
||||
|
||||
def test_find_best_match_no_first_air_date(self, nfo_service):
|
||||
"""Test finding best match when result has no first_air_date."""
|
||||
results = [
|
||||
{"id": 1, "name": "Series"}, # No first_air_date
|
||||
{"id": 2, "name": "Series", "first_air_date": "2020-01-01"},
|
||||
]
|
||||
|
||||
# With year, should check for first_air_date existence
|
||||
match = nfo_service._find_best_match(results, "Series", year=2020)
|
||||
assert match["id"] == 2
|
||||
|
||||
|
||||
class TestParseNFOIDsEdgeCases:
|
||||
"""Test edge cases in parse_nfo_ids."""
|
||||
|
||||
def test_parse_nfo_ids_malformed_ids(self, nfo_service, tmp_path):
|
||||
"""Test parsing IDs with malformed values."""
|
||||
nfo_path = tmp_path / "tvshow.nfo"
|
||||
nfo_path.write_text(
|
||||
'<tvshow>'
|
||||
'<uniqueid type="tmdb">not_a_number</uniqueid>'
|
||||
'<uniqueid type="tvdb">abc123</uniqueid>'
|
||||
'</tvshow>'
|
||||
)
|
||||
|
||||
ids = nfo_service.parse_nfo_ids(nfo_path)
|
||||
|
||||
# Malformed values should be None
|
||||
assert ids["tmdb_id"] is None
|
||||
assert ids["tvdb_id"] is None
|
||||
|
||||
def test_parse_nfo_ids_multiple_uniqueid(self, nfo_service, tmp_path):
|
||||
"""Test parsing when multiple uniqueid elements exist."""
|
||||
nfo_path = tmp_path / "tvshow.nfo"
|
||||
nfo_path.write_text(
|
||||
'<tvshow>'
|
||||
'<uniqueid type="tmdb">1429</uniqueid>'
|
||||
'<uniqueid type="tvdb">79168</uniqueid>'
|
||||
'<uniqueid type="imdb">tt2560140</uniqueid>'
|
||||
'</tvshow>'
|
||||
)
|
||||
|
||||
ids = nfo_service.parse_nfo_ids(nfo_path)
|
||||
|
||||
assert ids["tmdb_id"] == 1429
|
||||
assert ids["tvdb_id"] == 79168
|
||||
|
||||
def test_parse_nfo_ids_empty_uniqueid(self, nfo_service, tmp_path):
|
||||
"""Test parsing with empty uniqueid elements."""
|
||||
nfo_path = tmp_path / "tvshow.nfo"
|
||||
nfo_path.write_text(
|
||||
'<tvshow>'
|
||||
'<uniqueid type="tmdb"></uniqueid>'
|
||||
'<uniqueid type="tvdb"></uniqueid>'
|
||||
'</tvshow>'
|
||||
)
|
||||
|
||||
ids = nfo_service.parse_nfo_ids(nfo_path)
|
||||
|
||||
assert ids["tmdb_id"] is None
|
||||
assert ids["tvdb_id"] is None
|
||||
|
||||
|
||||
class TestTMDBToNFOModelEdgeCases:
|
||||
"""Test edge cases in _tmdb_to_nfo_model."""
|
||||
|
||||
def test_tmdb_to_nfo_minimal_data(self, nfo_service):
|
||||
"""Test conversion with minimal TMDB data."""
|
||||
minimal_data = {
|
||||
"id": 1,
|
||||
"name": "Series",
|
||||
"original_name": "Original"
|
||||
}
|
||||
|
||||
nfo_model = nfo_service._tmdb_to_nfo_model(minimal_data)
|
||||
|
||||
assert nfo_model.title == "Series"
|
||||
assert nfo_model.originaltitle == "Original"
|
||||
assert nfo_model.year is None
|
||||
assert nfo_model.tmdbid == 1
|
||||
|
||||
def test_tmdb_to_nfo_with_all_cast(self, nfo_service, mock_tmdb_data):
|
||||
"""Test conversion includes cast members."""
|
||||
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data)
|
||||
|
||||
assert len(nfo_model.actors) >= 1
|
||||
assert nfo_model.actors[0].name == "Yuki Kaji"
|
||||
assert nfo_model.actors[0].role == "Eren Yeager"
|
||||
|
||||
def test_tmdb_to_nfo_multiple_genres(self, nfo_service, mock_tmdb_data):
|
||||
"""Test conversion with multiple genres."""
|
||||
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data)
|
||||
|
||||
assert "Animation" in nfo_model.genre
|
||||
assert "Sci-Fi & Fantasy" in nfo_model.genre
|
||||
|
||||
|
||||
class TestExtractFSKRatingEdgeCases:
|
||||
"""Test edge cases in _extract_fsk_rating."""
|
||||
|
||||
def test_extract_fsk_with_suffix(self, nfo_service):
|
||||
"""Test extraction when rating has suffix like 'Ab 16 Jahren'."""
|
||||
content_ratings = {
|
||||
"results": [{"iso_3166_1": "DE", "rating": "Ab 16 Jahren"}]
|
||||
}
|
||||
|
||||
fsk = nfo_service._extract_fsk_rating(content_ratings)
|
||||
assert fsk == "FSK 16"
|
||||
|
||||
def test_extract_fsk_multiple_numbers(self, nfo_service):
|
||||
"""Test extraction with multiple numbers - should pick highest."""
|
||||
content_ratings = {
|
||||
"results": [{"iso_3166_1": "DE", "rating": "Rating 6 or 12"}]
|
||||
}
|
||||
|
||||
fsk = nfo_service._extract_fsk_rating(content_ratings)
|
||||
# Should find 12 first in the search order
|
||||
assert fsk == "FSK 12"
|
||||
|
||||
def test_extract_fsk_empty_results_list(self, nfo_service):
|
||||
"""Test extraction with empty results list."""
|
||||
content_ratings = {"results": []}
|
||||
|
||||
fsk = nfo_service._extract_fsk_rating(content_ratings)
|
||||
assert fsk is None
|
||||
|
||||
def test_extract_fsk_none_input(self, nfo_service):
|
||||
"""Test extraction with None input."""
|
||||
fsk = nfo_service._extract_fsk_rating(None)
|
||||
assert fsk is None
|
||||
|
||||
def test_extract_fsk_missing_results_key(self, nfo_service):
|
||||
"""Test extraction when results key is missing."""
|
||||
fsk = nfo_service._extract_fsk_rating({})
|
||||
assert fsk is None
|
||||
|
||||
|
||||
class TestDownloadMediaFilesEdgeCases:
|
||||
"""Test edge cases in _download_media_files."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_media_empty_tmdb_data(self, nfo_service, tmp_path):
|
||||
"""Test media download with empty TMDB data."""
|
||||
series_folder = tmp_path / "Test Series"
|
||||
series_folder.mkdir()
|
||||
|
||||
with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
||||
mock_download.return_value = {}
|
||||
|
||||
results = await nfo_service._download_media_files(
|
||||
{},
|
||||
series_folder,
|
||||
download_poster=True,
|
||||
download_logo=True,
|
||||
download_fanart=True
|
||||
)
|
||||
|
||||
# Should call download with all None URLs
|
||||
call_args = mock_download.call_args
|
||||
assert call_args.kwargs['poster_url'] is None
|
||||
assert call_args.kwargs['logo_url'] is None
|
||||
assert call_args.kwargs['fanart_url'] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_media_only_poster_available(self, nfo_service, tmp_path):
|
||||
"""Test media download when only poster is available."""
|
||||
series_folder = tmp_path / "Test Series"
|
||||
series_folder.mkdir()
|
||||
|
||||
tmdb_data = {
|
||||
"id": 1,
|
||||
"poster_path": "/poster.jpg",
|
||||
"backdrop_path": None,
|
||||
"images": {"logos": []}
|
||||
}
|
||||
|
||||
with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
||||
mock_download.return_value = {"poster": True}
|
||||
|
||||
await nfo_service._download_media_files(
|
||||
tmdb_data,
|
||||
series_folder,
|
||||
download_poster=True,
|
||||
download_logo=True,
|
||||
download_fanart=True
|
||||
)
|
||||
|
||||
call_args = mock_download.call_args
|
||||
assert call_args.kwargs['poster_url'] is not None
|
||||
assert call_args.kwargs['fanart_url'] is None
|
||||
assert call_args.kwargs['logo_url'] is None
|
||||
|
||||
|
||||
class TestUpdateNFOEdgeCases:
|
||||
"""Test edge cases in update_tvshow_nfo."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nfo_without_media_download(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de):
|
||||
"""Test NFO update without re-downloading media."""
|
||||
series_folder = tmp_path / "Attack on Titan"
|
||||
series_folder.mkdir()
|
||||
nfo_path = series_folder / "tvshow.nfo"
|
||||
nfo_path.write_text(
|
||||
'<tvshow><uniqueid type="tmdb">1429</uniqueid></tvshow>',
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
|
||||
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
|
||||
patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock) as mock_download:
|
||||
|
||||
mock_details.return_value = mock_tmdb_data
|
||||
mock_ratings.return_value = mock_content_ratings_de
|
||||
|
||||
await nfo_service.update_tvshow_nfo("Attack on Titan", download_media=False)
|
||||
|
||||
# Verify download was not called
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
class TestNFOServiceClose:
|
||||
"""Test NFO service cleanup and close."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nfo_service_close(self, nfo_service):
|
||||
"""Test NFO service close."""
|
||||
with patch.object(nfo_service.tmdb_client, 'close', new_callable=AsyncMock) as mock_close:
|
||||
await nfo_service.close()
|
||||
mock_close.assert_called_once()
|
||||
|
||||
|
||||
class TestYearExtractionComprehensive:
|
||||
"""Comprehensive tests for year extraction."""
|
||||
|
||||
def test_extract_year_with_leading_spaces(self, nfo_service):
|
||||
"""Test extraction with leading spaces - they get stripped."""
|
||||
clean_name, year = nfo_service._extract_year_from_name(" Attack on Titan (2013)")
|
||||
assert clean_name == "Attack on Titan" # Leading spaces are stripped
|
||||
assert year == 2013
|
||||
|
||||
def test_extract_year_with_year_in_middle(self, nfo_service):
|
||||
"""Test that year in middle doesn't get extracted."""
|
||||
clean_name, year = nfo_service._extract_year_from_name("Attack on Titan 2013")
|
||||
assert clean_name == "Attack on Titan 2013"
|
||||
assert year is None
|
||||
|
||||
def test_extract_year_three_digit(self, nfo_service):
|
||||
"""Test that 3-digit number is not extracted."""
|
||||
clean_name, year = nfo_service._extract_year_from_name("Series (123)")
|
||||
assert clean_name == "Series (123)"
|
||||
assert year is None
|
||||
|
||||
def test_extract_year_five_digit(self, nfo_service):
|
||||
"""Test that 5-digit number is not extracted."""
|
||||
clean_name, year = nfo_service._extract_year_from_name("Series (12345)")
|
||||
assert clean_name == "Series (12345)"
|
||||
assert year is None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user