Aniworld/tests/unit/test_anime_service.py
2025-11-15 09:11:02 +01:00

336 lines
11 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
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
def raise_error(*args):
raise Exception("Initialization failed")
bad_series_app.__setattr__ = raise_error
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."""
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):
"""Test get_anime_service factory function."""
from src.server.services.anime_service import get_anime_service
# The factory function doesn't take directory anymore
service = get_anime_service()
assert isinstance(service, AnimeService)
assert service._app is not None