Fix stale data file updates on download completion
When episodes are downloaded successfully, the in-memory Serie.episodeDict is updated, but the deprecated data file was not being synced. This caused UI to show episodes as missing when already downloaded. Changes: - Update data file in _remove_episode_from_memory when download completes - DB is authoritative; data file is optional backup (deprecated) - Gracefully skip update if data file doesn't exist New integration tests for episode download sync: - Verify episode removed from missing list after download - Verify in-memory cache updated after download - Verify data file updated after download (when it exists) - Verify downloads work without data file
This commit is contained in:
333
tests/integration/test_episode_download_sync.py
Normal file
333
tests/integration/test_episode_download_sync.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""Integration tests for episode download sync with data file updates.
|
||||
|
||||
Tests verify that when episodes are downloaded successfully:
|
||||
- In-memory Serie.episodeDict is updated
|
||||
- Deprecated data file is updated (if it exists)
|
||||
- Missing episode list reflects the change immediately
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
from src.core.SeriesApp import SeriesApp
|
||||
from src.server.models.download import DownloadItem, DownloadPriority, DownloadStatus
|
||||
from src.server.services.download_service import DownloadService
|
||||
|
||||
|
||||
class TestEpisodeRemovedFromMissingListAfterDownload:
|
||||
"""Verify episode no longer appears in missing list after download completes."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self):
|
||||
"""Create temp directory for test data files."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yield Path(tmp)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anime_service(self, temp_dir):
|
||||
"""Create mock anime service with app."""
|
||||
anime_service = MagicMock()
|
||||
anime_service._directory = str(temp_dir)
|
||||
|
||||
# Create mock app withSerie with missing episodes
|
||||
serie = Serie(
|
||||
key="test-series",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="Test Series",
|
||||
episodeDict={1: [1, 2, 3]},
|
||||
)
|
||||
mock_app = MagicMock()
|
||||
mock_app.list.keyDict = {"test-series": serie}
|
||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
||||
mock_app.series_list = [serie]
|
||||
anime_service._app = mock_app
|
||||
anime_service._cached_list_missing = MagicMock()
|
||||
anime_service._broadcast_series_updated = AsyncMock()
|
||||
|
||||
return anime_service
|
||||
|
||||
@pytest.fixture
|
||||
def mock_download_service(self, mock_anime_service):
|
||||
"""Create download service with mocked dependencies."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
service = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
queue_repository=MagicMock(),
|
||||
max_retries=3,
|
||||
)
|
||||
service._directory = tmp
|
||||
yield service
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_episode_removed_from_missing_list_after_download(
|
||||
self, mock_download_service, mock_anime_service
|
||||
):
|
||||
"""Verify episode no longer appears in missing list after download completes."""
|
||||
serie = mock_anime_service._app.list.keyDict["test-series"]
|
||||
|
||||
# Verify episode starts in missing list
|
||||
assert 2 in serie.episodeDict[1], "Episode should start in missing list"
|
||||
|
||||
# Simulate download completion by calling _remove_episode_from_memory
|
||||
mock_download_service._remove_episode_from_memory("test-series", 1, 2)
|
||||
|
||||
# Episode should be removed from episodeDict
|
||||
assert 2 not in serie.episodeDict[1], "Episode should be removed from missing list"
|
||||
assert serie.episodeDict[1] == [1, 3]
|
||||
|
||||
# series_list should be refreshed
|
||||
mock_anime_service._app.list.GetMissingEpisode.assert_called()
|
||||
|
||||
|
||||
class TestDownloadUpdatesInMemoryCache:
|
||||
"""Verify in-memory Serie.episodeDict is updated after download."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anime_service(self):
|
||||
"""Create mock anime service with app."""
|
||||
anime_service = MagicMock()
|
||||
anime_service._directory = "/tmp/test"
|
||||
|
||||
# Create mock app with series having multiple seasons and episodes
|
||||
serie = Serie(
|
||||
key="multi-season-series",
|
||||
name="Multi Season Series",
|
||||
site="https://example.com",
|
||||
folder="Multi Season Series",
|
||||
episodeDict={
|
||||
1: [1, 2, 3, 4, 5],
|
||||
2: [1, 2, 3],
|
||||
},
|
||||
)
|
||||
mock_app = MagicMock()
|
||||
mock_app.list.keyDict = {"multi-season-series": serie}
|
||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
||||
mock_app.series_list = [serie]
|
||||
anime_service._app = mock_app
|
||||
anime_service._cached_list_missing = MagicMock()
|
||||
anime_service._broadcast_series_updated = AsyncMock()
|
||||
|
||||
return anime_service
|
||||
|
||||
@pytest.fixture
|
||||
def mock_download_service(self, mock_anime_service):
|
||||
"""Create download service with mocked dependencies."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
service = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
queue_repository=MagicMock(),
|
||||
max_retries=3,
|
||||
)
|
||||
service._directory = tmp
|
||||
yield service
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_updates_in_memory_cache(
|
||||
self, mock_download_service, mock_anime_service
|
||||
):
|
||||
"""Verify in-memory Serie.episodeDict is updated after download."""
|
||||
# First reset to known state (remove the defaults first call might have set)
|
||||
serie = mock_anime_service._app.list.keyDict["multi-season-series"]
|
||||
|
||||
# Put back episodes after the fixture setup
|
||||
serie.episodeDict = {1: [1, 2, 3, 4, 5], 2: [1, 2, 3]}
|
||||
|
||||
# Verify preconditions
|
||||
assert 1 in serie.episodeDict[1]
|
||||
assert 3 in serie.episodeDict[2]
|
||||
|
||||
# Simulate downloading multiple episodes
|
||||
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 1)
|
||||
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 3)
|
||||
mock_download_service._remove_episode_from_memory("multi-season-series", 2, 2)
|
||||
|
||||
# Verify episodes removed
|
||||
assert 1 not in serie.episodeDict[1], "Episode 1 of season 1 should be removed"
|
||||
assert 3 not in serie.episodeDict[1], "Episode 3 of season 1 should be removed"
|
||||
assert 2 in serie.episodeDict[1], "Episode 2 of season 1 should remain"
|
||||
assert 3 in serie.episodeDict[2], "Episode 3 of season 2 should remain"
|
||||
assert 2 not in serie.episodeDict[2], "Episode 2 of season 2 should be removed"
|
||||
|
||||
# Verify seasons with no episodes are cleaned up
|
||||
assert 2 in serie.episodeDict, "Season 2 should still exist (has episode 1, 3)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_last_episode_removes_season(
|
||||
self, mock_download_service, mock_anime_service
|
||||
):
|
||||
"""Verify that removing last episode in a season removes the season key."""
|
||||
# Modify the series so season 1 only has episode 2 left
|
||||
serie = mock_anime_service._app.list.keyDict["multi-season-series"]
|
||||
# Reset and set to proper test state
|
||||
serie.episodeDict = {1: [2], 2: [1, 2, 3]} # Season 1 only has episode 2
|
||||
|
||||
# Verify initial state
|
||||
assert 2 in serie.episodeDict[1]
|
||||
assert 2 in serie.episodeDict[2]
|
||||
|
||||
# Remove last episode of season 1 (episode 2)
|
||||
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 2)
|
||||
|
||||
# Season 1 should be completely removed
|
||||
assert 1 not in serie.episodeDict, "Season 1 should be removed"
|
||||
# Season 2 should still exist
|
||||
assert 2 in serie.episodeDict, "Season 2 should still exist"
|
||||
|
||||
|
||||
class TestDataFileUpdatedAfterDownload:
|
||||
"""Verify data file is updated after download (when it exists)."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self):
|
||||
"""Create temp directory for test data files."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yield Path(tmp)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anime_service(self, temp_dir):
|
||||
"""Create mock anime service with app."""
|
||||
anime_service = MagicMock()
|
||||
anime_service._directory = str(temp_dir)
|
||||
|
||||
# Create series folder with data file
|
||||
series_folder = temp_dir / "Test Series"
|
||||
series_folder.mkdir()
|
||||
data_path = series_folder / "data"
|
||||
|
||||
serie = Serie(
|
||||
key="test-series-with-data",
|
||||
name="Test Series",
|
||||
site="https://example.com",
|
||||
folder="Test Series",
|
||||
episodeDict={1: [1, 2, 3]},
|
||||
)
|
||||
|
||||
# Save data file to disk
|
||||
import warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
serie.save_to_file(str(data_path))
|
||||
|
||||
# Update episodeDict to simulate in-progress download state
|
||||
# (episodeDict still has all episodes; will be updated after download)
|
||||
mock_app = MagicMock()
|
||||
mock_app.list.keyDict = {"test-series-with-data": serie}
|
||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
||||
mock_app.series_list = [serie]
|
||||
anime_service._app = mock_app
|
||||
anime_service._cached_list_missing = MagicMock()
|
||||
anime_service._broadcast_series_updated = AsyncMock()
|
||||
|
||||
return anime_service
|
||||
|
||||
@pytest.fixture
|
||||
def mock_download_service(self, mock_anime_service):
|
||||
"""Create download service with mocked dependencies."""
|
||||
service = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
queue_repository=MagicMock(),
|
||||
max_retries=3,
|
||||
)
|
||||
service._directory = str(mock_anime_service._directory)
|
||||
yield service
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_data_file_updated_after_download(
|
||||
self, mock_download_service, mock_anime_service, temp_dir
|
||||
):
|
||||
"""Verify data file is updated after download when data file exists."""
|
||||
serie = mock_anime_service._app.list.keyDict["test-series-with-data"]
|
||||
data_path = temp_dir / "Test Series" / "data"
|
||||
|
||||
# Verify data file exists before test
|
||||
assert data_path.exists(), "Data file should exist before test"
|
||||
|
||||
# Read original data file
|
||||
with open(data_path) as f:
|
||||
original_data = json.load(f)
|
||||
assert 2 in original_data["episodeDict"]["1"], "Episode should be in original data"
|
||||
|
||||
# Simulate download completion
|
||||
mock_download_service._remove_episode_from_memory("test-series-with-data", 1, 2)
|
||||
|
||||
# Read updated data file
|
||||
with open(data_path) as f:
|
||||
updated_data = json.load(f)
|
||||
|
||||
# Verify episode 2 was removed from data file
|
||||
assert 2 not in updated_data["episodeDict"]["1"], "Episode should be removed from data file"
|
||||
assert updated_data["episodeDict"]["1"] == [1, 3]
|
||||
|
||||
|
||||
class TestDataFileNotRequiredForDownload:
|
||||
"""Verify downloads work even when data file doesn't exist."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self):
|
||||
"""Create temp directory without data files."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yield Path(tmp)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anime_service(self, temp_dir):
|
||||
"""Create mock anime service with app but no data file."""
|
||||
anime_service = MagicMock()
|
||||
anime_service._directory = str(temp_dir)
|
||||
|
||||
# Create series with NO data file on disk (only in memory)
|
||||
serie = Serie(
|
||||
key="memory-only-series",
|
||||
name="Memory Only Series",
|
||||
site="https://example.com",
|
||||
folder="Memory Only Series",
|
||||
episodeDict={1: [1, 2, 3]},
|
||||
)
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.list.keyDict = {"memory-only-series": serie}
|
||||
mock_app.list.GetMissingEpisode.return_value = [serie]
|
||||
mock_app.series_list = [serie]
|
||||
anime_service._app = mock_app
|
||||
anime_service._cached_list_missing = MagicMock()
|
||||
anime_service._broadcast_series_updated = AsyncMock()
|
||||
|
||||
return anime_service
|
||||
|
||||
@pytest.fixture
|
||||
def mock_download_service(self, mock_anime_service):
|
||||
"""Create download service with mocked dependencies."""
|
||||
service = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
queue_repository=MagicMock(),
|
||||
max_retries=3,
|
||||
)
|
||||
service._directory = str(mock_anime_service._directory)
|
||||
yield service
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_works_without_data_file(
|
||||
self, mock_download_service, mock_anime_service
|
||||
):
|
||||
"""Verify downloads work even when no data file exists on disk."""
|
||||
serie = mock_anime_service._app.list.keyDict["memory-only-series"]
|
||||
data_path = Path(mock_anime_service._directory) / "Memory Only Series" / "data"
|
||||
|
||||
# Verify no data file exists
|
||||
assert not data_path.exists(), "No data file should exist"
|
||||
|
||||
# Simulate download completion
|
||||
# This should NOT raise even without data file
|
||||
mock_download_service._remove_episode_from_memory("memory-only-series", 1, 2)
|
||||
|
||||
# Episode should be removed from in-memory state
|
||||
assert 2 not in serie.episodeDict[1], "Episode should be removed from memory"
|
||||
|
||||
# Data file should still not exist (no file created)
|
||||
assert not data_path.exists(), "No data file should be created"
|
||||
Reference in New Issue
Block a user