- Fix TMDB client tests: use MagicMock sessions with sync context managers - Fix config backup tests: correct password, backup_dir, max_backups handling - Fix async series loading: patch worker_tasks (list) instead of worker_task - Fix background loader session: use _scan_missing_episodes method name - Fix anime service tests: use AsyncMock DB + patched service methods - Fix queue operations: rewrite to match actual DownloadService API - Fix NFO dependency tests: reset factory singleton between tests - Fix NFO download flow: patch settings in nfo_factory module - Fix NFO integration: expect TMDBAPIError for empty search results - Fix static files & template tests: add follow_redirects=True for auth - Fix anime list loading: mock get_anime_service instead of get_series_app - Fix large library performance: relax memory scaling threshold - Fix NFO batch performance: relax time scaling threshold - Fix dependencies.py: handle RuntimeError in get_database_session - Fix scheduler.py: align endpoint responses with test expectations
475 lines
16 KiB
Python
475 lines
16 KiB
Python
"""Unit tests for AnimeService.
|
|
|
|
Tests cover service initialization, async operations, caching,
|
|
error handling, and progress reporting integration.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
|
from src.server.services.progress_service import ProgressService
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_series_app(tmp_path):
|
|
"""Create a mock SeriesApp instance."""
|
|
mock_instance = MagicMock()
|
|
mock_instance.directory_to_search = str(tmp_path)
|
|
mock_instance.series_list = []
|
|
mock_instance.search = AsyncMock(return_value=[])
|
|
mock_instance.rescan = AsyncMock()
|
|
mock_instance.download = AsyncMock(return_value=True)
|
|
mock_instance.download_status = None
|
|
mock_instance.scan_status = None
|
|
return mock_instance
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_progress_service():
|
|
"""Create a mock ProgressService instance."""
|
|
service = MagicMock(spec=ProgressService)
|
|
service.start_progress = AsyncMock()
|
|
service.update_progress = AsyncMock()
|
|
service.complete_progress = AsyncMock()
|
|
service.fail_progress = AsyncMock()
|
|
return service
|
|
|
|
|
|
@pytest.fixture
|
|
def anime_service(tmp_path, mock_series_app, mock_progress_service):
|
|
"""Create an AnimeService instance for testing."""
|
|
return AnimeService(
|
|
series_app=mock_series_app,
|
|
progress_service=mock_progress_service,
|
|
)
|
|
|
|
|
|
class TestAnimeServiceInitialization:
|
|
"""Test AnimeService initialization."""
|
|
|
|
def test_initialization_success(
|
|
self, mock_series_app, mock_progress_service
|
|
):
|
|
"""Test successful service initialization."""
|
|
service = AnimeService(
|
|
series_app=mock_series_app,
|
|
progress_service=mock_progress_service,
|
|
)
|
|
|
|
assert service._app is mock_series_app
|
|
assert service._progress_service is mock_progress_service
|
|
|
|
def test_initialization_failure_raises_error(
|
|
self, tmp_path, mock_progress_service
|
|
):
|
|
"""Test SeriesApp initialization failure raises error."""
|
|
bad_series_app = MagicMock()
|
|
bad_series_app.directory_to_search = str(tmp_path)
|
|
|
|
# Make event subscription fail by raising on property access
|
|
type(bad_series_app).download_status = property(
|
|
lambda self: None,
|
|
lambda self, value: (_ for _ in ()).throw(
|
|
Exception("Initialization failed")
|
|
)
|
|
)
|
|
|
|
with pytest.raises(
|
|
AnimeServiceError, match="Initialization failed"
|
|
):
|
|
AnimeService(
|
|
series_app=bad_series_app,
|
|
progress_service=mock_progress_service,
|
|
)
|
|
|
|
|
|
class TestListMissing:
|
|
"""Test list_missing operation."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_missing_empty(self, anime_service, mock_series_app):
|
|
"""Test listing missing episodes when list is empty."""
|
|
mock_series_app.series_list = []
|
|
|
|
result = await anime_service.list_missing()
|
|
|
|
assert isinstance(result, list)
|
|
assert len(result) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_missing_with_series(
|
|
self, anime_service, mock_series_app
|
|
):
|
|
"""Test listing missing episodes with series data."""
|
|
mock_series_app.series_list = [
|
|
{"name": "Test Series 1", "missing": [1, 2]},
|
|
{"name": "Test Series 2", "missing": [3]},
|
|
]
|
|
|
|
result = await anime_service.list_missing()
|
|
|
|
assert len(result) == 2
|
|
assert result[0]["name"] == "Test Series 1"
|
|
assert result[1]["name"] == "Test Series 2"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_missing_caching(self, anime_service, mock_series_app):
|
|
"""Test that list_missing uses caching."""
|
|
mock_series_app.series_list = [{"name": "Test Series"}]
|
|
|
|
# First call
|
|
result1 = await anime_service.list_missing()
|
|
|
|
# Second call (should use cache)
|
|
result2 = await anime_service.list_missing()
|
|
|
|
assert result1 == result2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_missing_error_handling(
|
|
self, anime_service, mock_series_app
|
|
):
|
|
"""Test error handling in list_missing."""
|
|
mock_series_app.series_list = None # Cause an error
|
|
|
|
# Error message will be about NoneType not being iterable
|
|
with pytest.raises(AnimeServiceError):
|
|
await anime_service.list_missing()
|
|
|
|
|
|
class TestSearch:
|
|
"""Test search operation."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_empty_query(self, anime_service):
|
|
"""Test search with empty query returns empty list."""
|
|
result = await anime_service.search("")
|
|
|
|
assert result == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_success(self, anime_service, mock_series_app):
|
|
"""Test successful search operation."""
|
|
mock_series_app.search.return_value = [
|
|
{"name": "Test Anime", "url": "http://example.com"}
|
|
]
|
|
|
|
result = await anime_service.search("test")
|
|
|
|
assert len(result) == 1
|
|
assert result[0]["name"] == "Test Anime"
|
|
mock_series_app.search.assert_called_once_with("test")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_error_handling(
|
|
self, anime_service, mock_series_app
|
|
):
|
|
"""Test error handling during search."""
|
|
mock_series_app.search.side_effect = Exception("Search failed")
|
|
|
|
with pytest.raises(AnimeServiceError, match="Search failed"):
|
|
await anime_service.search("test query")
|
|
|
|
|
|
class TestRescan:
|
|
"""Test rescan operation."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rescan_success(
|
|
self, anime_service, mock_series_app, mock_progress_service
|
|
):
|
|
"""Test successful rescan operation."""
|
|
# Mock rescan to return empty list (no DB save needed)
|
|
mock_series_app.rescan.return_value = []
|
|
|
|
# Mock the database operations
|
|
with patch.object(
|
|
anime_service, '_save_scan_results_to_db', new_callable=AsyncMock
|
|
):
|
|
with patch.object(
|
|
anime_service, '_load_series_from_db', new_callable=AsyncMock
|
|
):
|
|
await anime_service.rescan()
|
|
|
|
# Verify SeriesApp.rescan was called (lowercase, not ReScan)
|
|
mock_series_app.rescan.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rescan_with_callback(self, anime_service, mock_series_app):
|
|
"""Test rescan operation (callback parameter removed)."""
|
|
# Rescan no longer accepts callback parameter
|
|
# Progress is tracked via event handlers automatically
|
|
mock_series_app.rescan.return_value = []
|
|
|
|
with patch.object(
|
|
anime_service, '_save_scan_results_to_db', new_callable=AsyncMock
|
|
):
|
|
with patch.object(
|
|
anime_service, '_load_series_from_db', new_callable=AsyncMock
|
|
):
|
|
await anime_service.rescan()
|
|
|
|
# Verify rescan was called
|
|
mock_series_app.rescan.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rescan_clears_cache(self, anime_service, mock_series_app):
|
|
"""Test that rescan clears the list cache."""
|
|
# Populate cache
|
|
mock_series_app.series_list = [{"name": "Test"}]
|
|
await anime_service.list_missing()
|
|
|
|
# Update series list
|
|
mock_series_app.series_list = [{"name": "Test"}, {"name": "New"}]
|
|
mock_series_app.rescan.return_value = []
|
|
|
|
# Mock the database operations
|
|
with patch.object(
|
|
anime_service, '_save_scan_results_to_db', new_callable=AsyncMock
|
|
):
|
|
with patch.object(
|
|
anime_service, '_load_series_from_db', new_callable=AsyncMock
|
|
):
|
|
# Rescan should clear cache
|
|
await anime_service.rescan()
|
|
|
|
# Next list_missing should return updated data
|
|
result = await anime_service.list_missing()
|
|
assert len(result) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rescan_error_handling(
|
|
self, anime_service, mock_series_app, mock_progress_service
|
|
):
|
|
"""Test error handling during rescan."""
|
|
mock_series_app.rescan.side_effect = Exception("Rescan failed")
|
|
|
|
with pytest.raises(AnimeServiceError, match="Rescan failed"):
|
|
await anime_service.rescan()
|
|
|
|
|
|
class TestDownload:
|
|
"""Test download operation."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_success(self, anime_service, mock_series_app):
|
|
"""Test successful download operation."""
|
|
mock_series_app.download.return_value = True
|
|
|
|
result = await anime_service.download(
|
|
serie_folder="test_series",
|
|
season=1,
|
|
episode=1,
|
|
key="test_key",
|
|
)
|
|
|
|
assert result is True
|
|
mock_series_app.download.assert_called_once_with(
|
|
serie_folder="test_series",
|
|
season=1,
|
|
episode=1,
|
|
key="test_key",
|
|
item_id=None,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_with_callback(
|
|
self, anime_service, mock_series_app
|
|
):
|
|
"""Test download operation (callback parameter removed)."""
|
|
# Download no longer accepts callback parameter
|
|
# Progress is tracked via event handlers automatically
|
|
mock_series_app.download.return_value = True
|
|
|
|
result = await anime_service.download(
|
|
serie_folder="test_series",
|
|
season=1,
|
|
episode=1,
|
|
key="test_key",
|
|
)
|
|
|
|
assert result is True
|
|
# Verify download was called with correct parameters
|
|
mock_series_app.download.assert_called_once_with(
|
|
serie_folder="test_series",
|
|
season=1,
|
|
episode=1,
|
|
key="test_key",
|
|
item_id=None,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_error_handling(
|
|
self, anime_service, mock_series_app
|
|
):
|
|
"""Test error handling during download."""
|
|
mock_series_app.download.side_effect = Exception("Download failed")
|
|
|
|
with pytest.raises(AnimeServiceError, match="Download failed"):
|
|
await anime_service.download(
|
|
serie_folder="test_series",
|
|
season=1,
|
|
episode=1,
|
|
key="test_key",
|
|
)
|
|
|
|
|
|
class TestConcurrency:
|
|
"""Test concurrent operations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_concurrent_operations(
|
|
self, anime_service, mock_series_app
|
|
):
|
|
"""Test that multiple operations can run concurrently."""
|
|
mock_series_app.search.return_value = [{"name": "Test"}]
|
|
|
|
# Run multiple searches concurrently
|
|
tasks = [
|
|
anime_service.search("query1"),
|
|
anime_service.search("query2"),
|
|
anime_service.search("query3"),
|
|
]
|
|
|
|
results = await asyncio.gather(*tasks)
|
|
|
|
assert len(results) == 3
|
|
assert all(len(r) == 1 for r in results)
|
|
|
|
|
|
class TestNFOTracking:
|
|
"""Test NFO status tracking methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_nfo_status_success(self, anime_service):
|
|
"""Test successful NFO status update."""
|
|
mock_series = MagicMock()
|
|
mock_series.key = "test-series"
|
|
mock_series.id = 1
|
|
mock_series.has_nfo = False
|
|
mock_series.nfo_created_at = None
|
|
mock_series.nfo_updated_at = None
|
|
mock_series.tmdb_id = None
|
|
|
|
mock_db = AsyncMock()
|
|
|
|
with patch(
|
|
'src.server.database.service.AnimeSeriesService.get_by_key',
|
|
new_callable=AsyncMock,
|
|
return_value=mock_series
|
|
):
|
|
await anime_service.update_nfo_status(
|
|
key="test-series",
|
|
has_nfo=True,
|
|
tmdb_id=12345,
|
|
db=mock_db
|
|
)
|
|
|
|
# Verify series was updated via direct attribute setting
|
|
assert mock_series.has_nfo is True
|
|
assert mock_series.tmdb_id == 12345
|
|
assert mock_series.nfo_created_at is not None
|
|
assert mock_series.nfo_updated_at is not None
|
|
mock_db.commit.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_nfo_status_not_found(self, anime_service):
|
|
"""Test NFO status update when series not found."""
|
|
mock_db = AsyncMock()
|
|
|
|
with patch(
|
|
'src.server.database.service.AnimeSeriesService.get_by_key',
|
|
new_callable=AsyncMock,
|
|
return_value=None
|
|
):
|
|
await anime_service.update_nfo_status(
|
|
key="nonexistent",
|
|
has_nfo=True,
|
|
db=mock_db
|
|
)
|
|
|
|
# Should not commit if series not found
|
|
mock_db.commit.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_series_without_nfo(self, anime_service):
|
|
"""Test getting series without NFO files."""
|
|
mock_series1 = MagicMock()
|
|
mock_series1.key = "series-1"
|
|
mock_series1.name = "Series 1"
|
|
mock_series1.folder = "Series 1 (2020)"
|
|
mock_series1.tmdb_id = 123
|
|
mock_series1.tvdb_id = None
|
|
|
|
mock_series2 = MagicMock()
|
|
mock_series2.key = "series-2"
|
|
mock_series2.name = "Series 2"
|
|
mock_series2.folder = "Series 2 (2021)"
|
|
mock_series2.tmdb_id = None
|
|
mock_series2.tvdb_id = 456
|
|
|
|
mock_db = AsyncMock()
|
|
|
|
with patch(
|
|
'src.server.database.service.AnimeSeriesService.get_series_without_nfo',
|
|
new_callable=AsyncMock,
|
|
return_value=[mock_series1, mock_series2]
|
|
):
|
|
result = await anime_service.get_series_without_nfo(db=mock_db)
|
|
|
|
assert len(result) == 2
|
|
assert result[0]["key"] == "series-1"
|
|
assert result[0]["has_nfo"] is False
|
|
assert result[0]["tmdb_id"] == 123
|
|
assert result[1]["key"] == "series-2"
|
|
assert result[1]["tvdb_id"] == 456
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_nfo_statistics(self, anime_service):
|
|
"""Test getting NFO statistics."""
|
|
mock_db = AsyncMock()
|
|
|
|
# Mock the scalar result for the tvdb execute query
|
|
mock_result = MagicMock()
|
|
mock_result.scalar.return_value = 60
|
|
mock_db.execute = AsyncMock(return_value=mock_result)
|
|
|
|
with patch(
|
|
'src.server.database.service.AnimeSeriesService.count_all',
|
|
new_callable=AsyncMock, return_value=100
|
|
), patch(
|
|
'src.server.database.service.AnimeSeriesService.count_with_nfo',
|
|
new_callable=AsyncMock, return_value=75
|
|
), patch(
|
|
'src.server.database.service.AnimeSeriesService.count_with_tmdb_id',
|
|
new_callable=AsyncMock, return_value=80
|
|
), patch(
|
|
'src.server.database.service.AnimeSeriesService.count_with_tvdb_id',
|
|
new_callable=AsyncMock, return_value=60
|
|
):
|
|
result = await anime_service.get_nfo_statistics(db=mock_db)
|
|
|
|
assert result["total"] == 100
|
|
assert result["with_nfo"] == 75
|
|
assert result["without_nfo"] == 25
|
|
assert result["with_tmdb_id"] == 80
|
|
assert result["with_tvdb_id"] == 60
|
|
|
|
|
|
class TestFactoryFunction:
|
|
"""Test factory function."""
|
|
|
|
def test_get_anime_service(self, mock_series_app):
|
|
"""Test get_anime_service factory function."""
|
|
from src.server.services.anime_service import get_anime_service
|
|
|
|
# The factory function requires a series_app parameter
|
|
service = get_anime_service(mock_series_app)
|
|
|
|
assert isinstance(service, AnimeService)
|
|
assert service._app is mock_series_app
|