Migrate download queue from JSON to SQLite database
- Created QueueRepository adapter in src/server/services/queue_repository.py - Refactored DownloadService to use repository pattern instead of JSON - Updated application startup to initialize download service from database - Updated all test fixtures to use MockQueueRepository - All 1104 tests passing
This commit is contained in:
@@ -1,14 +1,13 @@
|
||||
"""Unit tests for the download queue service.
|
||||
|
||||
Tests cover queue management, manual download control, persistence,
|
||||
Tests cover queue management, manual download control, database persistence,
|
||||
and error scenarios for the simplified download service.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -20,7 +19,125 @@ from src.server.models.download import (
|
||||
EpisodeIdentifier,
|
||||
)
|
||||
from src.server.services.anime_service import AnimeService
|
||||
from src.server.services.download_service import DownloadService, DownloadServiceError
|
||||
from src.server.services.download_service import (
|
||||
DownloadService,
|
||||
DownloadServiceError,
|
||||
)
|
||||
|
||||
|
||||
class MockQueueRepository:
|
||||
"""Mock implementation of QueueRepository for testing.
|
||||
|
||||
This provides an in-memory storage that mimics the database repository
|
||||
behavior without requiring actual database connections.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize mock repository with in-memory storage."""
|
||||
self._items: Dict[str, DownloadItem] = {}
|
||||
|
||||
async def save_item(self, item: DownloadItem) -> DownloadItem:
|
||||
"""Save item to in-memory storage."""
|
||||
self._items[item.id] = item
|
||||
return item
|
||||
|
||||
async def get_item(self, item_id: str) -> Optional[DownloadItem]:
|
||||
"""Get item by ID from in-memory storage."""
|
||||
return self._items.get(item_id)
|
||||
|
||||
async def get_pending_items(self) -> List[DownloadItem]:
|
||||
"""Get all pending items."""
|
||||
return [
|
||||
item for item in self._items.values()
|
||||
if item.status == DownloadStatus.PENDING
|
||||
]
|
||||
|
||||
async def get_active_item(self) -> Optional[DownloadItem]:
|
||||
"""Get the currently active item."""
|
||||
for item in self._items.values():
|
||||
if item.status == DownloadStatus.DOWNLOADING:
|
||||
return item
|
||||
return None
|
||||
|
||||
async def get_completed_items(
|
||||
self, limit: int = 100
|
||||
) -> List[DownloadItem]:
|
||||
"""Get completed items."""
|
||||
completed = [
|
||||
item for item in self._items.values()
|
||||
if item.status == DownloadStatus.COMPLETED
|
||||
]
|
||||
return completed[:limit]
|
||||
|
||||
async def get_failed_items(self, limit: int = 50) -> List[DownloadItem]:
|
||||
"""Get failed items."""
|
||||
failed = [
|
||||
item for item in self._items.values()
|
||||
if item.status == DownloadStatus.FAILED
|
||||
]
|
||||
return failed[:limit]
|
||||
|
||||
async def update_status(
|
||||
self,
|
||||
item_id: str,
|
||||
status: DownloadStatus,
|
||||
error: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Update item status."""
|
||||
if item_id not in self._items:
|
||||
return False
|
||||
self._items[item_id].status = status
|
||||
if error:
|
||||
self._items[item_id].error = error
|
||||
if status == DownloadStatus.COMPLETED:
|
||||
self._items[item_id].completed_at = datetime.now(timezone.utc)
|
||||
elif status == DownloadStatus.DOWNLOADING:
|
||||
self._items[item_id].started_at = datetime.now(timezone.utc)
|
||||
return True
|
||||
|
||||
async def update_progress(
|
||||
self,
|
||||
item_id: str,
|
||||
progress: float,
|
||||
downloaded: int,
|
||||
total: int,
|
||||
speed: float
|
||||
) -> bool:
|
||||
"""Update download progress."""
|
||||
if item_id not in self._items:
|
||||
return False
|
||||
item = self._items[item_id]
|
||||
if item.progress is None:
|
||||
from src.server.models.download import DownloadProgress
|
||||
item.progress = DownloadProgress(
|
||||
percent=progress,
|
||||
downloaded_bytes=downloaded,
|
||||
total_bytes=total,
|
||||
speed_bps=speed
|
||||
)
|
||||
else:
|
||||
item.progress.percent = progress
|
||||
item.progress.downloaded_bytes = downloaded
|
||||
item.progress.total_bytes = total
|
||||
item.progress.speed_bps = speed
|
||||
return True
|
||||
|
||||
async def delete_item(self, item_id: str) -> bool:
|
||||
"""Delete item from storage."""
|
||||
if item_id in self._items:
|
||||
del self._items[item_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
async def clear_completed(self) -> int:
|
||||
"""Clear all completed items."""
|
||||
completed_ids = [
|
||||
item_id for item_id, item in self._items.items()
|
||||
if item.status == DownloadStatus.COMPLETED
|
||||
]
|
||||
for item_id in completed_ids:
|
||||
del self._items[item_id]
|
||||
return len(completed_ids)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -32,18 +149,18 @@ def mock_anime_service():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_persistence_path(tmp_path):
|
||||
"""Create a temporary persistence path."""
|
||||
return str(tmp_path / "test_queue.json")
|
||||
def mock_queue_repository():
|
||||
"""Create a mock QueueRepository for testing."""
|
||||
return MockQueueRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def download_service(mock_anime_service, temp_persistence_path):
|
||||
def download_service(mock_anime_service, mock_queue_repository):
|
||||
"""Create a DownloadService instance for testing."""
|
||||
return DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
queue_repository=mock_queue_repository,
|
||||
max_retries=3,
|
||||
persistence_path=temp_persistence_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -51,12 +168,12 @@ class TestDownloadServiceInitialization:
|
||||
"""Test download service initialization."""
|
||||
|
||||
def test_initialization_creates_queues(
|
||||
self, mock_anime_service, temp_persistence_path
|
||||
self, mock_anime_service, mock_queue_repository
|
||||
):
|
||||
"""Test that initialization creates empty queues."""
|
||||
service = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
persistence_path=temp_persistence_path,
|
||||
queue_repository=mock_queue_repository,
|
||||
)
|
||||
|
||||
assert len(service._pending_queue) == 0
|
||||
@@ -65,45 +182,30 @@ class TestDownloadServiceInitialization:
|
||||
assert len(service._failed_items) == 0
|
||||
assert service._is_stopped is True
|
||||
|
||||
def test_initialization_loads_persisted_queue(
|
||||
self, mock_anime_service, temp_persistence_path
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialization_loads_persisted_queue(
|
||||
self, mock_anime_service, mock_queue_repository
|
||||
):
|
||||
"""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_folder": "test-series", # Added missing field
|
||||
"serie_name": "Test Series",
|
||||
"episode": {"season": 1, "episode": 1, "title": None},
|
||||
"status": "pending",
|
||||
"priority": "NORMAL", # Must be uppercase
|
||||
"added_at": datetime.now(timezone.utc).isoformat(),
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
"progress": None,
|
||||
"error": None,
|
||||
"retry_count": 0,
|
||||
"source_url": None,
|
||||
}
|
||||
],
|
||||
"active": [],
|
||||
"failed": [],
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
with open(persistence_file, "w", encoding="utf-8") as f:
|
||||
json.dump(test_data, f)
|
||||
"""Test that initialization loads persisted queue from database."""
|
||||
# Pre-populate the mock repository with a pending item
|
||||
test_item = DownloadItem(
|
||||
id="test-id-1",
|
||||
serie_id="series-1",
|
||||
serie_folder="test-series",
|
||||
serie_name="Test Series",
|
||||
episode=EpisodeIdentifier(season=1, episode=1),
|
||||
status=DownloadStatus.PENDING,
|
||||
priority=DownloadPriority.NORMAL,
|
||||
added_at=datetime.now(timezone.utc),
|
||||
)
|
||||
await mock_queue_repository.save_item(test_item)
|
||||
|
||||
# Create service and initialize from database
|
||||
service = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
persistence_path=temp_persistence_path,
|
||||
queue_repository=mock_queue_repository,
|
||||
)
|
||||
await service.initialize()
|
||||
|
||||
assert len(service._pending_queue) == 1
|
||||
assert service._pending_queue[0].id == "test-id-1"
|
||||
@@ -391,11 +493,13 @@ class TestQueueControl:
|
||||
|
||||
|
||||
class TestPersistence:
|
||||
"""Test queue persistence functionality."""
|
||||
"""Test queue persistence functionality with database backend."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queue_persistence(self, download_service):
|
||||
"""Test that queue state is persisted to disk."""
|
||||
async def test_queue_persistence(
|
||||
self, download_service, mock_queue_repository
|
||||
):
|
||||
"""Test that queue state is persisted to database."""
|
||||
await download_service.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_folder="series",
|
||||
@@ -403,26 +507,20 @@ class TestPersistence:
|
||||
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"
|
||||
# Item should be saved in mock repository
|
||||
pending_items = await mock_queue_repository.get_pending_items()
|
||||
assert len(pending_items) == 1
|
||||
assert pending_items[0].serie_id == "series-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queue_recovery_after_restart(
|
||||
self, mock_anime_service, temp_persistence_path
|
||||
self, mock_anime_service, mock_queue_repository
|
||||
):
|
||||
"""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,
|
||||
queue_repository=mock_queue_repository,
|
||||
)
|
||||
|
||||
await service1.add_to_queue(
|
||||
@@ -435,11 +533,13 @@ class TestPersistence:
|
||||
],
|
||||
)
|
||||
|
||||
# Create new service with same persistence path
|
||||
# Create new service with same repository (simulating restart)
|
||||
service2 = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
persistence_path=temp_persistence_path,
|
||||
queue_repository=mock_queue_repository,
|
||||
)
|
||||
# Initialize to load from database to recover state
|
||||
await service2.initialize()
|
||||
|
||||
# Should recover pending items
|
||||
assert len(service2._pending_queue) == 2
|
||||
|
||||
Reference in New Issue
Block a user