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
334 lines
13 KiB
Python
334 lines
13 KiB
Python
"""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"
|