Files
Aniworld/tests/unit/test_anime_service.py
Lukas e84a220f55 Expand test coverage: ~188 new tests across 6 critical files
- Fix failing test_authenticated_request_succeeds (dependency override)
- Expand test_anime_service.py (+35 tests: status events, DB, broadcasts)
- Create test_queue_repository.py (27 tests: CRUD, model conversion)
- Expand test_enhanced_provider.py (+24 tests: fetch, download, redirect)
- Expand test_serie_scanner.py (+25 tests: events, year extract, mp4 scan)
- Create test_database_connection.py (38 tests: sessions, transactions)
- Expand test_anime_endpoints.py (+39 tests: status, search, loading)
- Clean up docs/instructions.md TODO list
2026-02-15 17:49:12 +01:00

1444 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit tests for AnimeService.
Tests cover service initialization, async operations, caching,
error handling, progress reporting integration, scan/download status
event handling, database persistence, and WebSocket broadcasting.
"""
from __future__ import annotations
import asyncio
import time
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.server.services.anime_service import (
AnimeService,
AnimeServiceError,
sync_series_from_data_files,
)
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
# =============================================================================
# New coverage tests download / scan status, DB persistence, broadcasting
# =============================================================================
class _FakeDownloadArgs:
"""Minimal stand-in for DownloadStatusEventArgs."""
def __init__(self, **kwargs):
self.status = kwargs.get("status", "started")
self.serie_folder = kwargs.get("serie_folder", "TestFolder")
self.season = kwargs.get("season", 1)
self.episode = kwargs.get("episode", 1)
self.item_id = kwargs.get("item_id", None)
self.progress = kwargs.get("progress", 0)
self.message = kwargs.get("message", None)
self.error = kwargs.get("error", None)
self.mbper_sec = kwargs.get("mbper_sec", None)
self.eta = kwargs.get("eta", None)
class _FakeScanArgs:
"""Minimal stand-in for ScanStatusEventArgs."""
def __init__(self, **kwargs):
self.status = kwargs.get("status", "started")
self.current = kwargs.get("current", 0)
self.total = kwargs.get("total", 10)
self.folder = kwargs.get("folder", "")
self.message = kwargs.get("message", None)
self.error = kwargs.get("error", None)
class TestOnDownloadStatus:
"""Test _on_download_status event handler."""
def test_download_started_schedules_start_progress(
self, anime_service, mock_progress_service
):
"""started event should schedule start_progress."""
loop = asyncio.new_event_loop()
anime_service._event_loop = loop
try:
with patch("asyncio.get_running_loop", side_effect=RuntimeError):
with patch("asyncio.run_coroutine_threadsafe") as mock_run:
args = _FakeDownloadArgs(
status="started", item_id="q-1"
)
anime_service._on_download_status(args)
mock_run.assert_called_once()
coro = mock_run.call_args[0][0]
assert coro is not None
finally:
loop.close()
def test_download_progress_schedules_update(
self, anime_service, mock_progress_service
):
"""progress event should schedule update_progress."""
loop = asyncio.new_event_loop()
anime_service._event_loop = loop
try:
with patch("asyncio.get_running_loop", side_effect=RuntimeError):
with patch("asyncio.run_coroutine_threadsafe") as mock_run:
args = _FakeDownloadArgs(
status="progress",
progress=42,
message="Downloading...",
mbper_sec=5.5,
eta=30,
)
anime_service._on_download_status(args)
mock_run.assert_called_once()
finally:
loop.close()
def test_download_completed_schedules_complete(
self, anime_service, mock_progress_service
):
"""completed event should schedule complete_progress."""
loop = asyncio.new_event_loop()
anime_service._event_loop = loop
try:
with patch("asyncio.get_running_loop", side_effect=RuntimeError):
with patch("asyncio.run_coroutine_threadsafe") as mock_run:
args = _FakeDownloadArgs(status="completed")
anime_service._on_download_status(args)
mock_run.assert_called_once()
finally:
loop.close()
def test_download_failed_schedules_fail(
self, anime_service, mock_progress_service
):
"""failed event should schedule fail_progress."""
loop = asyncio.new_event_loop()
anime_service._event_loop = loop
try:
with patch("asyncio.get_running_loop", side_effect=RuntimeError):
with patch("asyncio.run_coroutine_threadsafe") as mock_run:
args = _FakeDownloadArgs(
status="failed", error=Exception("Err")
)
anime_service._on_download_status(args)
mock_run.assert_called_once()
finally:
loop.close()
def test_progress_id_from_item_id(self, anime_service):
"""item_id should be used as progress_id when available."""
loop = asyncio.new_event_loop()
anime_service._event_loop = loop
try:
with patch("asyncio.get_running_loop", side_effect=RuntimeError):
with patch("asyncio.run_coroutine_threadsafe") as mock_run:
args = _FakeDownloadArgs(
status="started", item_id="queue-42"
)
anime_service._on_download_status(args)
coro = mock_run.call_args[0][0]
# The coroutine was created with progress_id="queue-42"
assert mock_run.called
finally:
loop.close()
def test_progress_id_fallback_without_item_id(self, anime_service):
"""Without item_id, progress_id is built from folder/season/episode."""
loop = asyncio.new_event_loop()
anime_service._event_loop = loop
try:
with patch("asyncio.get_running_loop", side_effect=RuntimeError):
with patch("asyncio.run_coroutine_threadsafe") as mock_run:
args = _FakeDownloadArgs(
status="started",
item_id=None,
serie_folder="FolderX",
season=2,
episode=5,
)
anime_service._on_download_status(args)
assert mock_run.called
finally:
loop.close()
def test_no_event_loop_returns_silently(self, anime_service):
"""No loop available should not raise."""
anime_service._event_loop = None
with patch("asyncio.get_running_loop", side_effect=RuntimeError):
args = _FakeDownloadArgs(status="started")
anime_service._on_download_status(args) # should not raise
class TestOnScanStatus:
"""Test _on_scan_status event handler."""
def test_scan_started_schedules_progress_and_broadcast(
self, anime_service, mock_progress_service
):
"""started scan event should schedule start_progress and broadcast."""
loop = asyncio.new_event_loop()
anime_service._event_loop = loop
try:
with patch("asyncio.get_running_loop", side_effect=RuntimeError):
with patch("asyncio.run_coroutine_threadsafe") as mock_run:
args = _FakeScanArgs(status="started", total=5)
anime_service._on_scan_status(args)
# 2 calls: start_progress + broadcast_scan_started_safe
assert mock_run.call_count == 2
assert anime_service._is_scanning is True
finally:
loop.close()
def test_scan_progress_updates_counters(
self, anime_service, mock_progress_service
):
"""progress scan event should update counters."""
loop = asyncio.new_event_loop()
anime_service._event_loop = loop
try:
with patch("asyncio.get_running_loop", side_effect=RuntimeError):
with patch("asyncio.run_coroutine_threadsafe"):
args = _FakeScanArgs(
status="progress", current=3, total=10,
folder="Naruto"
)
anime_service._on_scan_status(args)
assert anime_service._scan_directories_count == 3
assert anime_service._scan_current_directory == "Naruto"
finally:
loop.close()
def test_scan_completed_marks_done(
self, anime_service, mock_progress_service
):
"""completed scan event should mark scanning as False."""
loop = asyncio.new_event_loop()
anime_service._event_loop = loop
anime_service._is_scanning = True
anime_service._scan_start_time = time.time() - 5
try:
with patch("asyncio.get_running_loop", side_effect=RuntimeError):
with patch("asyncio.run_coroutine_threadsafe"):
args = _FakeScanArgs(status="completed", total=10)
anime_service._on_scan_status(args)
assert anime_service._is_scanning is False
finally:
loop.close()
def test_scan_failed_marks_done(
self, anime_service, mock_progress_service
):
"""failed scan event should reset scanning state."""
loop = asyncio.new_event_loop()
anime_service._event_loop = loop
anime_service._is_scanning = True
try:
with patch("asyncio.get_running_loop", side_effect=RuntimeError):
with patch("asyncio.run_coroutine_threadsafe"):
args = _FakeScanArgs(
status="failed", error=Exception("boom")
)
anime_service._on_scan_status(args)
assert anime_service._is_scanning is False
finally:
loop.close()
def test_scan_cancelled_marks_done(
self, anime_service, mock_progress_service
):
"""cancelled scan event should reset scanning state."""
loop = asyncio.new_event_loop()
anime_service._event_loop = loop
anime_service._is_scanning = True
try:
with patch("asyncio.get_running_loop", side_effect=RuntimeError):
with patch("asyncio.run_coroutine_threadsafe"):
args = _FakeScanArgs(status="cancelled")
anime_service._on_scan_status(args)
assert anime_service._is_scanning is False
finally:
loop.close()
def test_scan_no_loop_returns_silently(self, anime_service):
"""No loop available should not raise for scan events."""
anime_service._event_loop = None
with patch("asyncio.get_running_loop", side_effect=RuntimeError):
args = _FakeScanArgs(status="started")
anime_service._on_scan_status(args) # no error
class TestGetScanStatus:
"""Test get_scan_status method."""
def test_returns_status_dict(self, anime_service):
"""Should return dict with expected keys."""
anime_service._is_scanning = True
anime_service._scan_total_items = 42
anime_service._scan_directories_count = 7
anime_service._scan_current_directory = "Naruto"
result = anime_service.get_scan_status()
assert result["is_scanning"] is True
assert result["total_items"] == 42
assert result["directories_scanned"] == 7
assert result["current_directory"] == "Naruto"
class TestBroadcastHelpers:
"""Test WebSocket broadcast safety wrappers."""
@pytest.mark.asyncio
async def test_broadcast_scan_started_safe(self, anime_service):
"""Should call websocket_service.broadcast_scan_started."""
anime_service._websocket_service.broadcast_scan_started = AsyncMock()
await anime_service._broadcast_scan_started_safe(total_items=5)
anime_service._websocket_service.broadcast_scan_started.assert_called_once()
@pytest.mark.asyncio
async def test_broadcast_scan_started_safe_handles_error(
self, anime_service
):
"""WS failure should be swallowed, not raised."""
anime_service._websocket_service.broadcast_scan_started = AsyncMock(
side_effect=Exception("ws-down")
)
# Should NOT raise
await anime_service._broadcast_scan_started_safe(total_items=5)
@pytest.mark.asyncio
async def test_broadcast_scan_progress_safe(self, anime_service):
"""Should call broadcast_scan_progress."""
anime_service._websocket_service.broadcast_scan_progress = AsyncMock()
await anime_service._broadcast_scan_progress_safe(
directories_scanned=3, files_found=3,
current_directory="AOT", total_items=10,
)
anime_service._websocket_service.broadcast_scan_progress.assert_called_once()
@pytest.mark.asyncio
async def test_broadcast_scan_progress_safe_handles_error(
self, anime_service
):
"""WS failure should be swallowed."""
anime_service._websocket_service.broadcast_scan_progress = AsyncMock(
side_effect=Exception("ws-down")
)
await anime_service._broadcast_scan_progress_safe(
directories_scanned=0, files_found=0,
current_directory="", total_items=0,
)
@pytest.mark.asyncio
async def test_broadcast_scan_completed_safe(self, anime_service):
"""Should call broadcast_scan_completed."""
anime_service._websocket_service.broadcast_scan_completed = AsyncMock()
await anime_service._broadcast_scan_completed_safe(
total_directories=10, total_files=10, elapsed_seconds=5.0,
)
anime_service._websocket_service.broadcast_scan_completed.assert_called_once()
@pytest.mark.asyncio
async def test_broadcast_scan_completed_safe_handles_error(
self, anime_service
):
"""WS failure should be swallowed."""
anime_service._websocket_service.broadcast_scan_completed = AsyncMock(
side_effect=Exception("ws-down")
)
await anime_service._broadcast_scan_completed_safe(
total_directories=0, total_files=0, elapsed_seconds=0,
)
@pytest.mark.asyncio
async def test_broadcast_series_updated(self, anime_service):
"""Should broadcast series_updated over WebSocket."""
anime_service._websocket_service.broadcast = AsyncMock()
await anime_service._broadcast_series_updated("aot")
anime_service._websocket_service.broadcast.assert_called_once()
payload = anime_service._websocket_service.broadcast.call_args[0][0]
assert payload["type"] == "series_updated"
assert payload["key"] == "aot"
@pytest.mark.asyncio
async def test_broadcast_series_updated_no_ws_service(self, anime_service):
"""Should return silently if no websocket service."""
anime_service._websocket_service = None
await anime_service._broadcast_series_updated("aot") # no error
class TestListSeriesWithFilters:
"""Test list_series_with_filters with database enrichment."""
@pytest.mark.asyncio
async def test_returns_enriched_list(
self, anime_service, mock_series_app
):
"""Should merge SeriesApp data with DB metadata."""
mock_serie = MagicMock()
mock_serie.key = "aot"
mock_serie.name = "Attack on Titan"
mock_serie.site = "aniworld.to"
mock_serie.folder = "Attack on Titan (2013)"
mock_serie.episodeDict = {1: [2, 3]}
mock_list = MagicMock()
mock_list.GetList.return_value = [mock_serie]
mock_series_app.list = mock_list
mock_db_series = MagicMock()
mock_db_series.folder = "Attack on Titan (2013)"
mock_db_series.has_nfo = True
mock_db_series.nfo_created_at = None
mock_db_series.nfo_updated_at = None
mock_db_series.tmdb_id = 1234
mock_db_series.tvdb_id = None
mock_db_series.id = 1
mock_session = AsyncMock()
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService"
) as MockASS:
MockASS.get_all = AsyncMock(return_value=[mock_db_series])
result = await anime_service.list_series_with_filters()
assert len(result) == 1
assert result[0]["key"] == "aot"
assert result[0]["has_nfo"] is True
assert result[0]["tmdb_id"] == 1234
@pytest.mark.asyncio
async def test_empty_series_returns_empty(
self, anime_service, mock_series_app
):
"""Should return [] when SeriesApp has no series."""
mock_list = MagicMock()
mock_list.GetList.return_value = []
mock_series_app.list = mock_list
result = await anime_service.list_series_with_filters()
assert result == []
@pytest.mark.asyncio
async def test_no_list_attribute_returns_empty(
self, anime_service, mock_series_app
):
"""Should return [] when SeriesApp has no list attribute."""
del mock_series_app.list
result = await anime_service.list_series_with_filters()
assert result == []
@pytest.mark.asyncio
async def test_db_error_raises_anime_service_error(
self, anime_service, mock_series_app
):
"""DB failure should raise AnimeServiceError."""
mock_serie = MagicMock()
mock_serie.key = "aot"
mock_serie.name = "AOT"
mock_serie.site = "x"
mock_serie.folder = "AOT"
mock_serie.episodeDict = {}
mock_list = MagicMock()
mock_list.GetList.return_value = [mock_serie]
mock_series_app.list = mock_list
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(
side_effect=RuntimeError("DB down")
)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
):
with pytest.raises(AnimeServiceError):
await anime_service.list_series_with_filters()
class TestSaveAndLoadDB:
"""Test DB persistence helpers."""
@pytest.mark.asyncio
async def test_save_scan_results_creates_new(
self, anime_service
):
"""New series should be created in DB."""
mock_serie = MagicMock()
mock_serie.key = "naruto"
mock_serie.name = "Naruto"
mock_serie.site = "aniworld.to"
mock_serie.folder = "Naruto"
mock_serie.year = 2002
mock_serie.episodeDict = {1: [1, 2]}
mock_session = AsyncMock()
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=None,
), patch(
"src.server.database.service.AnimeSeriesService.create",
new_callable=AsyncMock,
return_value=MagicMock(id=1),
) as mock_create, patch(
"src.server.database.service.EpisodeService.create",
new_callable=AsyncMock,
) as mock_ep_create:
count = await anime_service._save_scan_results_to_db(
[mock_serie]
)
assert count == 1
mock_create.assert_called_once()
assert mock_ep_create.call_count == 2
@pytest.mark.asyncio
async def test_save_scan_results_updates_existing(
self, anime_service
):
"""Existing series should be updated in DB."""
mock_serie = MagicMock()
mock_serie.key = "naruto"
mock_serie.name = "Naruto"
mock_serie.site = "aniworld.to"
mock_serie.folder = "Naruto"
mock_serie.episodeDict = {1: [3]}
existing = MagicMock()
existing.id = 1
existing.folder = "Naruto"
mock_session = AsyncMock()
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=existing,
), patch.object(
anime_service,
"_update_series_in_db",
new_callable=AsyncMock,
) as mock_update:
count = await anime_service._save_scan_results_to_db(
[mock_serie]
)
assert count == 1
mock_update.assert_called_once()
@pytest.mark.asyncio
async def test_load_series_from_db(
self, anime_service, mock_series_app
):
"""Should populate SeriesApp from DB records."""
mock_ep = MagicMock()
mock_ep.season = 1
mock_ep.episode_number = 5
mock_db_series = MagicMock()
mock_db_series.key = "naruto"
mock_db_series.name = "Naruto"
mock_db_series.site = "aniworld.to"
mock_db_series.folder = "Naruto"
mock_db_series.episodes = [mock_ep]
mock_session = AsyncMock()
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_all",
new_callable=AsyncMock,
return_value=[mock_db_series],
):
await anime_service._load_series_from_db()
mock_series_app.load_series_from_list.assert_called_once()
loaded = mock_series_app.load_series_from_list.call_args[0][0]
assert len(loaded) == 1
assert loaded[0].key == "naruto"
@pytest.mark.asyncio
async def test_sync_episodes_to_db(
self, anime_service, mock_series_app
):
"""Should sync missing episodes from memory to DB."""
mock_serie = MagicMock()
mock_serie.episodeDict = {1: [4, 5]}
mock_list = MagicMock()
mock_list.keyDict = {"aot": mock_serie}
mock_series_app.list = mock_list
mock_db_series = MagicMock()
mock_db_series.id = 10
mock_session = AsyncMock()
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
anime_service._websocket_service = MagicMock()
anime_service._websocket_service.broadcast = AsyncMock()
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_db_series,
), patch(
"src.server.database.service.EpisodeService.get_by_series",
new_callable=AsyncMock,
return_value=[],
), patch(
"src.server.database.service.EpisodeService.create",
new_callable=AsyncMock,
) as mock_ep_create:
count = await anime_service.sync_episodes_to_db("aot")
assert count == 2
assert mock_ep_create.call_count == 2
@pytest.mark.asyncio
async def test_sync_episodes_no_list_returns_zero(
self, anime_service, mock_series_app
):
"""No series list should return 0."""
del mock_series_app.list
count = await anime_service.sync_episodes_to_db("aot")
assert count == 0
class TestAddSeriesToDB:
"""Test add_series_to_db method."""
@pytest.mark.asyncio
async def test_creates_new_series(self, anime_service):
"""New series should be created in DB."""
mock_serie = MagicMock()
mock_serie.key = "x"
mock_serie.name = "X"
mock_serie.site = "y"
mock_serie.folder = "X"
mock_serie.year = 2020
mock_serie.episodeDict = {1: [1]}
mock_db = AsyncMock()
mock_created = MagicMock(id=99)
with patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=None,
), patch(
"src.server.database.service.AnimeSeriesService.create",
new_callable=AsyncMock,
return_value=mock_created,
), patch(
"src.server.database.service.EpisodeService.create",
new_callable=AsyncMock,
):
result = await anime_service.add_series_to_db(mock_serie, mock_db)
assert result is mock_created
@pytest.mark.asyncio
async def test_existing_returns_none(self, anime_service):
"""Already-existing series should return None."""
mock_serie = MagicMock()
mock_serie.key = "x"
mock_serie.name = "X"
mock_db = AsyncMock()
with patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=MagicMock(),
):
result = await anime_service.add_series_to_db(mock_serie, mock_db)
assert result is None
class TestContainsInDB:
"""Test contains_in_db method."""
@pytest.mark.asyncio
async def test_exists(self, anime_service):
"""Should return True when series exists."""
mock_db = AsyncMock()
with patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=MagicMock(),
):
assert await anime_service.contains_in_db("aot", mock_db) is True
@pytest.mark.asyncio
async def test_not_exists(self, anime_service):
"""Should return False when series missing."""
mock_db = AsyncMock()
with patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=None,
):
assert await anime_service.contains_in_db("x", mock_db) is False
class TestUpdateNFOStatusWithoutSession:
"""Test update_nfo_status when no db session is passed (self-managed)."""
@pytest.mark.asyncio
async def test_update_creates_session_and_commits(self, anime_service):
"""Should open its own session and commit."""
mock_series = MagicMock()
mock_series.id = 1
mock_series.has_nfo = False
mock_series.nfo_created_at = None
mock_series.nfo_updated_at = None
mock_session = AsyncMock()
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=mock_series,
), patch(
"src.server.database.service.AnimeSeriesService.update",
new_callable=AsyncMock,
):
await anime_service.update_nfo_status(
key="test", has_nfo=True, tmdb_id=42
)
# commit called by update path
mock_session.commit.assert_called_once()
@pytest.mark.asyncio
async def test_update_not_found_skips(self, anime_service):
"""Should return without error if series not in DB."""
mock_session = AsyncMock()
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=None,
):
await anime_service.update_nfo_status(key="missing", has_nfo=True)
mock_session.commit.assert_not_called()
class TestGetSeriesWithoutNFOSelfManaged:
"""Test get_series_without_nfo when db=None (self-managed session)."""
@pytest.mark.asyncio
async def test_returns_list(self, anime_service):
"""Should return formatted dicts."""
mock_s = MagicMock()
mock_s.key = "test"
mock_s.name = "Test"
mock_s.folder = "Test"
mock_s.tmdb_id = 1
mock_s.tvdb_id = 2
mock_session = AsyncMock()
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService"
".get_series_without_nfo",
new_callable=AsyncMock,
return_value=[mock_s],
):
result = await anime_service.get_series_without_nfo()
assert len(result) == 1
assert result[0]["has_nfo"] is False
class TestGetNFOStatisticsSelfManaged:
"""Test get_nfo_statistics when db=None (self-managed session)."""
@pytest.mark.asyncio
async def test_returns_stats(self, anime_service):
"""Should compute statistics correctly."""
mock_session = AsyncMock()
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
with patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.count_all",
new_callable=AsyncMock,
return_value=50,
), patch(
"src.server.database.service.AnimeSeriesService.count_with_nfo",
new_callable=AsyncMock,
return_value=30,
), patch(
"src.server.database.service.AnimeSeriesService"
".count_with_tmdb_id",
new_callable=AsyncMock,
return_value=40,
), patch(
"src.server.database.service.AnimeSeriesService"
".count_with_tvdb_id",
new_callable=AsyncMock,
return_value=20,
):
result = await anime_service.get_nfo_statistics()
assert result["total"] == 50
assert result["without_nfo"] == 20
assert result["with_tmdb_id"] == 40
class TestSyncSeriesFromDataFiles:
"""Test module-level sync_series_from_data_files function."""
@pytest.mark.asyncio
async def test_sync_adds_new_series(self, tmp_path):
"""Should create series for data files not in DB."""
mock_serie = MagicMock()
mock_serie.key = "new-series"
mock_serie.name = "New Series"
mock_serie.site = "aniworld.to"
mock_serie.folder = "New Series"
mock_serie.episodeDict = {1: [1]}
mock_session = AsyncMock()
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
with patch(
"src.server.services.anime_service.SeriesApp"
) as MockApp, patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=None,
), patch(
"src.server.database.service.AnimeSeriesService.create",
new_callable=AsyncMock,
return_value=MagicMock(id=1),
) as mock_create, patch(
"src.server.database.service.EpisodeService.create",
new_callable=AsyncMock,
):
mock_app_instance = MagicMock()
mock_app_instance.get_all_series_from_data_files.return_value = [
mock_serie
]
MockApp.return_value = mock_app_instance
count = await sync_series_from_data_files(str(tmp_path))
assert count == 1
mock_create.assert_called_once()
@pytest.mark.asyncio
async def test_sync_skips_existing(self, tmp_path):
"""Already-existing series should be skipped."""
mock_serie = MagicMock()
mock_serie.key = "exists"
mock_serie.name = "Exists"
mock_serie.site = "x"
mock_serie.folder = "Exists"
mock_serie.episodeDict = {}
mock_session = AsyncMock()
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
with patch(
"src.server.services.anime_service.SeriesApp"
) as MockApp, patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=MagicMock(),
), patch(
"src.server.database.service.AnimeSeriesService.create",
new_callable=AsyncMock,
) as mock_create:
mock_app_instance = MagicMock()
mock_app_instance.get_all_series_from_data_files.return_value = [
mock_serie
]
MockApp.return_value = mock_app_instance
count = await sync_series_from_data_files(str(tmp_path))
assert count == 0
mock_create.assert_not_called()
@pytest.mark.asyncio
async def test_sync_no_data_files(self, tmp_path):
"""Empty directory should return 0."""
with patch(
"src.server.services.anime_service.SeriesApp"
) as MockApp:
mock_app_instance = MagicMock()
mock_app_instance.get_all_series_from_data_files.return_value = []
MockApp.return_value = mock_app_instance
count = await sync_series_from_data_files(str(tmp_path))
assert count == 0
@pytest.mark.asyncio
async def test_sync_handles_empty_name(self, tmp_path):
"""Series with empty name should use folder as fallback."""
mock_serie = MagicMock()
mock_serie.key = "no-name"
mock_serie.name = ""
mock_serie.site = "x"
mock_serie.folder = "FallbackFolder"
mock_serie.episodeDict = {}
mock_session = AsyncMock()
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
with patch(
"src.server.services.anime_service.SeriesApp"
) as MockApp, patch(
"src.server.database.connection.get_db_session",
return_value=mock_ctx,
), patch(
"src.server.database.service.AnimeSeriesService.get_by_key",
new_callable=AsyncMock,
return_value=None,
), patch(
"src.server.database.service.AnimeSeriesService.create",
new_callable=AsyncMock,
return_value=MagicMock(id=1),
) as mock_create:
mock_app_instance = MagicMock()
mock_app_instance.get_all_series_from_data_files.return_value = [
mock_serie
]
MockApp.return_value = mock_app_instance
count = await sync_series_from_data_files(str(tmp_path))
assert count == 1
# The name should have been set to folder
assert mock_serie.name == "FallbackFolder"