- SerieScanner.scan() now calls _persist_serie_to_db() instead of serie.save_to_file() - Added _sync_episodes_to_db() helper to handle episode CRUD during sync - EpisodeService gains delete_by_series() for targeted episode deletion - SerieList gains add_to_db() async method for DB-based series addition - test_serie_scanner_db_writes.py covers create/update/preserve/sync scenarios - DATABASE.md updated with Series Persistence Flow section Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
226 lines
8.2 KiB
Python
226 lines
8.2 KiB
Python
"""Tests for SerieScanner DB persistence functionality."""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from src.core.entities.series import Serie
|
|
from src.core.SerieScanner import SerieScanner
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_session_factory():
|
|
"""Create a mock async session factory."""
|
|
mock_session = AsyncMock()
|
|
mock_session_factory = MagicMock(return_value=mock_session)
|
|
return mock_session_factory, mock_session
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_serie():
|
|
"""Create a sample Serie for testing."""
|
|
return Serie(
|
|
key="attack-on-titan",
|
|
name="Attack on Titan",
|
|
site="aniworld.to",
|
|
folder="Attack on Titan (2013)",
|
|
episodeDict={1: [1, 2, 3], 2: [1, 2]},
|
|
year=2013
|
|
)
|
|
|
|
|
|
class TestPersistSerieToDb:
|
|
"""Test _persist_serie_to_db method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_creates_new_series_when_not_exists(
|
|
self, mock_session_factory, sample_serie
|
|
):
|
|
"""Verify new series is created in DB."""
|
|
mock_factory, mock_session = mock_session_factory
|
|
|
|
with patch(
|
|
"src.server.database.connection.get_async_session_factory",
|
|
return_value=mock_factory
|
|
):
|
|
with patch(
|
|
"src.server.database.service.AnimeSeriesService.get_by_key",
|
|
return_value=None
|
|
):
|
|
mock_anime_series = MagicMock()
|
|
mock_anime_series.id = 1
|
|
with patch(
|
|
"src.server.database.service.AnimeSeriesService.create",
|
|
return_value=mock_anime_series
|
|
):
|
|
scanner = SerieScanner("/tmp", MagicMock())
|
|
await scanner._persist_serie_to_db(sample_serie)
|
|
|
|
from src.server.database.service import AnimeSeriesService
|
|
AnimeSeriesService.create.assert_called_once()
|
|
call_kwargs = AnimeSeriesService.create.call_args[1]
|
|
assert call_kwargs["key"] == "attack-on-titan"
|
|
assert call_kwargs["name"] == "Attack on Titan"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_updates_existing_series(self, mock_session_factory, sample_serie):
|
|
"""Verify existing series is updated."""
|
|
mock_factory, mock_session = mock_session_factory
|
|
|
|
mock_existing = MagicMock()
|
|
mock_existing.id = 42
|
|
mock_existing.key = "attack-on-titan"
|
|
|
|
scanner = SerieScanner("/tmp", MagicMock())
|
|
|
|
with patch(
|
|
"src.server.database.connection.get_async_session_factory",
|
|
return_value=mock_factory
|
|
):
|
|
with patch(
|
|
"src.server.database.service.AnimeSeriesService.get_by_key",
|
|
return_value=mock_existing
|
|
):
|
|
with patch(
|
|
"src.server.database.service.AnimeSeriesService.update",
|
|
new_callable=AsyncMock
|
|
) as mock_update:
|
|
with patch.object(
|
|
scanner,
|
|
"_sync_episodes_to_db",
|
|
new_callable=AsyncMock
|
|
):
|
|
await scanner._persist_serie_to_db(sample_serie)
|
|
|
|
mock_update.assert_called_once()
|
|
call_args = mock_update.call_args[0]
|
|
assert call_args[1] == 42 # series_id
|
|
|
|
|
|
class TestSyncEpisodesToDb:
|
|
"""Test _sync_episodes_to_db method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preserves_downloaded_episodes(self):
|
|
"""Verify downloaded episodes are not removed even when no longer missing."""
|
|
mock_session = AsyncMock()
|
|
|
|
# S01E1 was downloaded (file exists), S01E2 was missing but file now exists
|
|
# Both are no longer in episode_dict
|
|
existing_eps = [
|
|
MagicMock(id=1, season=1, episode_number=1, is_downloaded=True),
|
|
MagicMock(id=2, season=1, episode_number=2, is_downloaded=True),
|
|
]
|
|
|
|
with patch(
|
|
"src.server.database.service.EpisodeService.get_by_series",
|
|
return_value=existing_eps
|
|
):
|
|
with patch(
|
|
"src.server.database.service.EpisodeService.delete_by_series",
|
|
new_callable=AsyncMock
|
|
) as mock_delete:
|
|
scanner = SerieScanner("/tmp", MagicMock())
|
|
# Neither S01E1 nor S01E2 are missing now
|
|
await scanner._sync_episodes_to_db(
|
|
mock_session, 1, {} # No episodes missing
|
|
)
|
|
|
|
# Neither should be deleted since both are downloaded
|
|
mock_delete.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_removes_missing_episodes_when_no_longer_missing(self):
|
|
"""Verify episodes removed from DB if file now exists."""
|
|
mock_session = AsyncMock()
|
|
|
|
existing_eps = [
|
|
MagicMock(id=1, season=1, episode_number=1, is_downloaded=False),
|
|
MagicMock(id=2, season=1, episode_number=2, is_downloaded=False),
|
|
]
|
|
|
|
with patch(
|
|
"src.server.database.service.EpisodeService.get_by_series",
|
|
return_value=existing_eps
|
|
):
|
|
with patch(
|
|
"src.server.database.service.EpisodeService.delete_by_series",
|
|
new_callable=AsyncMock
|
|
) as mock_delete:
|
|
with patch(
|
|
"src.server.database.service.EpisodeService.create",
|
|
new_callable=AsyncMock
|
|
):
|
|
scanner = SerieScanner("/tmp", MagicMock())
|
|
await scanner._sync_episodes_to_db(
|
|
mock_session, 1, {1: [1]} # Only S01E01 now missing
|
|
)
|
|
|
|
# S01E02 should be deleted since no longer missing
|
|
mock_delete.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_adds_new_missing_episodes(self):
|
|
"""Verify new missing episodes are added."""
|
|
mock_session = AsyncMock()
|
|
|
|
existing_eps = [
|
|
MagicMock(id=1, season=1, episode_number=1, is_downloaded=False),
|
|
]
|
|
|
|
with patch(
|
|
"src.server.database.service.EpisodeService.get_by_series",
|
|
return_value=existing_eps
|
|
):
|
|
with patch(
|
|
"src.server.database.service.EpisodeService.create",
|
|
new_callable=AsyncMock
|
|
) as mock_create:
|
|
scanner = SerieScanner("/tmp", MagicMock())
|
|
await scanner._sync_episodes_to_db(
|
|
mock_session, 1, {1: [1, 2, 3]} # S01E01, S01E02, S01E03
|
|
)
|
|
|
|
# S01E02 and S01E03 should be created
|
|
assert mock_create.call_count == 2
|
|
|
|
|
|
class TestPersistSerieToDbErrorHandling:
|
|
"""Test error handling in _persist_serie_to_db."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_logs_error_when_db_unavailable(self, sample_serie):
|
|
"""Verify DB unavailability is logged but doesn't crash."""
|
|
with patch(
|
|
"src.server.database.connection.get_async_session_factory",
|
|
side_effect=RuntimeError("DB not initialized")
|
|
):
|
|
scanner = SerieScanner("/tmp", MagicMock())
|
|
# Should not raise
|
|
await scanner._persist_serie_to_db(sample_serie)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rollback_on_failure(self, mock_session_factory, sample_serie):
|
|
"""Verify rollback on DB failure."""
|
|
mock_factory, mock_session = mock_session_factory
|
|
|
|
mock_existing = MagicMock()
|
|
mock_existing.id = 1
|
|
|
|
with patch(
|
|
"src.server.database.connection.get_async_session_factory",
|
|
return_value=mock_factory
|
|
):
|
|
with patch(
|
|
"src.server.database.service.AnimeSeriesService.get_by_key",
|
|
return_value=mock_existing
|
|
):
|
|
with patch(
|
|
"src.server.database.service.AnimeSeriesService.update",
|
|
side_effect=Exception("DB error")
|
|
):
|
|
scanner = SerieScanner("/tmp", MagicMock())
|
|
# Should not raise but should rollback
|
|
await scanner._persist_serie_to_db(sample_serie)
|
|
mock_session.rollback.assert_called_once()
|