Aniworld/tests/unit/test_download_progress_websocket.py
Lukas b0f3b643c7 Migrate download queue from JSON to SQLite database
- Created QueueRepository adapter in src/server/services/queue_repository.py
- Refactored DownloadService to use repository pattern instead of JSON
- Updated application startup to initialize download service from database
- Updated all test fixtures to use MockQueueRepository
- All 1104 tests passing
2025-12-02 16:01:25 +01:00

447 lines
15 KiB
Python

"""Unit tests for download progress WebSocket updates.
This module tests the integration between download service progress tracking
and WebSocket broadcasting to ensure real-time updates are properly sent
to connected clients.
"""
import asyncio
from typing import Any, Dict, List
from unittest.mock import Mock, patch
import pytest
from src.server.models.download import DownloadPriority, EpisodeIdentifier
from src.server.services.anime_service import AnimeService
from src.server.services.download_service import DownloadService
from src.server.services.progress_service import ProgressService
@pytest.fixture
def mock_series_app():
"""Mock SeriesApp for testing."""
from unittest.mock import MagicMock
app = MagicMock()
app.series_list = []
app.search = Mock(return_value=[])
app.ReScan = Mock()
# Create mock event handlers that can be assigned
app.download_status = None
app.scan_status = None
# Mock download with event triggering
async def mock_download(
serie_folder, season, episode, key, **kwargs
):
"""Simulate download with events."""
# Create event args that mimic SeriesApp's DownloadStatusEventArgs
class MockDownloadArgs:
def __init__(
self, status, serie_folder, season, episode,
key=None, progress=None, message=None, error=None,
item_id=None
):
self.status = status
self.serie_folder = serie_folder
self.key = key
self.season = season
self.episode = episode
self.progress = progress
self.message = message
self.error = error
self.item_id = item_id
# Trigger started event
if app.download_status:
app.download_status(MockDownloadArgs(
"started", serie_folder, season, episode, key=key
))
# Simulate progress updates
progress_values = [25.0, 50.0, 75.0, 100.0]
for progress in progress_values:
if app.download_status:
await asyncio.sleep(0.01) # Small delay
app.download_status(MockDownloadArgs(
"progress", serie_folder, season, episode,
key=key,
progress=progress,
message=f"Downloading... {progress}%"
))
# Trigger completed event
if app.download_status:
app.download_status(MockDownloadArgs(
"completed", serie_folder, season, episode, key=key
))
return True
return True
app.download = Mock(side_effect=mock_download)
return app
@pytest.fixture
def progress_service():
"""Create a ProgressService instance for testing."""
return ProgressService()
@pytest.fixture
async def anime_service(mock_series_app, progress_service):
"""Create an AnimeService with mocked dependencies."""
service = AnimeService(
series_app=mock_series_app,
progress_service=progress_service,
)
yield service
@pytest.fixture
async def download_service(anime_service, progress_service):
"""Create a DownloadService with mock repository for testing."""
from tests.unit.test_download_service import MockQueueRepository
mock_repo = MockQueueRepository()
service = DownloadService(
anime_service=anime_service,
progress_service=progress_service,
queue_repository=mock_repo,
)
yield service, progress_service
await service.stop()
class TestDownloadProgressWebSocket:
"""Test download progress WebSocket broadcasting."""
@pytest.mark.asyncio
async def test_progress_callback_broadcasts_updates(
self, download_service
):
"""Test that progress updates are emitted via events."""
download_svc, progress_svc = download_service
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
"""Capture progress events."""
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict()
})
# Subscribe to progress_updated events
progress_svc.subscribe("progress_updated", mock_event_handler)
# Add item to queue
# Note: serie_id uses provider key format (URL-safe, lowercase)
item_ids = await download_svc.add_to_queue(
serie_id="test-serie-1-key",
serie_folder="Test Anime (2024)",
serie_name="Test Anime",
episodes=[EpisodeIdentifier(season=1, episode=1)],
priority=DownloadPriority.NORMAL,
)
assert len(item_ids) == 1
# Start processing - this should trigger download with progress
result = await download_svc.start_queue_processing()
assert result is not None
# Wait for download to process
await asyncio.sleep(0.5)
# Filter download progress broadcasts
progress_broadcasts = [
b for b in broadcasts if b["type"] == "download_progress"
]
# Should have received multiple progress updates
assert len(progress_broadcasts) >= 2
# Verify progress data structure (Progress model format)
for broadcast in progress_broadcasts:
data = broadcast["data"]
assert "id" in data # Progress ID
assert "type" in data # Progress type
# Progress events use 'current' and 'total'
assert "current" in data or "message" in data
@pytest.mark.asyncio
async def test_progress_updates_include_episode_info(
self, download_service
):
"""Test that progress updates include episode information."""
download_svc, progress_svc = download_service
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict()
})
progress_svc.subscribe("progress_updated", mock_event_handler)
# Add item with specific episode info
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_svc.add_to_queue(
serie_id="test-serie-2-key",
serie_folder="My Test Anime (2024)",
serie_name="My Test Anime",
episodes=[EpisodeIdentifier(season=2, episode=5)],
priority=DownloadPriority.HIGH,
)
# Start processing
await download_svc.start_queue_processing()
await asyncio.sleep(0.5)
# Find progress broadcasts
progress_broadcasts = [
b for b in broadcasts if b["type"] == "download_progress"
]
assert len(progress_broadcasts) > 0
# Verify progress info is included
data = progress_broadcasts[0]["data"]
assert "id" in data
# ID contains folder name: download_My Test Anime (2024)_2_5
# Check for folder name substring (case-insensitive)
assert "my test anime" in data["id"].lower()
@pytest.mark.asyncio
async def test_progress_percent_increases(self, download_service):
"""Test that progress percentage increases over time."""
download_svc, progress_svc = download_service
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict()
})
progress_svc.subscribe("progress_updated", mock_event_handler)
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_svc.add_to_queue(
serie_id="test-serie-3-key",
serie_folder="Progress Test (2024)",
serie_name="Progress Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
await download_svc.start_queue_processing()
await asyncio.sleep(0.5)
# Get progress broadcasts in order
progress_broadcasts = [
b for b in broadcasts if b["type"] == "download_progress"
]
# Verify we have multiple updates
assert len(progress_broadcasts) >= 2
# Verify progress increases (using current value)
current_values = [
b["data"].get("current", 0) for b in progress_broadcasts
]
# Each current value should be >= the previous one
for i in range(1, len(current_values)):
assert current_values[i] >= current_values[i - 1]
@pytest.mark.asyncio
async def test_progress_includes_speed_and_eta(self, download_service):
"""Test that progress updates include speed and ETA."""
download_svc, progress_svc = download_service
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict()
})
progress_svc.subscribe("progress_updated", mock_event_handler)
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_svc.add_to_queue(
serie_id="test-serie-4-key",
serie_folder="Speed Test (2024)",
serie_name="Speed Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
await download_svc.start_queue_processing()
await asyncio.sleep(0.5)
progress_broadcasts = [
b for b in broadcasts if b["type"] == "download_progress"
]
assert len(progress_broadcasts) > 0
# Check that progress data is present
progress_data = progress_broadcasts[0]["data"]
assert "id" in progress_data
assert "type" in progress_data
assert progress_data["type"] == "download"
@pytest.mark.asyncio
async def test_no_broadcast_without_callback(self, download_service):
"""Test that no errors occur when no event handlers subscribed."""
download_svc, progress_svc = download_service
# Don't subscribe to any events
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_svc.add_to_queue(
serie_id="test-serie-5-key",
serie_folder="No Broadcast Test (2024)",
serie_name="No Broadcast Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Should complete without errors
await download_svc.start_queue_processing()
await asyncio.sleep(0.5)
# Verify download completed successfully
status = await download_svc.get_queue_status()
assert len(status.completed_downloads) == 1
@pytest.mark.asyncio
async def test_broadcast_error_handling(self, download_service):
"""Test that event handler errors don't break download process."""
download_svc, progress_svc = download_service
error_count = 0
async def failing_handler(event):
"""Event handler that always fails."""
nonlocal error_count
error_count += 1
raise RuntimeError("Event handler failed")
progress_svc.subscribe("progress_updated", failing_handler)
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_svc.add_to_queue(
serie_id="test-serie-6-key",
serie_folder="Error Handling Test (2024)",
serie_name="Error Handling Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Should complete despite handler errors
await download_svc.start_queue_processing()
await asyncio.sleep(0.5)
# Verify download still completed
status = await download_svc.get_queue_status()
assert len(status.completed_downloads) == 1
# Verify handler was attempted
assert error_count > 0
@pytest.mark.asyncio
async def test_multiple_downloads_broadcast_separately(
self, download_service
):
"""Test that multiple downloads emit progress separately."""
download_svc, progress_svc = download_service
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict()
})
progress_svc.subscribe("progress_updated", mock_event_handler)
# Add multiple episodes
# Note: serie_id uses provider key format (URL-safe, lowercase)
item_ids = await download_svc.add_to_queue(
serie_id="test-serie-7-key",
serie_folder="Multi Episode Test (2024)",
serie_name="Multi Episode Test",
episodes=[
EpisodeIdentifier(season=1, episode=1),
EpisodeIdentifier(season=1, episode=2),
],
)
assert len(item_ids) == 2
# Start processing
# Give time for both downloads
await download_svc.start_queue_processing()
await asyncio.sleep(2.0)
# Get progress broadcasts
progress_broadcasts = [
b for b in broadcasts if b["type"] == "download_progress"
]
# Should have progress for both episodes
assert len(progress_broadcasts) >= 4 # At least 2 updates per ep
# Verify different download IDs
download_ids = set()
for broadcast in progress_broadcasts:
download_id = broadcast["data"].get("id", "")
if "download_" in download_id:
download_ids.add(download_id)
# Should have at least 2 unique download progress IDs
assert len(download_ids) >= 2
@pytest.mark.asyncio
async def test_progress_data_format_matches_model(self, download_service):
"""Test that event data matches Progress model."""
download_svc, progress_svc = download_service
broadcasts: List[Dict[str, Any]] = []
async def mock_event_handler(event):
broadcasts.append({
"type": event.event_type,
"data": event.progress.to_dict()
})
progress_svc.subscribe("progress_updated", mock_event_handler)
# Note: serie_id uses provider key format (URL-safe, lowercase)
await download_svc.add_to_queue(
serie_id="test-serie-8-key",
serie_folder="Model Test (2024)",
serie_name="Model Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
await download_svc.start_queue_processing()
await asyncio.sleep(0.5)
progress_broadcasts = [
b for b in broadcasts if b["type"] == "download_progress"
]
assert len(progress_broadcasts) > 0
# Verify progress follows Progress model structure
progress_data = progress_broadcasts[0]["data"]
# Verify required fields from Progress model
assert "id" in progress_data
assert "type" in progress_data
assert "status" in progress_data
assert progress_data["type"] == "download"