feat: implement download queue service with persistence, priority, and retry logic
- Added comprehensive download queue service (download_service.py) - Priority-based queue management (HIGH, NORMAL, LOW) - Concurrent download processing with configurable limits - Automatic queue persistence to JSON file - Retry logic for failed downloads with max retry limits - Real-time progress tracking and WebSocket broadcasting - Queue operations: add, remove, reorder, pause, resume - Statistics tracking: download speeds, sizes, ETA calculations - Created comprehensive unit tests (test_download_service.py) - 23 tests covering all service functionality - Tests for queue management, persistence, retry logic - Broadcast callbacks, error handling, and lifecycle - Added structlog dependency for structured logging - Updated infrastructure.md with download service documentation - Removed completed task from instructions.md All tests passing (23/23)
This commit is contained in:
491
tests/unit/test_download_service.py
Normal file
491
tests/unit/test_download_service.py
Normal file
@@ -0,0 +1,491 @@
|
||||
"""Unit tests for the download queue service.
|
||||
|
||||
Tests cover queue management, priority handling, persistence,
|
||||
concurrent downloads, and error scenarios.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.models.download import (
|
||||
DownloadItem,
|
||||
DownloadPriority,
|
||||
DownloadStatus,
|
||||
EpisodeIdentifier,
|
||||
)
|
||||
from src.server.services.anime_service import AnimeService
|
||||
from src.server.services.download_service import DownloadService, DownloadServiceError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anime_service():
|
||||
"""Create a mock AnimeService."""
|
||||
service = MagicMock(spec=AnimeService)
|
||||
service.download = AsyncMock(return_value=True)
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_persistence_path(tmp_path):
|
||||
"""Create a temporary persistence path."""
|
||||
return str(tmp_path / "test_queue.json")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def download_service(mock_anime_service, temp_persistence_path):
|
||||
"""Create a DownloadService instance for testing."""
|
||||
return DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
max_concurrent_downloads=2,
|
||||
max_retries=3,
|
||||
persistence_path=temp_persistence_path,
|
||||
)
|
||||
|
||||
|
||||
class TestDownloadServiceInitialization:
|
||||
"""Test download service initialization."""
|
||||
|
||||
def test_initialization_creates_queues(
|
||||
self, mock_anime_service, temp_persistence_path
|
||||
):
|
||||
"""Test that initialization creates empty queues."""
|
||||
service = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
persistence_path=temp_persistence_path,
|
||||
)
|
||||
|
||||
assert len(service._pending_queue) == 0
|
||||
assert len(service._active_downloads) == 0
|
||||
assert len(service._completed_items) == 0
|
||||
assert len(service._failed_items) == 0
|
||||
assert service._is_running is False
|
||||
assert service._is_paused is False
|
||||
|
||||
def test_initialization_loads_persisted_queue(
|
||||
self, mock_anime_service, temp_persistence_path
|
||||
):
|
||||
"""Test that initialization loads persisted queue state."""
|
||||
# Create a persisted queue file
|
||||
persistence_file = Path(temp_persistence_path)
|
||||
persistence_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
test_data = {
|
||||
"pending": [
|
||||
{
|
||||
"id": "test-id-1",
|
||||
"serie_id": "series-1",
|
||||
"serie_name": "Test Series",
|
||||
"episode": {"season": 1, "episode": 1, "title": None},
|
||||
"status": "pending",
|
||||
"priority": "normal",
|
||||
"added_at": datetime.utcnow().isoformat(),
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
"progress": None,
|
||||
"error": None,
|
||||
"retry_count": 0,
|
||||
"source_url": None,
|
||||
}
|
||||
],
|
||||
"active": [],
|
||||
"failed": [],
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
with open(persistence_file, "w", encoding="utf-8") as f:
|
||||
json.dump(test_data, f)
|
||||
|
||||
service = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
persistence_path=temp_persistence_path,
|
||||
)
|
||||
|
||||
assert len(service._pending_queue) == 1
|
||||
assert service._pending_queue[0].id == "test-id-1"
|
||||
|
||||
|
||||
class TestQueueManagement:
|
||||
"""Test queue management operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_to_queue_single_episode(self, download_service):
|
||||
"""Test adding a single episode to queue."""
|
||||
episodes = [EpisodeIdentifier(season=1, episode=1)]
|
||||
|
||||
item_ids = await download_service.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_name="Test Series",
|
||||
episodes=episodes,
|
||||
priority=DownloadPriority.NORMAL,
|
||||
)
|
||||
|
||||
assert len(item_ids) == 1
|
||||
assert len(download_service._pending_queue) == 1
|
||||
assert download_service._pending_queue[0].serie_id == "series-1"
|
||||
assert (
|
||||
download_service._pending_queue[0].status
|
||||
== DownloadStatus.PENDING
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_to_queue_multiple_episodes(self, download_service):
|
||||
"""Test adding multiple episodes to queue."""
|
||||
episodes = [
|
||||
EpisodeIdentifier(season=1, episode=1),
|
||||
EpisodeIdentifier(season=1, episode=2),
|
||||
EpisodeIdentifier(season=1, episode=3),
|
||||
]
|
||||
|
||||
item_ids = await download_service.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_name="Test Series",
|
||||
episodes=episodes,
|
||||
priority=DownloadPriority.NORMAL,
|
||||
)
|
||||
|
||||
assert len(item_ids) == 3
|
||||
assert len(download_service._pending_queue) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_high_priority_to_front(self, download_service):
|
||||
"""Test that high priority items are added to front of queue."""
|
||||
# Add normal priority item
|
||||
await download_service.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_name="Test Series",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
priority=DownloadPriority.NORMAL,
|
||||
)
|
||||
|
||||
# Add high priority item
|
||||
await download_service.add_to_queue(
|
||||
serie_id="series-2",
|
||||
serie_name="Priority Series",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
priority=DownloadPriority.HIGH,
|
||||
)
|
||||
|
||||
# High priority should be at front
|
||||
assert download_service._pending_queue[0].serie_id == "series-2"
|
||||
assert download_service._pending_queue[1].serie_id == "series-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_from_pending_queue(self, download_service):
|
||||
"""Test removing items from pending queue."""
|
||||
item_ids = await download_service.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_name="Test Series",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
|
||||
removed_ids = await download_service.remove_from_queue(item_ids)
|
||||
|
||||
assert len(removed_ids) == 1
|
||||
assert removed_ids[0] == item_ids[0]
|
||||
assert len(download_service._pending_queue) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reorder_queue(self, download_service):
|
||||
"""Test reordering items in queue."""
|
||||
# Add three items
|
||||
await download_service.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_name="Series 1",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
await download_service.add_to_queue(
|
||||
serie_id="series-2",
|
||||
serie_name="Series 2",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
await download_service.add_to_queue(
|
||||
serie_id="series-3",
|
||||
serie_name="Series 3",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
|
||||
# Move last item to position 0
|
||||
item_to_move = download_service._pending_queue[2].id
|
||||
success = await download_service.reorder_queue(item_to_move, 0)
|
||||
|
||||
assert success is True
|
||||
assert download_service._pending_queue[0].id == item_to_move
|
||||
assert download_service._pending_queue[0].serie_id == "series-3"
|
||||
|
||||
|
||||
class TestQueueStatus:
|
||||
"""Test queue status reporting."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_queue_status(self, download_service):
|
||||
"""Test getting queue status."""
|
||||
# Add items to queue
|
||||
await download_service.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_name="Test Series",
|
||||
episodes=[
|
||||
EpisodeIdentifier(season=1, episode=1),
|
||||
EpisodeIdentifier(season=1, episode=2),
|
||||
],
|
||||
)
|
||||
|
||||
status = await download_service.get_queue_status()
|
||||
|
||||
assert status.is_running is False
|
||||
assert status.is_paused is False
|
||||
assert len(status.pending_queue) == 2
|
||||
assert len(status.active_downloads) == 0
|
||||
assert len(status.completed_downloads) == 0
|
||||
assert len(status.failed_downloads) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_queue_stats(self, download_service):
|
||||
"""Test getting queue statistics."""
|
||||
# Add items
|
||||
await download_service.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_name="Test Series",
|
||||
episodes=[
|
||||
EpisodeIdentifier(season=1, episode=1),
|
||||
EpisodeIdentifier(season=1, episode=2),
|
||||
],
|
||||
)
|
||||
|
||||
stats = await download_service.get_queue_stats()
|
||||
|
||||
assert stats.total_items == 2
|
||||
assert stats.pending_count == 2
|
||||
assert stats.active_count == 0
|
||||
assert stats.completed_count == 0
|
||||
assert stats.failed_count == 0
|
||||
assert stats.total_downloaded_mb == 0.0
|
||||
|
||||
|
||||
class TestQueueControl:
|
||||
"""Test queue control operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_queue(self, download_service):
|
||||
"""Test pausing the queue."""
|
||||
await download_service.pause_queue()
|
||||
assert download_service._is_paused is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_queue(self, download_service):
|
||||
"""Test resuming the queue."""
|
||||
await download_service.pause_queue()
|
||||
await download_service.resume_queue()
|
||||
assert download_service._is_paused is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_completed(self, download_service):
|
||||
"""Test clearing completed downloads."""
|
||||
# Manually add completed item
|
||||
completed_item = DownloadItem(
|
||||
id="completed-1",
|
||||
serie_id="series-1",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=1),
|
||||
status=DownloadStatus.COMPLETED,
|
||||
)
|
||||
download_service._completed_items.append(completed_item)
|
||||
|
||||
count = await download_service.clear_completed()
|
||||
|
||||
assert count == 1
|
||||
assert len(download_service._completed_items) == 0
|
||||
|
||||
|
||||
class TestPersistence:
|
||||
"""Test queue persistence functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queue_persistence(self, download_service):
|
||||
"""Test that queue state is persisted to disk."""
|
||||
await download_service.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_name="Test Series",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
|
||||
# Persistence file should exist
|
||||
persistence_path = Path(download_service._persistence_path)
|
||||
assert persistence_path.exists()
|
||||
|
||||
# Check file contents
|
||||
with open(persistence_path, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert len(data["pending"]) == 1
|
||||
assert data["pending"][0]["serie_id"] == "series-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queue_recovery_after_restart(
|
||||
self, mock_anime_service, temp_persistence_path
|
||||
):
|
||||
"""Test that queue is recovered after service restart."""
|
||||
# Create and populate first service
|
||||
service1 = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
persistence_path=temp_persistence_path,
|
||||
)
|
||||
|
||||
await service1.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_name="Test Series",
|
||||
episodes=[
|
||||
EpisodeIdentifier(season=1, episode=1),
|
||||
EpisodeIdentifier(season=1, episode=2),
|
||||
],
|
||||
)
|
||||
|
||||
# Create new service with same persistence path
|
||||
service2 = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
persistence_path=temp_persistence_path,
|
||||
)
|
||||
|
||||
# Should recover pending items
|
||||
assert len(service2._pending_queue) == 2
|
||||
|
||||
|
||||
class TestRetryLogic:
|
||||
"""Test retry logic for failed downloads."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_failed_items(self, download_service):
|
||||
"""Test retrying failed downloads."""
|
||||
# Manually add failed item
|
||||
failed_item = DownloadItem(
|
||||
id="failed-1",
|
||||
serie_id="series-1",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=1),
|
||||
status=DownloadStatus.FAILED,
|
||||
retry_count=0,
|
||||
error="Test error",
|
||||
)
|
||||
download_service._failed_items.append(failed_item)
|
||||
|
||||
retried_ids = await download_service.retry_failed()
|
||||
|
||||
assert len(retried_ids) == 1
|
||||
assert len(download_service._failed_items) == 0
|
||||
assert len(download_service._pending_queue) == 1
|
||||
assert download_service._pending_queue[0].retry_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_retries_not_exceeded(self, download_service):
|
||||
"""Test that items with max retries are not retried."""
|
||||
# Create item with max retries
|
||||
failed_item = DownloadItem(
|
||||
id="failed-1",
|
||||
serie_id="series-1",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=1),
|
||||
status=DownloadStatus.FAILED,
|
||||
retry_count=3, # Max retries
|
||||
error="Test error",
|
||||
)
|
||||
download_service._failed_items.append(failed_item)
|
||||
|
||||
retried_ids = await download_service.retry_failed()
|
||||
|
||||
assert len(retried_ids) == 0
|
||||
assert len(download_service._failed_items) == 1
|
||||
assert len(download_service._pending_queue) == 0
|
||||
|
||||
|
||||
class TestBroadcastCallbacks:
|
||||
"""Test WebSocket broadcast functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_broadcast_callback(self, download_service):
|
||||
"""Test setting broadcast callback."""
|
||||
mock_callback = AsyncMock()
|
||||
download_service.set_broadcast_callback(mock_callback)
|
||||
|
||||
assert download_service._broadcast_callback == mock_callback
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_on_queue_update(self, download_service):
|
||||
"""Test that broadcasts are sent on queue updates."""
|
||||
mock_callback = AsyncMock()
|
||||
download_service.set_broadcast_callback(mock_callback)
|
||||
|
||||
await download_service.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_name="Test Series",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
|
||||
# Allow async callback to execute
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Verify callback was called
|
||||
mock_callback.assert_called()
|
||||
|
||||
|
||||
class TestServiceLifecycle:
|
||||
"""Test service start and stop operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_service(self, download_service):
|
||||
"""Test starting the service."""
|
||||
await download_service.start()
|
||||
assert download_service._is_running is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_service(self, download_service):
|
||||
"""Test stopping the service."""
|
||||
await download_service.start()
|
||||
await download_service.stop()
|
||||
assert download_service._is_running is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_already_running(self, download_service):
|
||||
"""Test starting service when already running."""
|
||||
await download_service.start()
|
||||
await download_service.start() # Should not raise error
|
||||
assert download_service._is_running is True
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test error handling in download service."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reorder_nonexistent_item(self, download_service):
|
||||
"""Test reordering non-existent item raises error."""
|
||||
with pytest.raises(DownloadServiceError):
|
||||
await download_service.reorder_queue("nonexistent-id", 0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_failure_moves_to_failed(self, download_service):
|
||||
"""Test that download failures are handled correctly."""
|
||||
# Mock download to fail
|
||||
download_service._anime_service.download = AsyncMock(
|
||||
side_effect=Exception("Download failed")
|
||||
)
|
||||
|
||||
await download_service.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_name="Test Series",
|
||||
episodes=[EpisodeIdentifier(season=1, episode=1)],
|
||||
)
|
||||
|
||||
# Process the download
|
||||
item = download_service._pending_queue.popleft()
|
||||
await download_service._process_download(item)
|
||||
|
||||
# Item should be in failed queue
|
||||
assert len(download_service._failed_items) == 1
|
||||
assert (
|
||||
download_service._failed_items[0].status == DownloadStatus.FAILED
|
||||
)
|
||||
assert download_service._failed_items[0].error is not None
|
||||
Reference in New Issue
Block a user