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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user