A finished download marks the Episode row with is_downloaded=True and
populates file_path (commit 0ba2587). The intent was to preserve
download history, but the row stayed in the DB forever because two
sibling sync methods refused to delete it:
- AnimeService._update_series_in_db: `downloaded_set` guard skipped
deletion if the row was marked downloaded.
- SerieScanner._sync_episodes_to_db: `if ep.is_downloaded: continue`
kept downloaded rows.
The user-visible bug: the missing-list UI is correct (it filters by
is_downloaded and the broadcast rebuilds from the DB), but a finished
download left a stale entry in the DB that the user could see when
querying the database directly. Worse, this stale row accumulated
indefinitely across rescans.
Fix: drop the is_downloaded preservation guards. Once the scanner
confirms the file is on disk and the episode is no longer in the
missing set, the Episode row has no further purpose and is deleted to
keep the DB in sync with the filesystem. The UI derives "missing"
from row presence, so deleting the row is the correct way to make the
episode stop appearing as missing in *all* views (UI and DB queries).
Tests:
- test_update_series_deletes_downloaded_episodes_when_no_longer_missing
(RED): regression for _update_series_in_db.
- test_update_series_keeps_still_missing_episodes: sanity sibling
to ensure the fix does not over-reach and delete still-missing
episodes.
- test_deletes_downloaded_episodes_when_no_longer_missing
(RED): regression for SerieScanner._sync_episodes_to_db.
- Replaced test_preserves_downloaded_episodes (which asserted the
old buggy behavior) with the deletion-asserting variant.
1416 lines
50 KiB
Python
1416 lines
50 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
|
||
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_update_series_deletes_downloaded_episodes_when_no_longer_missing(
|
||
self, anime_service
|
||
):
|
||
"""Regression: a finished download marks the Episode row with
|
||
is_downloaded=True. When a later rescan confirms the file is on
|
||
disk (i.e. the episode is no longer in the missing set), the
|
||
DB row should be deleted so it stops appearing in queries.
|
||
|
||
Bug shape: previously the ``downloaded_set`` guard in
|
||
``_update_series_in_db`` preserved the row forever, so the DB
|
||
kept stale ``is_downloaded=True`` entries that the user could
|
||
see in the database but not in any UI.
|
||
"""
|
||
mock_serie = MagicMock()
|
||
mock_serie.key = "the-100-girlfriends"
|
||
mock_serie.name = "The 100 Girlfriends"
|
||
mock_serie.site = "aniworld.to"
|
||
mock_serie.folder = "The 100 Girlfriends (2023)"
|
||
# Scanner reports no missing episodes for this series —
|
||
# every file is on disk.
|
||
mock_serie.episodeDict = {}
|
||
|
||
existing = MagicMock()
|
||
existing.id = 1
|
||
existing.folder = "The 100 Girlfriends (2023)"
|
||
|
||
# DB currently has one row for S03E08 marked as downloaded
|
||
# (the result of an earlier successful download). The
|
||
# scanner confirms the file is on disk, so the episode is
|
||
# no longer missing.
|
||
existing_eps = [
|
||
MagicMock(
|
||
id=10, season=3, episode_number=8, is_downloaded=True,
|
||
),
|
||
]
|
||
|
||
mock_session = AsyncMock()
|
||
|
||
with patch(
|
||
"src.server.database.service.EpisodeService.get_by_series",
|
||
new_callable=AsyncMock,
|
||
return_value=existing_eps,
|
||
), patch(
|
||
"src.server.database.service.EpisodeService.delete",
|
||
new_callable=AsyncMock,
|
||
) as mock_delete:
|
||
await anime_service._update_series_in_db(
|
||
mock_serie, existing, mock_session
|
||
)
|
||
|
||
# The downloaded episode (S03E08) MUST be deleted — the file
|
||
# is on disk and the scanner does not report it as missing.
|
||
# EpisodeService.delete is (db, episode_id) — episode_id is
|
||
# the second positional arg.
|
||
deleted_ids = [
|
||
call.args[1] for call in mock_delete.call_args_list
|
||
]
|
||
assert deleted_ids == [10], (
|
||
f"Expected S03E08 (id=10) to be the only deleted row; "
|
||
f"got delete calls for {deleted_ids}"
|
||
)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_update_series_keeps_still_missing_episodes(
|
||
self, anime_service
|
||
):
|
||
"""A still-missing episode (is_downloaded=False, in scanner's
|
||
missing set) must NOT be deleted by _update_series_in_db.
|
||
Sanity-check sibling to the downloaded-episode regression
|
||
test, ensuring the fix does not over-reach.
|
||
"""
|
||
mock_serie = MagicMock()
|
||
mock_serie.key = "naruto"
|
||
mock_serie.name = "Naruto"
|
||
mock_serie.site = "aniworld.to"
|
||
mock_serie.folder = "Naruto"
|
||
# Scanner reports S01E07 still missing.
|
||
mock_serie.episodeDict = {1: [7]}
|
||
|
||
existing = MagicMock()
|
||
existing.id = 1
|
||
existing.folder = "Naruto"
|
||
|
||
existing_eps = [
|
||
MagicMock(
|
||
id=20, season=1, episode_number=7, is_downloaded=False,
|
||
),
|
||
]
|
||
|
||
mock_session = AsyncMock()
|
||
|
||
with patch(
|
||
"src.server.database.service.EpisodeService.get_by_series",
|
||
new_callable=AsyncMock,
|
||
return_value=existing_eps,
|
||
), patch(
|
||
"src.server.database.service.EpisodeService.delete",
|
||
new_callable=AsyncMock,
|
||
) as mock_delete, patch(
|
||
"src.server.database.service.EpisodeService.create",
|
||
new_callable=AsyncMock,
|
||
):
|
||
await anime_service._update_series_in_db(
|
||
mock_serie, existing, mock_session
|
||
)
|
||
|
||
# S01E07 is still missing per the scanner — it must NOT be
|
||
# deleted. (No new episode needs to be created either — it
|
||
# is already in the DB.)
|
||
assert mock_delete.call_count == 0, (
|
||
f"Still-missing episode S01E07 must not be deleted; "
|
||
f"got delete calls: {mock_delete.call_args_list}"
|
||
)
|
||
|
||
@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
|
||
|
||
|