- Fixed room name mismatch: ProgressService was broadcasting to 'download_progress' but JS clients join 'downloads' room - Added _get_room_for_progress_type() mapping function - Updated all progress methods to use correct room names - Added 13 new tests for room name mapping and broadcast verification - Updated existing tests to expect correct room names - Fixed JS clients to join valid rooms (downloads, queue, scan)
602 lines
20 KiB
Python
602 lines
20 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."""
|
|
# Subscribe to progress_updated events
|
|
service.subscribe("progress_updated", 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()
|
|
# First positional arg is ProgressEvent
|
|
call_args = mock_broadcast.call_args[0][0]
|
|
assert call_args.event_type == "download_progress"
|
|
assert call_args.room == "downloads" # Room name for DOWNLOAD type
|
|
assert call_args.progress_id == "test-1"
|
|
assert call_args.progress.id == "test-1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_on_update(self, service, mock_broadcast):
|
|
"""Test broadcast on progress update."""
|
|
# Subscribe to progress_updated events
|
|
service.subscribe("progress_updated", 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
|
|
# First positional arg is ProgressEvent
|
|
call_args = mock_broadcast.call_args[0][0]
|
|
assert call_args.progress.current == 50
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_on_complete(self, service, mock_broadcast):
|
|
"""Test broadcast on progress completion."""
|
|
# Subscribe to progress_updated events
|
|
service.subscribe("progress_updated", 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()
|
|
# First positional arg is ProgressEvent
|
|
call_args = mock_broadcast.call_args[0][0]
|
|
assert call_args.progress.status.value == "completed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_broadcast_on_failure(self, service, mock_broadcast):
|
|
"""Test broadcast on progress failure."""
|
|
# Subscribe to progress_updated events
|
|
service.subscribe("progress_updated", 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()
|
|
# First positional arg is ProgressEvent
|
|
call_args = mock_broadcast.call_args[0][0]
|
|
assert call_args.progress.status.value == "failed"
|
|
|
|
@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
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_progress_with_key_and_folder(self, service):
|
|
"""Test progress tracking with series key and folder."""
|
|
# Start progress with key and folder
|
|
update = await service.start_progress(
|
|
progress_id="download-series-1",
|
|
progress_type=ProgressType.DOWNLOAD,
|
|
title="Downloading Attack on Titan",
|
|
key="attack-on-titan",
|
|
folder="Attack on Titan (2013)",
|
|
total=100,
|
|
)
|
|
|
|
assert update.key == "attack-on-titan"
|
|
assert update.folder == "Attack on Titan (2013)"
|
|
|
|
# Verify to_dict includes key and folder
|
|
dict_repr = update.to_dict()
|
|
assert dict_repr["key"] == "attack-on-titan"
|
|
assert dict_repr["folder"] == "Attack on Titan (2013)"
|
|
|
|
# Update progress and verify key/folder are preserved
|
|
updated = await service.update_progress(
|
|
progress_id="download-series-1",
|
|
current=50,
|
|
)
|
|
|
|
assert updated.key == "attack-on-titan"
|
|
assert updated.folder == "Attack on Titan (2013)"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_progress_update_key_and_folder(self, service):
|
|
"""Test updating key and folder in existing progress."""
|
|
# Start without key/folder
|
|
await service.start_progress(
|
|
progress_id="test-1",
|
|
progress_type=ProgressType.SCAN,
|
|
title="Test Scan",
|
|
)
|
|
|
|
# Update with key and folder
|
|
updated = await service.update_progress(
|
|
progress_id="test-1",
|
|
key="one-piece",
|
|
folder="One Piece (1999)",
|
|
current=10,
|
|
)
|
|
|
|
assert updated.key == "one-piece"
|
|
assert updated.folder == "One Piece (1999)"
|
|
|
|
# Verify to_dict includes the fields
|
|
dict_repr = updated.to_dict()
|
|
assert dict_repr["key"] == "one-piece"
|
|
assert dict_repr["folder"] == "One Piece (1999)"
|
|
|
|
def test_progress_update_to_dict_without_key_folder(self):
|
|
"""Test to_dict doesn't include key/folder if not set."""
|
|
update = ProgressUpdate(
|
|
id="test-1",
|
|
type=ProgressType.SYSTEM,
|
|
status=ProgressStatus.STARTED,
|
|
title="System Task",
|
|
)
|
|
|
|
result = update.to_dict()
|
|
|
|
# key and folder should not be in dict if not set
|
|
assert "key" not in result
|
|
assert "folder" not in result
|
|
|
|
def test_progress_update_creation_with_key_folder(self):
|
|
"""Test creating progress update with key and folder."""
|
|
update = ProgressUpdate(
|
|
id="test-1",
|
|
type=ProgressType.DOWNLOAD,
|
|
status=ProgressStatus.STARTED,
|
|
title="Test Download",
|
|
key="naruto",
|
|
folder="Naruto (2002)",
|
|
total=100,
|
|
)
|
|
|
|
assert update.key == "naruto"
|
|
assert update.folder == "Naruto (2002)"
|
|
|
|
# Verify to_dict includes them
|
|
result = update.to_dict()
|
|
assert result["key"] == "naruto"
|
|
assert result["folder"] == "Naruto (2002)"
|