Expand test coverage: ~188 new tests across 6 critical files

- Fix failing test_authenticated_request_succeeds (dependency override)
- Expand test_anime_service.py (+35 tests: status events, DB, broadcasts)
- Create test_queue_repository.py (27 tests: CRUD, model conversion)
- Expand test_enhanced_provider.py (+24 tests: fetch, download, redirect)
- Expand test_serie_scanner.py (+25 tests: events, year extract, mp4 scan)
- Create test_database_connection.py (38 tests: sessions, transactions)
- Expand test_anime_endpoints.py (+39 tests: status, search, loading)
- Clean up docs/instructions.md TODO list
This commit is contained in:
2026-02-15 17:44:27 +01:00
parent d7ab689fe1
commit e84a220f55
8 changed files with 3254 additions and 115 deletions

View File

@@ -1,16 +1,23 @@
"""Unit tests for AnimeService.
Tests cover service initialization, async operations, caching,
error handling, and progress reporting integration.
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.anime_service import (
AnimeService,
AnimeServiceError,
sync_series_from_data_files,
)
from src.server.services.progress_service import ProgressService
@@ -472,3 +479,965 @@ class TestFactoryFunction:
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"