"""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"