Aniworld/tests/unit/test_progress_service.py
Lukas 700f491ef9 fix: progress broadcasts now use correct WebSocket room names
- 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)
2025-12-16 19:21:30 +01:00

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)"