- Add ProgressService for centralized progress tracking and broadcasting - Integrate ProgressService with DownloadService for download progress - Integrate ProgressService with AnimeService for scan progress - Add progress-related WebSocket message models (ScanProgress, ErrorNotification, etc.) - Initialize ProgressService with WebSocket callback in application startup - Add comprehensive unit tests for ProgressService - Update infrastructure.md with ProgressService documentation - Remove completed WebSocket Real-time Updates task from instructions.md The ProgressService provides: - Real-time progress tracking for downloads, scans, and queue operations - Automatic progress percentage calculation - Progress lifecycle management (start, update, complete, fail, cancel) - WebSocket integration for instant client updates - Progress history with size limits - Thread-safe operations using asyncio locks - Support for metadata and custom messages Benefits: - Decoupled progress tracking from WebSocket broadcasting - Single reusable service across all components - Supports multiple concurrent operations efficiently - Centralized progress tracking simplifies monitoring - Instant feedback to users on long-running operations
500 lines
16 KiB
Python
500 lines
16 KiB
Python
"""Unit tests for ProgressService.
|
|
|
|
This module contains comprehensive tests for the progress tracking service,
|
|
including progress lifecycle, broadcasting, error handling, and concurrency.
|
|
"""
|
|
import asyncio
|
|
from datetime import datetime
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from src.server.services.progress_service import (
|
|
ProgressService,
|
|
ProgressServiceError,
|
|
ProgressStatus,
|
|
ProgressType,
|
|
ProgressUpdate,
|
|
)
|
|
|
|
|
|
class TestProgressUpdate:
|
|
"""Test ProgressUpdate dataclass."""
|
|
|
|
def test_progress_update_creation(self):
|
|
"""Test creating a progress update."""
|
|
update = ProgressUpdate(
|
|
id="test-1",
|
|
type=ProgressType.DOWNLOAD,
|
|
status=ProgressStatus.STARTED,
|
|
title="Test Download",
|
|
message="Starting download",
|
|
total=100,
|
|
)
|
|
|
|
assert update.id == "test-1"
|
|
assert update.type == ProgressType.DOWNLOAD
|
|
assert update.status == ProgressStatus.STARTED
|
|
assert update.title == "Test Download"
|
|
assert update.message == "Starting download"
|
|
assert update.total == 100
|
|
assert update.current == 0
|
|
assert update.percent == 0.0
|
|
|
|
def test_progress_update_to_dict(self):
|
|
"""Test converting progress update to dictionary."""
|
|
update = ProgressUpdate(
|
|
id="test-1",
|
|
type=ProgressType.SCAN,
|
|
status=ProgressStatus.IN_PROGRESS,
|
|
title="Test Scan",
|
|
message="Scanning files",
|
|
current=50,
|
|
total=100,
|
|
metadata={"test_key": "test_value"},
|
|
)
|
|
|
|
result = update.to_dict()
|
|
|
|
assert result["id"] == "test-1"
|
|
assert result["type"] == "scan"
|
|
assert result["status"] == "in_progress"
|
|
assert result["title"] == "Test Scan"
|
|
assert result["message"] == "Scanning files"
|
|
assert result["current"] == 50
|
|
assert result["total"] == 100
|
|
assert result["percent"] == 0.0
|
|
assert result["metadata"]["test_key"] == "test_value"
|
|
assert "started_at" in result
|
|
assert "updated_at" in result
|
|
|
|
|
|
class TestProgressService:
|
|
"""Test ProgressService class."""
|
|
|
|
@pytest.fixture
|
|
def service(self):
|
|
"""Create a fresh ProgressService instance for each test."""
|
|
return ProgressService()
|
|
|
|
@pytest.fixture
|
|
def mock_broadcast(self):
|
|
"""Create a mock broadcast callback."""
|
|
return AsyncMock()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_progress(self, service):
|
|
"""Test starting a new progress operation."""
|
|
update = await service.start_progress(
|
|
progress_id="download-1",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title="Downloading episode",
|
|
total=1000,
|
|
message="Starting...",
|
|
metadata={"episode": "S01E01"},
|
|
)
|
|
|
|
assert update.id == "download-1"
|
|
assert update.type == ProgressType.DOWNLOAD
|
|
assert update.status == ProgressStatus.STARTED
|
|
assert update.title == "Downloading episode"
|
|
assert update.total == 1000
|
|
assert update.message == "Starting..."
|
|
assert update.metadata["episode"] == "S01E01"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_progress_duplicate_id(self, service):
|
|
"""Test starting progress with duplicate ID raises error."""
|
|
await service.start_progress(
|
|
progress_id="test-1",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title="Test",
|
|
)
|
|
|
|
with pytest.raises(ProgressServiceError, match="already exists"):
|
|
await service.start_progress(
|
|
progress_id="test-1",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title="Test Duplicate",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_progress(self, service):
|
|
"""Test updating an existing progress operation."""
|
|
await service.start_progress(
|
|
progress_id="test-1",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title="Test",
|
|
total=100,
|
|
)
|
|
|
|
update = await service.update_progress(
|
|
progress_id="test-1",
|
|
current=50,
|
|
message="Half way",
|
|
)
|
|
|
|
assert update.current == 50
|
|
assert update.total == 100
|
|
assert update.percent == 50.0
|
|
assert update.message == "Half way"
|
|
assert update.status == ProgressStatus.IN_PROGRESS
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_progress_not_found(self, service):
|
|
"""Test updating non-existent progress raises error."""
|
|
with pytest.raises(ProgressServiceError, match="not found"):
|
|
await service.update_progress(
|
|
progress_id="nonexistent",
|
|
current=50,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_progress_percentage_calculation(self, service):
|
|
"""Test progress percentage is calculated correctly."""
|
|
await service.start_progress(
|
|
progress_id="test-1",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title="Test",
|
|
total=200,
|
|
)
|
|
|
|
await service.update_progress(progress_id="test-1", current=50)
|
|
update = await service.get_progress("test-1")
|
|
assert update.percent == 25.0
|
|
|
|
await service.update_progress(progress_id="test-1", current=100)
|
|
update = await service.get_progress("test-1")
|
|
assert update.percent == 50.0
|
|
|
|
await service.update_progress(progress_id="test-1", current=200)
|
|
update = await service.get_progress("test-1")
|
|
assert update.percent == 100.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_progress(self, service):
|
|
"""Test completing a progress operation."""
|
|
await service.start_progress(
|
|
progress_id="test-1",
|
|
progress_type=ProgressType.SCAN,
|
|
title="Test Scan",
|
|
total=100,
|
|
)
|
|
|
|
await service.update_progress(progress_id="test-1", current=50)
|
|
|
|
update = await service.complete_progress(
|
|
progress_id="test-1",
|
|
message="Scan completed successfully",
|
|
metadata={"items_found": 42},
|
|
)
|
|
|
|
assert update.status == ProgressStatus.COMPLETED
|
|
assert update.percent == 100.0
|
|
assert update.current == update.total
|
|
assert update.message == "Scan completed successfully"
|
|
assert update.metadata["items_found"] == 42
|
|
|
|
# Should be moved to history
|
|
active_progress = await service.get_all_active_progress()
|
|
assert "test-1" not in active_progress
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fail_progress(self, service):
|
|
"""Test failing a progress operation."""
|
|
await service.start_progress(
|
|
progress_id="test-1",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title="Test Download",
|
|
)
|
|
|
|
update = await service.fail_progress(
|
|
progress_id="test-1",
|
|
error_message="Network timeout",
|
|
metadata={"retry_count": 3},
|
|
)
|
|
|
|
assert update.status == ProgressStatus.FAILED
|
|
assert update.message == "Network timeout"
|
|
assert update.metadata["retry_count"] == 3
|
|
|
|
# Should be moved to history
|
|
active_progress = await service.get_all_active_progress()
|
|
assert "test-1" not in active_progress
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_progress(self, service):
|
|
"""Test cancelling a progress operation."""
|
|
await service.start_progress(
|
|
progress_id="test-1",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title="Test Download",
|
|
)
|
|
|
|
update = await service.cancel_progress(
|
|
progress_id="test-1",
|
|
message="Cancelled by user",
|
|
)
|
|
|
|
assert update.status == ProgressStatus.CANCELLED
|
|
assert update.message == "Cancelled by user"
|
|
|
|
# Should be moved to history
|
|
active_progress = await service.get_all_active_progress()
|
|
assert "test-1" not in active_progress
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_progress(self, service):
|
|
"""Test retrieving progress by ID."""
|
|
await service.start_progress(
|
|
progress_id="test-1",
|
|
progress_type=ProgressType.SCAN,
|
|
title="Test",
|
|
)
|
|
|
|
progress = await service.get_progress("test-1")
|
|
assert progress is not None
|
|
assert progress.id == "test-1"
|
|
|
|
# Test non-existent progress
|
|
progress = await service.get_progress("nonexistent")
|
|
assert progress is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_all_active_progress(self, service):
|
|
"""Test retrieving all active progress operations."""
|
|
await service.start_progress(
|
|
progress_id="download-1",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title="Download 1",
|
|
)
|
|
await service.start_progress(
|
|
progress_id="download-2",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title="Download 2",
|
|
)
|
|
await service.start_progress(
|
|
progress_id="scan-1",
|
|
progress_type=ProgressType.SCAN,
|
|
title="Scan 1",
|
|
)
|
|
|
|
all_progress = await service.get_all_active_progress()
|
|
assert len(all_progress) == 3
|
|
assert "download-1" in all_progress
|
|
assert "download-2" in all_progress
|
|
assert "scan-1" in all_progress
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_all_active_progress_filtered(self, service):
|
|
"""Test retrieving active progress filtered by type."""
|
|
await service.start_progress(
|
|
progress_id="download-1",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title="Download 1",
|
|
)
|
|
await service.start_progress(
|
|
progress_id="download-2",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title="Download 2",
|
|
)
|
|
await service.start_progress(
|
|
progress_id="scan-1",
|
|
progress_type=ProgressType.SCAN,
|
|
title="Scan 1",
|
|
)
|
|
|
|
download_progress = await service.get_all_active_progress(
|
|
progress_type=ProgressType.DOWNLOAD
|
|
)
|
|
assert len(download_progress) == 2
|
|
assert "download-1" in download_progress
|
|
assert "download-2" in download_progress
|
|
assert "scan-1" not in download_progress
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_history_management(self, service):
|
|
"""Test progress history is maintained with size limit."""
|
|
# Start and complete multiple progress operations
|
|
for i in range(60): # More than max_history_size (50)
|
|
await service.start_progress(
|
|
progress_id=f"test-{i}",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title=f"Test {i}",
|
|
)
|
|
await service.complete_progress(
|
|
progress_id=f"test-{i}",
|
|
message="Completed",
|
|
)
|
|
|
|
# Check that oldest entries were removed
|
|
history = service._history
|
|
assert len(history) <= 50
|
|
|
|
# Most recent should be in history
|
|
recent_progress = await service.get_progress("test-59")
|
|
assert recent_progress is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_callback(self, service, mock_broadcast):
|
|
"""Test broadcast callback is invoked correctly."""
|
|
service.set_broadcast_callback(mock_broadcast)
|
|
|
|
await service.start_progress(
|
|
progress_id="test-1",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title="Test",
|
|
)
|
|
|
|
# Verify callback was called for start
|
|
mock_broadcast.assert_called_once()
|
|
call_args = mock_broadcast.call_args
|
|
assert call_args[1]["message_type"] == "download_progress"
|
|
assert call_args[1]["room"] == "download_progress"
|
|
assert "test-1" in str(call_args[1]["data"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_on_update(self, service, mock_broadcast):
|
|
"""Test broadcast on progress update."""
|
|
service.set_broadcast_callback(mock_broadcast)
|
|
|
|
await service.start_progress(
|
|
progress_id="test-1",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title="Test",
|
|
total=100,
|
|
)
|
|
mock_broadcast.reset_mock()
|
|
|
|
# Update with significant change (>1%)
|
|
await service.update_progress(
|
|
progress_id="test-1",
|
|
current=50,
|
|
force_broadcast=True,
|
|
)
|
|
|
|
# Should have been called
|
|
assert mock_broadcast.call_count >= 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_on_complete(self, service, mock_broadcast):
|
|
"""Test broadcast on progress completion."""
|
|
service.set_broadcast_callback(mock_broadcast)
|
|
|
|
await service.start_progress(
|
|
progress_id="test-1",
|
|
progress_type=ProgressType.SCAN,
|
|
title="Test",
|
|
)
|
|
mock_broadcast.reset_mock()
|
|
|
|
await service.complete_progress(
|
|
progress_id="test-1",
|
|
message="Done",
|
|
)
|
|
|
|
# Should have been called
|
|
mock_broadcast.assert_called_once()
|
|
call_args = mock_broadcast.call_args
|
|
assert "completed" in str(call_args[1]["data"]).lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_on_failure(self, service, mock_broadcast):
|
|
"""Test broadcast on progress failure."""
|
|
service.set_broadcast_callback(mock_broadcast)
|
|
|
|
await service.start_progress(
|
|
progress_id="test-1",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title="Test",
|
|
)
|
|
mock_broadcast.reset_mock()
|
|
|
|
await service.fail_progress(
|
|
progress_id="test-1",
|
|
error_message="Test error",
|
|
)
|
|
|
|
# Should have been called
|
|
mock_broadcast.assert_called_once()
|
|
call_args = mock_broadcast.call_args
|
|
assert "failed" in str(call_args[1]["data"]).lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_history(self, service):
|
|
"""Test clearing progress history."""
|
|
# Create and complete some progress
|
|
for i in range(5):
|
|
await service.start_progress(
|
|
progress_id=f"test-{i}",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title=f"Test {i}",
|
|
)
|
|
await service.complete_progress(
|
|
progress_id=f"test-{i}",
|
|
message="Done",
|
|
)
|
|
|
|
# History should not be empty
|
|
assert len(service._history) > 0
|
|
|
|
# Clear history
|
|
await service.clear_history()
|
|
|
|
# History should now be empty
|
|
assert len(service._history) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_progress_operations(self, service):
|
|
"""Test handling multiple concurrent progress operations."""
|
|
|
|
async def create_and_complete_progress(id_num: int):
|
|
"""Helper to create and complete a progress."""
|
|
await service.start_progress(
|
|
progress_id=f"test-{id_num}",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title=f"Test {id_num}",
|
|
total=100,
|
|
)
|
|
for i in range(0, 101, 10):
|
|
await service.update_progress(
|
|
progress_id=f"test-{id_num}",
|
|
current=i,
|
|
)
|
|
await asyncio.sleep(0.01)
|
|
await service.complete_progress(
|
|
progress_id=f"test-{id_num}",
|
|
message="Done",
|
|
)
|
|
|
|
# Run multiple concurrent operations
|
|
tasks = [create_and_complete_progress(i) for i in range(10)]
|
|
await asyncio.gather(*tasks)
|
|
|
|
# All should be in history
|
|
for i in range(10):
|
|
progress = await service.get_progress(f"test-{i}")
|
|
assert progress is not None
|
|
assert progress.status == ProgressStatus.COMPLETED
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_with_metadata(self, service):
|
|
"""Test updating progress with metadata."""
|
|
await service.start_progress(
|
|
progress_id="test-1",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title="Test",
|
|
metadata={"initial": "value"},
|
|
)
|
|
|
|
await service.update_progress(
|
|
progress_id="test-1",
|
|
current=50,
|
|
metadata={"additional": "data", "speed": 1.5},
|
|
)
|
|
|
|
progress = await service.get_progress("test-1")
|
|
assert progress.metadata["initial"] == "value"
|
|
assert progress.metadata["additional"] == "data"
|
|
assert progress.metadata["speed"] == 1.5
|