- 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
1444 lines
51 KiB
Python
1444 lines
51 KiB
Python
"""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"
|