Files
Aniworld/tests/unit/test_serie_scanner_db_writes.py
Lukas 1d121b427d fix(rescan): delete downloaded episode rows when no longer missing
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.
2026-09-15 20:34:12 +02:00

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()