Aniworld/instructions.md
Lukas 3b516c0e24 Complete download queue SQLite migration: documentation and cleanup
- Updated infrastructure.md with queue database schema and storage details
- Updated instructions.md to mark migration task as completed
- No deprecated JSON code remains in codebase
2025-12-02 16:08:37 +01:00

33 KiB

Aniworld Web Application Development Instructions

This document provides detailed tasks for AI agents to implement a modern web application for the Aniworld anime download manager. All tasks should follow the coding guidelines specified in the project's copilot instructions.

Project Overview

The goal is to create a FastAPI-based web application that provides a modern interface for the existing Aniworld anime download functionality. The core anime logic should remain in SeriesApp.py while the web layer provides REST API endpoints and a responsive UI.

Architecture Principles

  • Single Responsibility: Each file/class has one clear purpose
  • Dependency Injection: Use FastAPI's dependency system
  • Clean Separation: Web layer calls core logic, never the reverse
  • File Size Limit: Maximum 500 lines per file
  • Type Hints: Use comprehensive type annotations
  • Error Handling: Proper exception handling and logging

Additional Implementation Guidelines

Code Style and Standards

  • Type Hints: Use comprehensive type annotations throughout all modules
  • Docstrings: Follow PEP 257 for function and class documentation
  • Error Handling: Implement custom exception classes with meaningful messages
  • Logging: Use structured logging with appropriate log levels
  • Security: Validate all inputs and sanitize outputs
  • Performance: Use async/await patterns for I/O operations

📞 Escalation

If you encounter:

  • Architecture issues requiring design decisions
  • Tests that conflict with documented requirements
  • Breaking changes needed
  • Unclear requirements or expectations

Document the issue and escalate rather than guessing.


📚 Helpful Commands

# Run all tests
conda run -n AniWorld python -m pytest tests/ -v --tb=short

# Run specific test file
conda run -n AniWorld python -m pytest tests/unit/test_websocket_service.py -v

# Run specific test class
conda run -n AniWorld python -m pytest tests/unit/test_websocket_service.py::TestWebSocketService -v

# Run specific test
conda run -n AniWorld python -m pytest tests/unit/test_websocket_service.py::TestWebSocketService::test_broadcast_download_progress -v

# Run with extra verbosity
conda run -n AniWorld python -m pytest tests/ -vv

# Run with full traceback
conda run -n AniWorld python -m pytest tests/ -v --tb=long

# Run and stop at first failure
conda run -n AniWorld python -m pytest tests/ -v -x

# Run tests matching pattern
conda run -n AniWorld python -m pytest tests/ -v -k "auth"

# Show all print statements
conda run -n AniWorld python -m pytest tests/ -v -s

#Run app
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload

Implementation Notes

  1. Incremental Development: Implement features incrementally, testing each component thoroughly before moving to the next
  2. Code Review: Review all generated code for adherence to project standards
  3. Documentation: Document all public APIs and complex logic
  4. Testing: Maintain test coverage above 80% for all new code
  5. Performance: Profile and optimize critical paths, especially download and streaming operations
  6. Security: Regular security audits and dependency updates
  7. Monitoring: Implement comprehensive monitoring and alerting
  8. Maintenance: Plan for regular maintenance and updates

Task Completion Checklist

For each task completed:

  • Implementation follows coding standards
  • Unit tests written and passing
  • Integration tests passing
  • Documentation updated
  • Error handling implemented
  • Logging added
  • Security considerations addressed
  • Performance validated
  • Code reviewed
  • Task marked as complete in instructions.md
  • Infrastructure.md updated
  • Changes committed to git; keep your messages in git short and clear
  • Take the next task

Prerequisites

  1. Server is running: conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload
  2. Password: Hallo123!
  3. Login via browser at http://127.0.0.1:8000/login

Notes

  • This is a simplification that removes complexity while maintaining core functionality
  • Improves user experience with explicit manual control
  • Easier to understand, test, and maintain
  • Good foundation for future enhancements if needed

Completed: Download Queue Migration to SQLite Database

The download queue has been successfully migrated from JSON file to SQLite database:

Component Status Description
QueueRepository Done src/server/services/queue_repository.py
DownloadService Done Refactored to use repository pattern
Application Startup Done Queue restored from database on startup
API Endpoints Done All endpoints work with database-backed queue
Tests Updated Done All 1104 tests passing with MockQueueRepository
Documentation Updated Done infrastructure.md updated with new architecture

Key Changes:

  • DownloadService no longer uses persistence_path parameter
  • Queue state is persisted to SQLite via QueueRepository
  • In-memory cache maintained for performance
  • All tests use MockQueueRepository fixture

🧪 Tests for Download Queue Database Migration

Unit Tests

File: tests/unit/test_queue_repository.py

"""Unit tests for QueueRepository database adapter."""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import datetime, timezone

from src.server.services.queue_repository import QueueRepository
from src.server.models.download import DownloadItem, DownloadStatus, DownloadPriority
from src.server.database.models import DownloadQueueItem as DBDownloadQueueItem


class TestQueueRepository:
    """Test suite for QueueRepository."""

    @pytest.fixture
    def mock_db_session(self):
        """Create mock database session."""
        session = AsyncMock()
        return session

    @pytest.fixture
    def repository(self, mock_db_session):
        """Create repository instance with mock session."""
        return QueueRepository(db_session_factory=lambda: mock_db_session)

    @pytest.fixture
    def sample_download_item(self):
        """Create sample DownloadItem for testing."""
        return DownloadItem(
            id="test-uuid-123",
            series_key="attack-on-titan",
            series_name="Attack on Titan",
            season=1,
            episode=5,
            status=DownloadStatus.PENDING,
            priority=DownloadPriority.NORMAL,
            progress_percent=0.0,
            downloaded_bytes=0,
            total_bytes=None,
        )

    # === Conversion Tests ===

    async def test_convert_to_db_model(self, repository, sample_download_item):
        """Test converting DownloadItem to database model."""
        # Arrange
        series_id = 42

        # Act
        db_item = repository._to_db_model(sample_download_item, series_id)

        # Assert
        assert db_item.series_id == series_id
        assert db_item.season == sample_download_item.season
        assert db_item.episode_number == sample_download_item.episode
        assert db_item.status == sample_download_item.status
        assert db_item.priority == sample_download_item.priority

    async def test_convert_from_db_model(self, repository):
        """Test converting database model to DownloadItem."""
        # Arrange
        db_item = MagicMock()
        db_item.id = 1
        db_item.series_id = 42
        db_item.series.key = "attack-on-titan"
        db_item.series.name = "Attack on Titan"
        db_item.season = 1
        db_item.episode_number = 5
        db_item.status = DownloadStatus.PENDING
        db_item.priority = DownloadPriority.NORMAL
        db_item.progress_percent = 25.5
        db_item.downloaded_bytes = 1024000
        db_item.total_bytes = 4096000

        # Act
        item = repository._from_db_model(db_item)

        # Assert
        assert item.series_key == "attack-on-titan"
        assert item.series_name == "Attack on Titan"
        assert item.season == 1
        assert item.episode == 5
        assert item.progress_percent == 25.5

    # === CRUD Operation Tests ===

    async def test_save_item_creates_new_record(self, repository, mock_db_session, sample_download_item):
        """Test saving a new download item to database."""
        # Arrange
        mock_db_session.execute.return_value.scalar_one_or_none.return_value = MagicMock(id=42)

        # Act
        result = await repository.save_item(sample_download_item)

        # Assert
        mock_db_session.add.assert_called_once()
        mock_db_session.flush.assert_called_once()
        assert result is not None

    async def test_get_pending_items_returns_ordered_list(self, repository, mock_db_session):
        """Test retrieving pending items ordered by priority."""
        # Arrange
        mock_items = [MagicMock(), MagicMock()]
        mock_db_session.execute.return_value.scalars.return_value.all.return_value = mock_items

        # Act
        result = await repository.get_pending_items()

        # Assert
        assert len(result) == 2
        mock_db_session.execute.assert_called_once()

    async def test_update_status_success(self, repository, mock_db_session):
        """Test updating item status."""
        # Arrange
        mock_item = MagicMock()
        mock_db_session.execute.return_value.scalar_one_or_none.return_value = mock_item

        # Act
        result = await repository.update_status("test-id", DownloadStatus.DOWNLOADING)

        # Assert
        assert result is True
        assert mock_item.status == DownloadStatus.DOWNLOADING

    async def test_update_status_item_not_found(self, repository, mock_db_session):
        """Test updating status for non-existent item."""
        # Arrange
        mock_db_session.execute.return_value.scalar_one_or_none.return_value = None

        # Act
        result = await repository.update_status("non-existent", DownloadStatus.DOWNLOADING)

        # Assert
        assert result is False

    async def test_update_progress(self, repository, mock_db_session):
        """Test updating download progress."""
        # Arrange
        mock_item = MagicMock()
        mock_db_session.execute.return_value.scalar_one_or_none.return_value = mock_item

        # Act
        result = await repository.update_progress(
            item_id="test-id",
            progress=50.0,
            downloaded=2048000,
            total=4096000,
            speed=1024000.0
        )

        # Assert
        assert result is True
        assert mock_item.progress_percent == 50.0
        assert mock_item.downloaded_bytes == 2048000

    async def test_delete_item_success(self, repository, mock_db_session):
        """Test deleting download item."""
        # Arrange
        mock_db_session.execute.return_value.rowcount = 1

        # Act
        result = await repository.delete_item("test-id")

        # Assert
        assert result is True

    async def test_clear_completed_returns_count(self, repository, mock_db_session):
        """Test clearing completed items returns count."""
        # Arrange
        mock_db_session.execute.return_value.rowcount = 5

        # Act
        result = await repository.clear_completed()

        # Assert
        assert result == 5


class TestQueueRepositoryErrorHandling:
    """Test error handling in QueueRepository."""

    @pytest.fixture
    def mock_db_session(self):
        """Create mock database session."""
        return AsyncMock()

    @pytest.fixture
    def repository(self, mock_db_session):
        """Create repository instance."""
        return QueueRepository(db_session_factory=lambda: mock_db_session)

    async def test_save_item_handles_database_error(self, repository, mock_db_session):
        """Test handling database errors on save."""
        # Arrange
        mock_db_session.execute.side_effect = Exception("Database connection failed")

        # Act & Assert
        with pytest.raises(Exception):
            await repository.save_item(MagicMock())

    async def test_get_items_handles_database_error(self, repository, mock_db_session):
        """Test handling database errors on query."""
        # Arrange
        mock_db_session.execute.side_effect = Exception("Query failed")

        # Act & Assert
        with pytest.raises(Exception):
            await repository.get_pending_items()

File: tests/unit/test_download_service_database.py

"""Unit tests for DownloadService with database persistence."""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import datetime, timezone

from src.server.services.download_service import DownloadService
from src.server.models.download import DownloadItem, DownloadStatus, DownloadPriority


class TestDownloadServiceDatabasePersistence:
    """Test DownloadService database persistence."""

    @pytest.fixture
    def mock_anime_service(self):
        """Create mock anime service."""
        return AsyncMock()

    @pytest.fixture
    def mock_queue_repository(self):
        """Create mock queue repository."""
        repo = AsyncMock()
        repo.get_pending_items.return_value = []
        repo.get_active_item.return_value = None
        repo.get_completed_items.return_value = []
        repo.get_failed_items.return_value = []
        return repo

    @pytest.fixture
    def download_service(self, mock_anime_service, mock_queue_repository):
        """Create download service with mocked dependencies."""
        return DownloadService(
            anime_service=mock_anime_service,
            queue_repository=mock_queue_repository,
        )

    # === Persistence Tests ===

    async def test_add_to_queue_saves_to_database(
        self, download_service, mock_queue_repository
    ):
        """Test that adding to queue persists to database."""
        # Arrange
        mock_queue_repository.save_item.return_value = MagicMock(id="new-id")

        # Act
        result = await download_service.add_to_queue(
            series_key="test-series",
            season=1,
            episode=1,
        )

        # Assert
        mock_queue_repository.save_item.assert_called_once()

    async def test_startup_loads_from_database(
        self, mock_anime_service, mock_queue_repository
    ):
        """Test that startup loads queue state from database."""
        # Arrange
        pending_items = [
            MagicMock(id="1", status=DownloadStatus.PENDING),
            MagicMock(id="2", status=DownloadStatus.PENDING),
        ]
        mock_queue_repository.get_pending_items.return_value = pending_items

        # Act
        service = DownloadService(
            anime_service=mock_anime_service,
            queue_repository=mock_queue_repository,
        )
        await service.initialize()

        # Assert
        mock_queue_repository.get_pending_items.assert_called()

    async def test_download_completion_updates_database(
        self, download_service, mock_queue_repository
    ):
        """Test that download completion updates database status."""
        # Arrange
        item = MagicMock(id="test-id")

        # Act
        await download_service._mark_completed(item)

        # Assert
        mock_queue_repository.update_status.assert_called_with(
            "test-id", DownloadStatus.COMPLETED, error=None
        )

    async def test_download_failure_updates_database(
        self, download_service, mock_queue_repository
    ):
        """Test that download failure updates database with error."""
        # Arrange
        item = MagicMock(id="test-id")
        error_message = "Network timeout"

        # Act
        await download_service._mark_failed(item, error_message)

        # Assert
        mock_queue_repository.update_status.assert_called_with(
            "test-id", DownloadStatus.FAILED, error=error_message
        )

    async def test_progress_update_persists_to_database(
        self, download_service, mock_queue_repository
    ):
        """Test that progress updates are persisted."""
        # Arrange
        item = MagicMock(id="test-id")

        # Act
        await download_service._update_progress(
            item, progress=50.0, downloaded=2048, total=4096, speed=1024.0
        )

        # Assert
        mock_queue_repository.update_progress.assert_called_with(
            item_id="test-id",
            progress=50.0,
            downloaded=2048,
            total=4096,
            speed=1024.0,
        )

    async def test_remove_from_queue_deletes_from_database(
        self, download_service, mock_queue_repository
    ):
        """Test that removing from queue deletes from database."""
        # Arrange
        mock_queue_repository.delete_item.return_value = True

        # Act
        result = await download_service.remove_from_queue("test-id")

        # Assert
        mock_queue_repository.delete_item.assert_called_with("test-id")
        assert result is True

    async def test_clear_completed_clears_database(
        self, download_service, mock_queue_repository
    ):
        """Test that clearing completed items updates database."""
        # Arrange
        mock_queue_repository.clear_completed.return_value = 5

        # Act
        result = await download_service.clear_completed()

        # Assert
        mock_queue_repository.clear_completed.assert_called_once()
        assert result == 5


class TestDownloadServiceNoJsonFile:
    """Verify DownloadService no longer uses JSON files."""

    async def test_no_json_file_operations(self):
        """Verify no JSON file read/write operations exist."""
        import inspect
        from src.server.services.download_service import DownloadService

        source = inspect.getsource(DownloadService)

        # Assert no JSON file operations
        assert "download_queue.json" not in source
        assert "_load_queue" not in source or "database" in source.lower()
        assert "_save_queue" not in source or "database" in source.lower()

Integration Tests

File: tests/integration/test_queue_database_integration.py

"""Integration tests for download queue database operations."""
import pytest
from datetime import datetime, timezone

from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker

from src.server.database.base import Base
from src.server.database.models import AnimeSeries, DownloadQueueItem, DownloadStatus, DownloadPriority
from src.server.database.service import DownloadQueueService, AnimeSeriesService
from src.server.services.queue_repository import QueueRepository


@pytest.fixture
async def async_engine():
    """Create async test database engine."""
    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    await engine.dispose()


@pytest.fixture
async def async_session(async_engine):
    """Create async session for tests."""
    async_session_maker = sessionmaker(
        async_engine, class_=AsyncSession, expire_on_commit=False
    )
    async with async_session_maker() as session:
        yield session
        await session.rollback()


@pytest.fixture
async def test_series(async_session):
    """Create test anime series."""
    series = await AnimeSeriesService.create(
        db=async_session,
        key="test-anime",
        name="Test Anime",
        site="https://example.com/test-anime",
        folder="Test Anime (2024)",
    )
    await async_session.commit()
    return series


class TestQueueDatabaseIntegration:
    """Integration tests for queue database operations."""

    async def test_create_and_retrieve_queue_item(self, async_session, test_series):
        """Test creating and retrieving a queue item."""
        # Create
        item = await DownloadQueueService.create(
            db=async_session,
            series_id=test_series.id,
            season=1,
            episode_number=5,
            priority=DownloadPriority.HIGH,
        )
        await async_session.commit()

        # Retrieve
        retrieved = await DownloadQueueService.get_by_id(async_session, item.id)

        # Assert
        assert retrieved is not None
        assert retrieved.series_id == test_series.id
        assert retrieved.season == 1
        assert retrieved.episode_number == 5
        assert retrieved.priority == DownloadPriority.HIGH
        assert retrieved.status == DownloadStatus.PENDING

    async def test_update_download_progress(self, async_session, test_series):
        """Test updating download progress."""
        # Create item
        item = await DownloadQueueService.create(
            db=async_session,
            series_id=test_series.id,
            season=1,
            episode_number=1,
        )
        await async_session.commit()

        # Update progress
        updated = await DownloadQueueService.update_progress(
            db=async_session,
            item_id=item.id,
            progress_percent=75.5,
            downloaded_bytes=3072000,
            total_bytes=4096000,
            download_speed=1024000.0,
        )
        await async_session.commit()

        # Assert
        assert updated.progress_percent == 75.5
        assert updated.downloaded_bytes == 3072000
        assert updated.total_bytes == 4096000
        assert updated.download_speed == 1024000.0

    async def test_status_transitions(self, async_session, test_series):
        """Test download status transitions."""
        # Create pending item
        item = await DownloadQueueService.create(
            db=async_session,
            series_id=test_series.id,
            season=1,
            episode_number=1,
        )
        await async_session.commit()
        assert item.status == DownloadStatus.PENDING

        # Transition to downloading
        item = await DownloadQueueService.update_status(
            async_session, item.id, DownloadStatus.DOWNLOADING
        )
        await async_session.commit()
        assert item.status == DownloadStatus.DOWNLOADING
        assert item.started_at is not None

        # Transition to completed
        item = await DownloadQueueService.update_status(
            async_session, item.id, DownloadStatus.COMPLETED
        )
        await async_session.commit()
        assert item.status == DownloadStatus.COMPLETED
        assert item.completed_at is not None

    async def test_failed_download_with_retry(self, async_session, test_series):
        """Test failed download with error message and retry count."""
        # Create item
        item = await DownloadQueueService.create(
            db=async_session,
            series_id=test_series.id,
            season=1,
            episode_number=1,
        )
        await async_session.commit()

        # Mark as failed with error
        item = await DownloadQueueService.update_status(
            async_session,
            item.id,
            DownloadStatus.FAILED,
            error_message="Connection timeout",
        )
        await async_session.commit()

        # Assert
        assert item.status == DownloadStatus.FAILED
        assert item.error_message == "Connection timeout"
        assert item.retry_count == 1

    async def test_get_pending_items_ordered_by_priority(self, async_session, test_series):
        """Test retrieving pending items ordered by priority."""
        # Create items with different priorities
        await DownloadQueueService.create(
            async_session, test_series.id, 1, 1, priority=DownloadPriority.LOW
        )
        await DownloadQueueService.create(
            async_session, test_series.id, 1, 2, priority=DownloadPriority.HIGH
        )
        await DownloadQueueService.create(
            async_session, test_series.id, 1, 3, priority=DownloadPriority.NORMAL
        )
        await async_session.commit()

        # Get pending items
        pending = await DownloadQueueService.get_pending(async_session)

        # Assert order: HIGH -> NORMAL -> LOW
        assert len(pending) == 3
        assert pending[0].priority == DownloadPriority.HIGH
        assert pending[1].priority == DownloadPriority.NORMAL
        assert pending[2].priority == DownloadPriority.LOW

    async def test_clear_completed_items(self, async_session, test_series):
        """Test clearing completed download items."""
        # Create items
        item1 = await DownloadQueueService.create(
            async_session, test_series.id, 1, 1
        )
        item2 = await DownloadQueueService.create(
            async_session, test_series.id, 1, 2
        )
        item3 = await DownloadQueueService.create(
            async_session, test_series.id, 1, 3
        )

        # Complete first two
        await DownloadQueueService.update_status(
            async_session, item1.id, DownloadStatus.COMPLETED
        )
        await DownloadQueueService.update_status(
            async_session, item2.id, DownloadStatus.COMPLETED
        )
        await async_session.commit()

        # Clear completed
        cleared = await DownloadQueueService.clear_completed(async_session)
        await async_session.commit()

        # Assert
        assert cleared == 2

        # Verify pending item remains
        remaining = await DownloadQueueService.get_all(async_session)
        assert len(remaining) == 1
        assert remaining[0].id == item3.id

    async def test_cascade_delete_with_series(self, async_session, test_series):
        """Test that queue items are deleted when series is deleted."""
        # Create queue items
        await DownloadQueueService.create(
            async_session, test_series.id, 1, 1
        )
        await DownloadQueueService.create(
            async_session, test_series.id, 1, 2
        )
        await async_session.commit()

        # Delete series
        await AnimeSeriesService.delete(async_session, test_series.id)
        await async_session.commit()

        # Verify queue items are gone
        all_items = await DownloadQueueService.get_all(async_session)
        assert len(all_items) == 0

API Tests

File: tests/api/test_queue_endpoints_database.py

"""API tests for queue endpoints with database persistence."""
import pytest
from httpx import AsyncClient
from unittest.mock import patch, AsyncMock


class TestQueueAPIWithDatabase:
    """Test queue API endpoints with database backend."""

    @pytest.fixture
    def auth_headers(self):
        """Get authentication headers."""
        return {"Authorization": "Bearer test-token"}

    async def test_get_queue_returns_database_items(
        self, client: AsyncClient, auth_headers
    ):
        """Test GET /api/queue returns items from database."""
        response = await client.get("/api/queue", headers=auth_headers)

        assert response.status_code == 200
        data = response.json()
        assert "pending" in data
        assert "active" in data
        assert "completed" in data

    async def test_add_to_queue_persists_to_database(
        self, client: AsyncClient, auth_headers
    ):
        """Test POST /api/queue persists item to database."""
        payload = {
            "series_key": "test-anime",
            "season": 1,
            "episode": 1,
            "priority": "normal",
        }

        response = await client.post(
            "/api/queue",
            json=payload,
            headers=auth_headers,
        )

        assert response.status_code == 201
        data = response.json()
        assert "id" in data

    async def test_remove_from_queue_deletes_from_database(
        self, client: AsyncClient, auth_headers
    ):
        """Test DELETE /api/queue/{id} removes from database."""
        # First add an item
        add_response = await client.post(
            "/api/queue",
            json={"series_key": "test-anime", "season": 1, "episode": 1},
            headers=auth_headers,
        )
        item_id = add_response.json()["id"]

        # Then delete it
        response = await client.delete(
            f"/api/queue/{item_id}",
            headers=auth_headers,
        )

        assert response.status_code == 200

        # Verify it's gone
        get_response = await client.get("/api/queue", headers=auth_headers)
        queue_data = get_response.json()
        item_ids = [item["id"] for item in queue_data.get("pending", [])]
        assert item_id not in item_ids

    async def test_queue_survives_server_restart(
        self, client: AsyncClient, auth_headers
    ):
        """Test that queue items persist across simulated restart."""
        # Add item
        add_response = await client.post(
            "/api/queue",
            json={"series_key": "test-anime", "season": 1, "episode": 5},
            headers=auth_headers,
        )
        item_id = add_response.json()["id"]

        # Simulate restart by clearing in-memory cache
        # (In real scenario, this would be a server restart)

        # Verify item still exists
        response = await client.get("/api/queue", headers=auth_headers)
        queue_data = response.json()
        item_ids = [item["id"] for item in queue_data.get("pending", [])]
        assert item_id in item_ids

    async def test_clear_completed_endpoint(
        self, client: AsyncClient, auth_headers
    ):
        """Test POST /api/queue/clear-completed endpoint."""
        response = await client.post(
            "/api/queue/clear-completed",
            headers=auth_headers,
        )

        assert response.status_code == 200
        data = response.json()
        assert "cleared_count" in data

Performance Tests

File: tests/performance/test_queue_database_performance.py

"""Performance tests for database-backed download queue."""
import pytest
import asyncio
import time
from datetime import datetime


class TestQueueDatabasePerformance:
    """Performance tests for queue database operations."""

    @pytest.mark.performance
    async def test_bulk_insert_performance(self, async_session, test_series):
        """Test performance of bulk queue item insertion."""
        from src.server.database.service import DownloadQueueService

        start_time = time.time()

        # Insert 100 queue items
        for i in range(100):
            await DownloadQueueService.create(
                async_session,
                test_series.id,
                season=1,
                episode_number=i + 1,
            )
        await async_session.commit()

        elapsed = time.time() - start_time

        # Should complete in under 2 seconds
        assert elapsed < 2.0, f"Bulk insert took {elapsed:.2f}s, expected < 2s"

    @pytest.mark.performance
    async def test_query_performance_with_many_items(self, async_session, test_series):
        """Test query performance with many queue items."""
        from src.server.database.service import DownloadQueueService

        # Setup: Create 500 items
        for i in range(500):
            await DownloadQueueService.create(
                async_session,
                test_series.id,
                season=(i // 12) + 1,
                episode_number=(i % 12) + 1,
            )
        await async_session.commit()

        # Test query performance
        start_time = time.time()

        pending = await DownloadQueueService.get_pending(async_session)

        elapsed = time.time() - start_time

        # Query should complete in under 100ms
        assert elapsed < 0.1, f"Query took {elapsed*1000:.1f}ms, expected < 100ms"
        assert len(pending) == 500

    @pytest.mark.performance
    async def test_progress_update_performance(self, async_session, test_series):
        """Test performance of frequent progress updates."""
        from src.server.database.service import DownloadQueueService

        # Create item
        item = await DownloadQueueService.create(
            async_session, test_series.id, 1, 1
        )
        await async_session.commit()

        start_time = time.time()

        # Simulate 100 progress updates (like during download)
        for i in range(100):
            await DownloadQueueService.update_progress(
                async_session,
                item.id,
                progress_percent=i,
                downloaded_bytes=i * 10240,
                total_bytes=1024000,
                download_speed=102400.0,
            )
        await async_session.commit()

        elapsed = time.time() - start_time

        # 100 updates should complete in under 1 second
        assert elapsed < 1.0, f"Progress updates took {elapsed:.2f}s, expected < 1s"

Summary

These tasks will migrate the download queue from JSON file persistence to SQLite database, providing:

  1. Data Integrity: ACID-compliant storage with proper relationships
  2. Query Capability: Efficient filtering, sorting, and pagination
  3. Consistency: Single source of truth for all application data
  4. Scalability: Better performance for large queues
  5. Recovery: Robust handling of crashes and restarts

The existing database infrastructure (DownloadQueueItem model and DownloadQueueService) is already in place, making this primarily an integration task rather than new development.