Add comprehensive unit tests for core services (93 tests)
This commit is contained in:
@@ -1,27 +1,332 @@
|
||||
"""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.mark.asyncio
|
||||
async def test_list_missing_empty(tmp_path):
|
||||
svc = AnimeService(directory=str(tmp_path))
|
||||
# SeriesApp may return empty list depending on filesystem; ensure it returns a list
|
||||
result = await svc.list_missing()
|
||||
assert isinstance(result, list)
|
||||
@pytest.fixture
|
||||
def mock_series_app():
|
||||
"""Create a mock SeriesApp instance."""
|
||||
with patch("src.server.services.anime_service.SeriesApp") as mock_class:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.series_list = []
|
||||
mock_instance.search = MagicMock(return_value=[])
|
||||
mock_instance.ReScan = MagicMock()
|
||||
mock_instance.download = MagicMock(return_value=True)
|
||||
mock_class.return_value = mock_instance
|
||||
yield mock_instance
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_empty_query(tmp_path):
|
||||
svc = AnimeService(directory=str(tmp_path))
|
||||
res = await svc.search("")
|
||||
assert res == []
|
||||
@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.mark.asyncio
|
||||
async def test_rescan_and_cache_clear(tmp_path):
|
||||
svc = AnimeService(directory=str(tmp_path))
|
||||
# calling rescan should not raise
|
||||
await svc.rescan()
|
||||
@pytest.fixture
|
||||
def anime_service(tmp_path, mock_series_app, mock_progress_service):
|
||||
"""Create an AnimeService instance for testing."""
|
||||
return AnimeService(
|
||||
directory=str(tmp_path),
|
||||
max_workers=2,
|
||||
progress_service=mock_progress_service,
|
||||
)
|
||||
|
||||
|
||||
class TestAnimeServiceInitialization:
|
||||
"""Test AnimeService initialization."""
|
||||
|
||||
def test_initialization_success(self, tmp_path, mock_progress_service):
|
||||
"""Test successful service initialization."""
|
||||
with patch("src.server.services.anime_service.SeriesApp"):
|
||||
service = AnimeService(
|
||||
directory=str(tmp_path),
|
||||
max_workers=2,
|
||||
progress_service=mock_progress_service,
|
||||
)
|
||||
|
||||
assert service._directory == str(tmp_path)
|
||||
assert service._executor is not None
|
||||
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."""
|
||||
with patch(
|
||||
"src.server.services.anime_service.SeriesApp"
|
||||
) as mock_class:
|
||||
mock_class.side_effect = Exception("Initialization failed")
|
||||
|
||||
with pytest.raises(
|
||||
AnimeServiceError, match="Initialization failed"
|
||||
):
|
||||
AnimeService(
|
||||
directory=str(tmp_path),
|
||||
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."""
|
||||
await anime_service.rescan()
|
||||
|
||||
# Verify SeriesApp.ReScan was called
|
||||
mock_series_app.ReScan.assert_called_once()
|
||||
|
||||
# Verify progress tracking
|
||||
mock_progress_service.start_progress.assert_called_once()
|
||||
mock_progress_service.complete_progress.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_with_callback(self, anime_service, mock_series_app):
|
||||
"""Test rescan with progress callback."""
|
||||
callback_called = False
|
||||
callback_data = None
|
||||
|
||||
def callback(data):
|
||||
nonlocal callback_called, callback_data
|
||||
callback_called = True
|
||||
callback_data = data
|
||||
|
||||
# Mock ReScan to call the callback
|
||||
def mock_rescan(cb):
|
||||
if cb:
|
||||
cb({"current": 5, "total": 10, "message": "Scanning..."})
|
||||
|
||||
mock_series_app.ReScan.side_effect = mock_rescan
|
||||
|
||||
await anime_service.rescan(callback=callback)
|
||||
|
||||
assert callback_called
|
||||
assert callback_data is not None
|
||||
|
||||
@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"}]
|
||||
|
||||
# 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()
|
||||
|
||||
# Verify progress failure was recorded
|
||||
mock_progress_service.fail_progress.assert_called_once()
|
||||
|
||||
|
||||
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(
|
||||
"test_series", 1, 1, "test_key", None
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_with_callback(self, anime_service, mock_series_app):
|
||||
"""Test download with progress callback."""
|
||||
callback = MagicMock()
|
||||
mock_series_app.download.return_value = True
|
||||
|
||||
result = await anime_service.download(
|
||||
serie_folder="test_series",
|
||||
season=1,
|
||||
episode=1,
|
||||
key="test_key",
|
||||
callback=callback,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
# Verify callback was passed to SeriesApp
|
||||
mock_series_app.download.assert_called_once_with(
|
||||
"test_series", 1, 1, "test_key", callback
|
||||
)
|
||||
|
||||
@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 TestFactoryFunction:
|
||||
"""Test factory function."""
|
||||
|
||||
def test_get_anime_service(self, tmp_path):
|
||||
"""Test get_anime_service factory function."""
|
||||
from src.server.services.anime_service import get_anime_service
|
||||
|
||||
with patch("src.server.services.anime_service.SeriesApp"):
|
||||
service = get_anime_service(directory=str(tmp_path))
|
||||
|
||||
assert isinstance(service, AnimeService)
|
||||
assert service._directory == str(tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user