fix progress issues

This commit is contained in:
2025-11-02 08:33:44 +01:00
parent d5f7b1598f
commit ca4bf72fde
8 changed files with 1217 additions and 322 deletions

View File

@@ -0,0 +1,398 @@
"""Integration tests for download progress WebSocket real-time updates.
This module tests the end-to-end flow of download progress from the
download service through the WebSocket service 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 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
from src.server.services.websocket_service import WebSocketService
@pytest.fixture
def mock_series_app():
"""Mock SeriesApp for testing."""
app = Mock()
app.series_list = []
app.search = Mock(return_value=[])
app.ReScan = Mock()
def mock_download(
serie_folder, season, episode, key, callback=None, **kwargs
):
"""Simulate download with realistic progress updates."""
if callback:
# Simulate yt-dlp progress updates
for percent in [10, 25, 50, 75, 90, 100]:
callback({
'percent': float(percent),
'downloaded_mb': percent,
'total_mb': 100.0,
'speed_mbps': 2.5,
'eta_seconds': int((100 - percent) / 2.5),
})
result = Mock()
result.success = True
result.message = "Download completed"
return result
app.download = Mock(side_effect=mock_download)
return app
@pytest.fixture
def progress_service():
"""Create a ProgressService instance."""
return ProgressService()
@pytest.fixture
def websocket_service():
"""Create a WebSocketService instance."""
return WebSocketService()
@pytest.fixture
async def anime_service(mock_series_app, progress_service):
"""Create an AnimeService."""
with patch(
"src.server.services.anime_service.SeriesApp",
return_value=mock_series_app
):
service = AnimeService(
directory="/test/anime",
progress_service=progress_service,
)
yield service
@pytest.fixture
async def download_service(anime_service, progress_service):
"""Create a DownloadService."""
service = DownloadService(
anime_service=anime_service,
progress_service=progress_service,
persistence_path="/tmp/test_integration_progress_queue.json",
)
yield service
await service.stop()
class TestDownloadProgressIntegration:
"""Integration tests for download progress WebSocket flow."""
@pytest.mark.asyncio
async def test_full_progress_flow_with_websocket(
self, download_service, websocket_service
):
"""Test complete flow from download to WebSocket broadcast."""
# Track all messages sent via WebSocket
sent_messages: List[Dict[str, Any]] = []
# Mock WebSocket broadcast methods
original_broadcast_progress = (
websocket_service.broadcast_download_progress
)
async def mock_broadcast_progress(download_id: str, data: dict):
"""Capture broadcast calls."""
sent_messages.append({
'type': 'download_progress',
'download_id': download_id,
'data': data,
})
# Call original to maintain functionality
await original_broadcast_progress(download_id, data)
websocket_service.broadcast_download_progress = (
mock_broadcast_progress
)
# Connect download service to WebSocket service
async def broadcast_callback(update_type: str, data: dict):
"""Bridge download service to WebSocket service."""
if update_type == "download_progress":
await websocket_service.broadcast_download_progress(
data.get("download_id", ""),
data,
)
download_service.set_broadcast_callback(broadcast_callback)
# Add download to queue
await download_service.add_to_queue(
serie_id="integration_test",
serie_folder="test_folder",
serie_name="Integration Test Anime",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Start processing
await download_service.start_queue_processing()
# Wait for download to complete
await asyncio.sleep(1.0)
# Verify progress messages were sent
progress_messages = [
m for m in sent_messages if m['type'] == 'download_progress'
]
assert len(progress_messages) >= 3 # Multiple progress updates
# Verify progress increases
percentages = [
m['data'].get('progress', {}).get('percent', 0)
for m in progress_messages
]
# Should have increasing percentages
for i in range(1, len(percentages)):
assert percentages[i] >= percentages[i - 1]
# Last update should be close to 100%
assert percentages[-1] >= 90
@pytest.mark.asyncio
async def test_websocket_client_receives_progress(
self, download_service, websocket_service
):
"""Test that WebSocket clients receive progress messages."""
# Track messages received by clients
client_messages: List[Dict[str, Any]] = []
# Mock WebSocket client
class MockWebSocket:
"""Mock WebSocket for testing."""
async def accept(self):
pass
async def send_json(self, data):
"""Capture sent messages."""
client_messages.append(data)
async def receive_json(self):
# Keep connection open
await asyncio.sleep(10)
mock_ws = MockWebSocket()
# Connect mock client
connection_id = "test_client_1"
await websocket_service.connect(mock_ws, connection_id)
# Connect download service to WebSocket service
async def broadcast_callback(update_type: str, data: dict):
if update_type == "download_progress":
await websocket_service.broadcast_download_progress(
data.get("download_id", ""),
data,
)
download_service.set_broadcast_callback(broadcast_callback)
# Add and start download
await download_service.add_to_queue(
serie_id="client_test",
serie_folder="test_folder",
serie_name="Client Test Anime",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
await download_service.start_queue_processing()
await asyncio.sleep(1.0)
# Verify client received messages
progress_messages = [
m for m in client_messages
if m.get('type') == 'download_progress'
]
assert len(progress_messages) >= 2
# Cleanup
await websocket_service.disconnect(connection_id)
@pytest.mark.asyncio
async def test_multiple_clients_receive_same_progress(
self, download_service, websocket_service
):
"""Test that all connected clients receive progress updates."""
# Track messages for each client
client1_messages: List[Dict] = []
client2_messages: List[Dict] = []
class MockWebSocket:
"""Mock WebSocket for testing."""
def __init__(self, message_list):
self.messages = message_list
async def accept(self):
pass
async def send_json(self, data):
self.messages.append(data)
async def receive_json(self):
await asyncio.sleep(10)
# Connect two clients
client1 = MockWebSocket(client1_messages)
client2 = MockWebSocket(client2_messages)
await websocket_service.connect(client1, "client1")
await websocket_service.connect(client2, "client2")
# Connect download service
async def broadcast_callback(update_type: str, data: dict):
if update_type == "download_progress":
await websocket_service.broadcast_download_progress(
data.get("download_id", ""),
data,
)
download_service.set_broadcast_callback(broadcast_callback)
# Start download
await download_service.add_to_queue(
serie_id="multi_client_test",
serie_folder="test_folder",
serie_name="Multi Client Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
await download_service.start_queue_processing()
await asyncio.sleep(1.0)
# Both clients should receive progress
client1_progress = [
m for m in client1_messages
if m.get('type') == 'download_progress'
]
client2_progress = [
m for m in client2_messages
if m.get('type') == 'download_progress'
]
assert len(client1_progress) >= 2
assert len(client2_progress) >= 2
# Both should have similar number of updates
assert abs(len(client1_progress) - len(client2_progress)) <= 2
# Cleanup
await websocket_service.disconnect("client1")
await websocket_service.disconnect("client2")
@pytest.mark.asyncio
async def test_progress_data_structure_matches_frontend_expectations(
self, download_service, websocket_service
):
"""Test that progress data structure matches frontend requirements."""
captured_data: List[Dict] = []
async def capture_broadcast(update_type: str, data: dict):
if update_type == "download_progress":
captured_data.append(data)
await websocket_service.broadcast_download_progress(
data.get("download_id", ""),
data,
)
download_service.set_broadcast_callback(capture_broadcast)
await download_service.add_to_queue(
serie_id="structure_test",
serie_folder="test_folder",
serie_name="Structure Test",
episodes=[EpisodeIdentifier(season=2, episode=3)],
)
await download_service.start_queue_processing()
await asyncio.sleep(1.0)
assert len(captured_data) > 0
# Verify data structure matches frontend expectations
for data in captured_data:
# Required fields for frontend (queue.js)
assert 'download_id' in data or 'item_id' in data
assert 'serie_name' in data
assert 'season' in data
assert 'episode' in data
assert 'progress' in data
# Progress object structure
progress = data['progress']
assert 'percent' in progress
assert 'downloaded_mb' in progress
assert 'total_mb' in progress
# Verify episode info
assert data['season'] == 2
assert data['episode'] == 3
assert data['serie_name'] == "Structure Test"
@pytest.mark.asyncio
async def test_disconnected_client_doesnt_receive_progress(
self, download_service, websocket_service
):
"""Test that disconnected clients don't receive updates."""
client_messages: List[Dict] = []
class MockWebSocket:
async def accept(self):
pass
async def send_json(self, data):
client_messages.append(data)
async def receive_json(self):
await asyncio.sleep(10)
mock_ws = MockWebSocket()
# Connect and then disconnect
connection_id = "temp_client"
await websocket_service.connect(mock_ws, connection_id)
await websocket_service.disconnect(connection_id)
# Connect download service
async def broadcast_callback(update_type: str, data: dict):
if update_type == "download_progress":
await websocket_service.broadcast_download_progress(
data.get("download_id", ""),
data,
)
download_service.set_broadcast_callback(broadcast_callback)
# Start download after disconnect
await download_service.add_to_queue(
serie_id="disconnect_test",
serie_folder="test_folder",
serie_name="Disconnect Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
initial_message_count = len(client_messages)
await download_service.start_queue_processing()
await asyncio.sleep(1.0)
# Should not receive progress updates after disconnect
progress_messages = [
m for m in client_messages[initial_message_count:]
if m.get('type') == 'download_progress'
]
assert len(progress_messages) == 0

View File

@@ -0,0 +1,403 @@
"""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,
DownloadProgress,
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."""
app = Mock()
app.series_list = []
app.search = Mock(return_value=[])
app.ReScan = Mock()
# Mock download with progress callback
def mock_download(
serie_folder, season, episode, key, callback=None, **kwargs
):
"""Simulate download with progress updates."""
if callback:
# Simulate progress updates
callback({
'percent': 25.0,
'downloaded_mb': 25.0,
'total_mb': 100.0,
'speed_mbps': 2.5,
'eta_seconds': 30,
})
callback({
'percent': 50.0,
'downloaded_mb': 50.0,
'total_mb': 100.0,
'speed_mbps': 2.5,
'eta_seconds': 20,
})
callback({
'percent': 100.0,
'downloaded_mb': 100.0,
'total_mb': 100.0,
'speed_mbps': 2.5,
'eta_seconds': 0,
})
# Return success result
result = Mock()
result.success = True
result.message = "Download completed"
return result
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."""
with patch(
"src.server.services.anime_service.SeriesApp",
return_value=mock_series_app
):
service = AnimeService(
directory="/test/anime",
progress_service=progress_service,
)
yield service
@pytest.fixture
async def download_service(anime_service, progress_service):
"""Create a DownloadService with dependencies."""
service = DownloadService(
anime_service=anime_service,
progress_service=progress_service,
persistence_path="/tmp/test_download_progress_queue.json",
)
yield 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 callback broadcasts updates via WebSocket."""
broadcasts: List[Dict[str, Any]] = []
async def mock_broadcast(update_type: str, data: dict):
"""Capture broadcast calls."""
broadcasts.append({"type": update_type, "data": data})
download_service.set_broadcast_callback(mock_broadcast)
# Add item to queue
item_ids = await download_service.add_to_queue(
serie_id="test_serie_1",
serie_folder="test_folder",
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_service.start_queue_processing()
assert result is not None
# Wait for download to process
await asyncio.sleep(0.5)
# Filter 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
for broadcast in progress_broadcasts:
data = broadcast["data"]
assert "download_id" in data or "item_id" in data
assert "progress" in data
progress = data["progress"]
assert "percent" in progress
assert "downloaded_mb" in progress
assert "total_mb" in progress
assert 0 <= progress["percent"] <= 100
@pytest.mark.asyncio
async def test_progress_updates_include_episode_info(
self, download_service
):
"""Test that progress updates include episode information."""
broadcasts: List[Dict[str, Any]] = []
async def mock_broadcast(update_type: str, data: dict):
broadcasts.append({"type": update_type, "data": data})
download_service.set_broadcast_callback(mock_broadcast)
# Add item with specific episode info
await download_service.add_to_queue(
serie_id="test_serie_2",
serie_folder="test_folder",
serie_name="My Test Anime",
episodes=[EpisodeIdentifier(season=2, episode=5)],
priority=DownloadPriority.HIGH,
)
# Start processing
await download_service.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 episode info is included
data = progress_broadcasts[0]["data"]
assert data["serie_name"] == "My Test Anime"
assert data["season"] == 2
assert data["episode"] == 5
@pytest.mark.asyncio
async def test_progress_percent_increases(self, download_service):
"""Test that progress percentage increases over time."""
broadcasts: List[Dict[str, Any]] = []
async def mock_broadcast(update_type: str, data: dict):
broadcasts.append({"type": update_type, "data": data})
download_service.set_broadcast_callback(mock_broadcast)
await download_service.add_to_queue(
serie_id="test_serie_3",
serie_folder="test_folder",
serie_name="Progress Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
await download_service.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
percentages = [
b["data"]["progress"]["percent"] for b in progress_broadcasts
]
# Each percentage should be >= the previous one
for i in range(1, len(percentages)):
assert percentages[i] >= percentages[i - 1]
@pytest.mark.asyncio
async def test_progress_includes_speed_and_eta(self, download_service):
"""Test that progress updates include speed and ETA."""
broadcasts: List[Dict[str, Any]] = []
async def mock_broadcast(update_type: str, data: dict):
broadcasts.append({"type": update_type, "data": data})
download_service.set_broadcast_callback(mock_broadcast)
await download_service.add_to_queue(
serie_id="test_serie_4",
serie_folder="test_folder",
serie_name="Speed Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
await download_service.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 speed and ETA are present
progress = progress_broadcasts[0]["data"]["progress"]
assert "speed_mbps" in progress
assert "eta_seconds" in progress
# Speed and ETA should be numeric (or None)
if progress["speed_mbps"] is not None:
assert isinstance(progress["speed_mbps"], (int, float))
if progress["eta_seconds"] is not None:
assert isinstance(progress["eta_seconds"], (int, float))
@pytest.mark.asyncio
async def test_no_broadcast_without_callback(self, download_service):
"""Test that no errors occur when broadcast callback is not set."""
# Don't set broadcast callback
await download_service.add_to_queue(
serie_id="test_serie_5",
serie_folder="test_folder",
serie_name="No Broadcast Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Should complete without errors
await download_service.start_queue_processing()
await asyncio.sleep(0.5)
# Verify download completed successfully
status = await download_service.get_queue_status()
assert len(status.completed_downloads) == 1
@pytest.mark.asyncio
async def test_broadcast_error_handling(self, download_service):
"""Test that broadcast errors don't break download process."""
error_count = 0
async def failing_broadcast(update_type: str, data: dict):
"""Broadcast that always fails."""
nonlocal error_count
error_count += 1
raise RuntimeError("Broadcast failed")
download_service.set_broadcast_callback(failing_broadcast)
await download_service.add_to_queue(
serie_id="test_serie_6",
serie_folder="test_folder",
serie_name="Error Handling Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
# Should complete despite broadcast errors
await download_service.start_queue_processing()
await asyncio.sleep(0.5)
# Verify download still completed
status = await download_service.get_queue_status()
assert len(status.completed_downloads) == 1
# Verify broadcast was attempted
assert error_count > 0
@pytest.mark.asyncio
async def test_multiple_downloads_broadcast_separately(
self, download_service
):
"""Test that multiple downloads broadcast their progress separately."""
broadcasts: List[Dict[str, Any]] = []
async def mock_broadcast(update_type: str, data: dict):
broadcasts.append({"type": update_type, "data": data})
download_service.set_broadcast_callback(mock_broadcast)
# Add multiple episodes
item_ids = await download_service.add_to_queue(
serie_id="test_serie_7",
serie_folder="test_folder",
serie_name="Multi Episode Test",
episodes=[
EpisodeIdentifier(season=1, episode=1),
EpisodeIdentifier(season=1, episode=2),
],
)
assert len(item_ids) == 2
# Start processing
await download_service.start_queue_processing()
await asyncio.sleep(1.0) # Give time for both downloads
# 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 episode
# Verify different download IDs
download_ids = set()
for broadcast in progress_broadcasts:
download_id = (
broadcast["data"].get("download_id")
or broadcast["data"].get("item_id")
)
if download_id:
download_ids.add(download_id)
# Should have at least 2 unique download IDs
assert len(download_ids) >= 2
@pytest.mark.asyncio
async def test_progress_data_format_matches_model(self, download_service):
"""Test that broadcast data matches DownloadProgress model."""
broadcasts: List[Dict[str, Any]] = []
async def mock_broadcast(update_type: str, data: dict):
broadcasts.append({"type": update_type, "data": data})
download_service.set_broadcast_callback(mock_broadcast)
await download_service.add_to_queue(
serie_id="test_serie_8",
serie_folder="test_folder",
serie_name="Model Test",
episodes=[EpisodeIdentifier(season=1, episode=1)],
)
await download_service.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 can be parsed as DownloadProgress
progress_data = progress_broadcasts[0]["data"]["progress"]
progress = DownloadProgress(**progress_data)
# Verify required fields
assert isinstance(progress.percent, float)
assert isinstance(progress.downloaded_mb, float)
assert 0 <= progress.percent <= 100
assert progress.downloaded_mb >= 0