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:
@@ -72,11 +72,14 @@ async def anime_service(mock_series_app, progress_service):
|
||||
|
||||
@pytest.fixture
|
||||
async def download_service(anime_service, progress_service):
|
||||
"""Create a DownloadService."""
|
||||
"""Create a DownloadService with mock queue repository."""
|
||||
from tests.unit.test_download_service import MockQueueRepository
|
||||
|
||||
mock_repo = MockQueueRepository()
|
||||
service = DownloadService(
|
||||
anime_service=anime_service,
|
||||
progress_service=progress_service,
|
||||
persistence_path="/tmp/test_integration_progress_queue.json",
|
||||
queue_repository=mock_repo,
|
||||
)
|
||||
yield service
|
||||
await service.stop()
|
||||
|
||||
@@ -88,9 +88,10 @@ def progress_service():
|
||||
|
||||
@pytest.fixture
|
||||
async def download_service(mock_series_app, progress_service, tmp_path):
|
||||
"""Create a DownloadService with dependencies."""
|
||||
import uuid
|
||||
persistence_path = tmp_path / f"test_queue_{uuid.uuid4()}.json"
|
||||
"""Create a DownloadService with mock repository for testing."""
|
||||
from tests.unit.test_download_service import MockQueueRepository
|
||||
|
||||
mock_repo = MockQueueRepository()
|
||||
|
||||
anime_service = AnimeService(
|
||||
series_app=mock_series_app,
|
||||
@@ -101,7 +102,7 @@ async def download_service(mock_series_app, progress_service, tmp_path):
|
||||
service = DownloadService(
|
||||
anime_service=anime_service,
|
||||
progress_service=progress_service,
|
||||
persistence_path=str(persistence_path),
|
||||
queue_repository=mock_repo,
|
||||
)
|
||||
yield service
|
||||
await service.stop()
|
||||
@@ -319,8 +320,6 @@ class TestServiceIdentifierConsistency:
|
||||
- Persisted data contains serie_id (key)
|
||||
- Data can be restored with correct identifiers
|
||||
"""
|
||||
import json
|
||||
|
||||
# Add item to queue
|
||||
await download_service.add_to_queue(
|
||||
serie_id="jujutsu-kaisen",
|
||||
@@ -330,18 +329,13 @@ class TestServiceIdentifierConsistency:
|
||||
priority=DownloadPriority.NORMAL,
|
||||
)
|
||||
|
||||
# Read persisted data
|
||||
persistence_path = download_service._persistence_path
|
||||
with open(persistence_path, "r") as f:
|
||||
data = json.load(f)
|
||||
# Verify item is in pending queue (in-memory cache synced with DB)
|
||||
pending_items = list(download_service._pending_queue)
|
||||
assert len(pending_items) == 1
|
||||
|
||||
# Verify persisted data structure
|
||||
assert "pending" in data
|
||||
assert len(data["pending"]) == 1
|
||||
|
||||
persisted_item = data["pending"][0]
|
||||
assert persisted_item["serie_id"] == "jujutsu-kaisen"
|
||||
assert persisted_item["serie_folder"] == "Jujutsu Kaisen (2020)"
|
||||
persisted_item = pending_items[0]
|
||||
assert persisted_item.serie_id == "jujutsu-kaisen"
|
||||
assert persisted_item.serie_folder == "Jujutsu Kaisen (2020)"
|
||||
|
||||
|
||||
class TestWebSocketIdentifierConsistency:
|
||||
|
||||
@@ -69,16 +69,17 @@ async def anime_service(mock_series_app, progress_service):
|
||||
|
||||
@pytest.fixture
|
||||
async def download_service(anime_service, progress_service, tmp_path):
|
||||
"""Create a DownloadService with dependencies.
|
||||
"""Create a DownloadService with mock repository for testing.
|
||||
|
||||
Uses tmp_path to ensure each test has isolated queue storage.
|
||||
Uses mock repository to ensure each test has isolated queue storage.
|
||||
"""
|
||||
import uuid
|
||||
persistence_path = tmp_path / f"test_queue_{uuid.uuid4()}.json"
|
||||
from tests.unit.test_download_service import MockQueueRepository
|
||||
|
||||
mock_repo = MockQueueRepository()
|
||||
service = DownloadService(
|
||||
anime_service=anime_service,
|
||||
progress_service=progress_service,
|
||||
persistence_path=str(persistence_path),
|
||||
queue_repository=mock_repo,
|
||||
)
|
||||
yield service, progress_service
|
||||
await service.stop()
|
||||
|
||||
@@ -28,12 +28,13 @@ class TestDownloadQueueStress:
|
||||
|
||||
@pytest.fixture
|
||||
def download_service(self, mock_anime_service, tmp_path):
|
||||
"""Create download service with mock."""
|
||||
persistence_path = str(tmp_path / "test_queue.json")
|
||||
"""Create download service with mock repository."""
|
||||
from tests.unit.test_download_service import MockQueueRepository
|
||||
mock_repo = MockQueueRepository()
|
||||
service = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
max_retries=3,
|
||||
persistence_path=persistence_path,
|
||||
queue_repository=mock_repo,
|
||||
)
|
||||
return service
|
||||
|
||||
@@ -176,12 +177,13 @@ class TestDownloadMemoryUsage:
|
||||
|
||||
@pytest.fixture
|
||||
def download_service(self, mock_anime_service, tmp_path):
|
||||
"""Create download service with mock."""
|
||||
persistence_path = str(tmp_path / "test_queue.json")
|
||||
"""Create download service with mock repository."""
|
||||
from tests.unit.test_download_service import MockQueueRepository
|
||||
mock_repo = MockQueueRepository()
|
||||
service = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
max_retries=3,
|
||||
persistence_path=persistence_path,
|
||||
queue_repository=mock_repo,
|
||||
)
|
||||
return service
|
||||
|
||||
@@ -232,12 +234,13 @@ class TestDownloadConcurrency:
|
||||
|
||||
@pytest.fixture
|
||||
def download_service(self, mock_anime_service, tmp_path):
|
||||
"""Create download service with mock."""
|
||||
persistence_path = str(tmp_path / "test_queue.json")
|
||||
"""Create download service with mock repository."""
|
||||
from tests.unit.test_download_service import MockQueueRepository
|
||||
mock_repo = MockQueueRepository()
|
||||
service = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
max_retries=3,
|
||||
persistence_path=persistence_path,
|
||||
queue_repository=mock_repo,
|
||||
)
|
||||
return service
|
||||
|
||||
@@ -321,11 +324,12 @@ class TestDownloadErrorHandling:
|
||||
self, mock_failing_anime_service, tmp_path
|
||||
):
|
||||
"""Create download service with failing mock."""
|
||||
persistence_path = str(tmp_path / "test_queue.json")
|
||||
from tests.unit.test_download_service import MockQueueRepository
|
||||
mock_repo = MockQueueRepository()
|
||||
service = DownloadService(
|
||||
anime_service=mock_failing_anime_service,
|
||||
max_retries=3,
|
||||
persistence_path=persistence_path,
|
||||
queue_repository=mock_repo,
|
||||
)
|
||||
return service
|
||||
|
||||
@@ -338,12 +342,13 @@ class TestDownloadErrorHandling:
|
||||
|
||||
@pytest.fixture
|
||||
def download_service(self, mock_anime_service, tmp_path):
|
||||
"""Create download service with mock."""
|
||||
persistence_path = str(tmp_path / "test_queue.json")
|
||||
"""Create download service with mock repository."""
|
||||
from tests.unit.test_download_service import MockQueueRepository
|
||||
mock_repo = MockQueueRepository()
|
||||
service = DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
max_retries=3,
|
||||
persistence_path=persistence_path,
|
||||
queue_repository=mock_repo,
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
@@ -102,27 +102,20 @@ async def anime_service(mock_series_app, progress_service):
|
||||
|
||||
@pytest.fixture
|
||||
async def download_service(anime_service, progress_service):
|
||||
"""Create a DownloadService with dependencies."""
|
||||
import os
|
||||
persistence_path = "/tmp/test_download_progress_queue.json"
|
||||
"""Create a DownloadService with mock repository for testing."""
|
||||
from tests.unit.test_download_service import MockQueueRepository
|
||||
|
||||
# Remove any existing queue file
|
||||
if os.path.exists(persistence_path):
|
||||
os.remove(persistence_path)
|
||||
mock_repo = MockQueueRepository()
|
||||
|
||||
service = DownloadService(
|
||||
anime_service=anime_service,
|
||||
progress_service=progress_service,
|
||||
persistence_path=persistence_path,
|
||||
queue_repository=mock_repo,
|
||||
)
|
||||
|
||||
yield service, progress_service
|
||||
|
||||
await service.stop()
|
||||
|
||||
# Clean up after test
|
||||
if os.path.exists(persistence_path):
|
||||
os.remove(persistence_path)
|
||||
|
||||
|
||||
class TestDownloadProgressWebSocket:
|
||||
|
||||
@@ -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