feat(scanner): replace file writes with DB persistence for series
- 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>
This commit is contained in:
@@ -75,12 +75,12 @@ class TestSerieScannerInitialization:
|
||||
class TestSerieScannerScan:
|
||||
"""Test file-based scan operations."""
|
||||
|
||||
def test_file_based_scan_works(
|
||||
def test_scan_persists_to_db(
|
||||
self, temp_directory, mock_loader, sample_serie
|
||||
):
|
||||
"""Test file-based scan works properly."""
|
||||
"""Test scan persists series to database."""
|
||||
scanner = SerieScanner(temp_directory, mock_loader)
|
||||
|
||||
|
||||
with patch.object(scanner, 'get_total_to_scan', return_value=1):
|
||||
with patch.object(
|
||||
scanner,
|
||||
@@ -100,12 +100,15 @@ class TestSerieScannerScan:
|
||||
return_value=({1: [2, 3]}, "aniworld.to")
|
||||
):
|
||||
with patch.object(
|
||||
sample_serie, 'save_to_file'
|
||||
) as mock_save:
|
||||
scanner, '_persist_serie_to_db'
|
||||
) as mock_persist:
|
||||
scanner.scan()
|
||||
|
||||
# Verify file was saved
|
||||
mock_save.assert_called_once()
|
||||
|
||||
# Verify DB persistence was called
|
||||
mock_persist.assert_called_once()
|
||||
# Check the serie passed matches
|
||||
call_args = mock_persist.call_args
|
||||
assert call_args[0][0].key == "attack-on-titan"
|
||||
|
||||
def test_keydict_populated_after_scan(
|
||||
self, temp_directory, mock_loader, sample_serie
|
||||
|
||||
225
tests/unit/test_serie_scanner_db_writes.py
Normal file
225
tests/unit/test_serie_scanner_db_writes.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user