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.
242 lines
9.1 KiB
Python
242 lines
9.1 KiB
Python
"""Tests for SerieScanner DB persistence functionality."""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from src.server.database.models import AnimeSeries
|
|
from src.server.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 AnimeSeries mock for testing."""
|
|
anime = MagicMock(spec=AnimeSeries)
|
|
anime.key = "attack-on-titan"
|
|
anime.name = "Attack on Titan"
|
|
anime.site = "aniworld.to"
|
|
anime.folder = "Attack on Titan (2013)"
|
|
anime.year = 2013
|
|
anime.episodeDict = {1: [1, 2, 3], 2: [1, 2]}
|
|
return anime
|
|
|
|
|
|
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_deletes_downloaded_episodes_when_no_longer_missing(self):
|
|
"""Downloaded episodes are deleted once the rescan confirms the
|
|
file is on disk and they are no longer missing. The DB stays
|
|
in sync with the filesystem: a row that is not in the scanner's
|
|
missing set has no further purpose and is removed.
|
|
"""
|
|
mock_session = AsyncMock()
|
|
|
|
# S01E1 was downloaded (file exists) and the scanner confirms
|
|
# it is no longer missing; S01E2 was previously marked as
|
|
# downloaded and is also no longer missing. Both should be
|
|
# deleted — there is no notion of "preserving download history"
|
|
# in the DB: the rescan's filesystem view is the source of
|
|
# truth, and a row whose episode is no longer missing is dead.
|
|
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
|
|
)
|
|
|
|
# Both downloaded rows should be deleted; the scanner
|
|
# found the files on disk and they're not in the
|
|
# missing set.
|
|
assert mock_delete.call_count == 2
|
|
deleted_calls = [
|
|
(c.args[1], c.args[2], c.args[3])
|
|
for c in mock_delete.call_args_list
|
|
]
|
|
assert (1, 1, 1) in deleted_calls
|
|
assert (1, 1, 2) in deleted_calls
|
|
|
|
@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_session
|
|
):
|
|
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()
|