feat: implement download queue Pydantic models
- Add comprehensive download queue models in src/server/models/download.py - DownloadStatus and DownloadPriority enums for type safety - EpisodeIdentifier for episode references - DownloadProgress for real-time progress tracking - DownloadItem for queue item representation with timestamps and error handling - QueueStatus for overall queue state management - QueueStats for aggregated queue statistics - DownloadRequest/DownloadResponse for API contracts - QueueOperationRequest and QueueReorderRequest for queue management - QueueStatusResponse for complete status endpoint responses - Add comprehensive unit tests (47 tests, all passing) - Test validation constraints (positive numbers, ranges, etc.) - Test default values and optional fields - Test serialization/deserialization - Test model relationships and nested structures - Update documentation - Add download models section to infrastructure.md - Remove completed task from instructions.md - Update models package __init__.py All models follow PEP 8 style guide with proper type hints and validation.
This commit is contained in:
parent
d0f63063ca
commit
1ba4336291
@ -326,3 +326,20 @@ Notes:
|
|||||||
validation errors are logged to the centralized logging system. For
|
validation errors are logged to the centralized logging system. For
|
||||||
high-throughput routes, consider response model caching at the
|
high-throughput routes, consider response model caching at the
|
||||||
application or reverse-proxy layer.
|
application or reverse-proxy layer.
|
||||||
|
|
||||||
|
### Download Queue Models
|
||||||
|
|
||||||
|
- Download queue models in `src/server/models/download.py` define the data
|
||||||
|
structures for the download queue system.
|
||||||
|
- Key models include:
|
||||||
|
- `DownloadItem`: Represents a single queued download with metadata,
|
||||||
|
progress tracking, and error information
|
||||||
|
- `QueueStatus`: Overall queue state with active, pending, completed,
|
||||||
|
and failed downloads
|
||||||
|
- `QueueStats`: Aggregated statistics for monitoring queue performance
|
||||||
|
- `DownloadProgress`: Real-time progress information (percent, speed,
|
||||||
|
ETA)
|
||||||
|
- `DownloadRequest`/`DownloadResponse`: API request/response contracts
|
||||||
|
- Models enforce validation constraints (e.g., positive episode numbers,
|
||||||
|
progress percentage 0-100, non-negative retry counts) and provide
|
||||||
|
clean JSON serialization for API endpoints and WebSocket updates.
|
||||||
|
|||||||
@ -45,13 +45,6 @@ The tasks should be completed in the following order to ensure proper dependenci
|
|||||||
|
|
||||||
### 5. Download Queue Management
|
### 5. Download Queue Management
|
||||||
|
|
||||||
#### [] Implement download queue models
|
|
||||||
|
|
||||||
- []Create `src/server/models/download.py`
|
|
||||||
- []Define DownloadItem, QueueStatus models
|
|
||||||
- []Add DownloadProgress, QueueStats models
|
|
||||||
- []Include DownloadRequest model
|
|
||||||
|
|
||||||
#### [] Create download queue service
|
#### [] Create download queue service
|
||||||
|
|
||||||
- []Create `src/server/services/download_service.py`
|
- []Create `src/server/services/download_service.py`
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"""Models package for server-side Pydantic models."""
|
"""Models package for server-side Pydantic models."""
|
||||||
|
|
||||||
__all__ = ["auth"]
|
__all__ = ["auth", "anime", "config", "download"]
|
||||||
|
|||||||
207
src/server/models/download.py
Normal file
207
src/server/models/download.py
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
"""Download queue Pydantic models for the Aniworld web application.
|
||||||
|
|
||||||
|
This module defines request/response models used by the download queue API
|
||||||
|
and the download service. Models are intentionally lightweight and focused
|
||||||
|
on serialization, validation, and OpenAPI documentation.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, HttpUrl
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadStatus(str, Enum):
|
||||||
|
"""Status of a download item in the queue."""
|
||||||
|
|
||||||
|
PENDING = "pending"
|
||||||
|
DOWNLOADING = "downloading"
|
||||||
|
PAUSED = "paused"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadPriority(str, Enum):
|
||||||
|
"""Priority level for download queue items."""
|
||||||
|
|
||||||
|
LOW = "low"
|
||||||
|
NORMAL = "normal"
|
||||||
|
HIGH = "high"
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeIdentifier(BaseModel):
|
||||||
|
"""Episode identification information for a download item."""
|
||||||
|
|
||||||
|
season: int = Field(..., ge=1, description="Season number (1-based)")
|
||||||
|
episode: int = Field(
|
||||||
|
..., ge=1, description="Episode number within season (1-based)"
|
||||||
|
)
|
||||||
|
title: Optional[str] = Field(None, description="Episode title if known")
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadProgress(BaseModel):
|
||||||
|
"""Real-time progress information for an active download."""
|
||||||
|
|
||||||
|
percent: float = Field(
|
||||||
|
0.0, ge=0.0, le=100.0, description="Download progress percentage"
|
||||||
|
)
|
||||||
|
downloaded_mb: float = Field(
|
||||||
|
0.0, ge=0.0, description="Downloaded size in megabytes"
|
||||||
|
)
|
||||||
|
total_mb: Optional[float] = Field(
|
||||||
|
None, ge=0.0, description="Total size in megabytes if known"
|
||||||
|
)
|
||||||
|
speed_mbps: Optional[float] = Field(
|
||||||
|
None, ge=0.0, description="Download speed in MB/s"
|
||||||
|
)
|
||||||
|
eta_seconds: Optional[int] = Field(
|
||||||
|
None, ge=0, description="Estimated time remaining in seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadItem(BaseModel):
|
||||||
|
"""Represents a single download item in the queue."""
|
||||||
|
|
||||||
|
id: str = Field(..., description="Unique download item identifier")
|
||||||
|
serie_id: str = Field(..., description="Series identifier")
|
||||||
|
serie_name: str = Field(..., min_length=1, description="Series name")
|
||||||
|
episode: EpisodeIdentifier = Field(
|
||||||
|
..., description="Episode identification"
|
||||||
|
)
|
||||||
|
status: DownloadStatus = Field(
|
||||||
|
DownloadStatus.PENDING, description="Current download status"
|
||||||
|
)
|
||||||
|
priority: DownloadPriority = Field(
|
||||||
|
DownloadPriority.NORMAL, description="Queue priority"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
added_at: datetime = Field(
|
||||||
|
default_factory=datetime.utcnow,
|
||||||
|
description="When item was added to queue"
|
||||||
|
)
|
||||||
|
started_at: Optional[datetime] = Field(
|
||||||
|
None, description="When download started"
|
||||||
|
)
|
||||||
|
completed_at: Optional[datetime] = Field(
|
||||||
|
None, description="When download completed/failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Progress tracking
|
||||||
|
progress: Optional[DownloadProgress] = Field(
|
||||||
|
None, description="Current progress if downloading"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
error: Optional[str] = Field(None, description="Error message if failed")
|
||||||
|
retry_count: int = Field(0, ge=0, description="Number of retry attempts")
|
||||||
|
|
||||||
|
# Download source
|
||||||
|
source_url: Optional[HttpUrl] = Field(
|
||||||
|
None, description="Source URL for download"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QueueStatus(BaseModel):
|
||||||
|
"""Overall status of the download queue system."""
|
||||||
|
|
||||||
|
is_running: bool = Field(
|
||||||
|
False, description="Whether the queue processor is running"
|
||||||
|
)
|
||||||
|
is_paused: bool = Field(False, description="Whether downloads are paused")
|
||||||
|
active_downloads: List[DownloadItem] = Field(
|
||||||
|
default_factory=list, description="Currently downloading items"
|
||||||
|
)
|
||||||
|
pending_queue: List[DownloadItem] = Field(
|
||||||
|
default_factory=list, description="Items waiting to be downloaded"
|
||||||
|
)
|
||||||
|
completed_downloads: List[DownloadItem] = Field(
|
||||||
|
default_factory=list, description="Recently completed downloads"
|
||||||
|
)
|
||||||
|
failed_downloads: List[DownloadItem] = Field(
|
||||||
|
default_factory=list, description="Failed download items"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QueueStats(BaseModel):
|
||||||
|
"""Statistics about the download queue."""
|
||||||
|
|
||||||
|
total_items: int = Field(
|
||||||
|
0, ge=0, description="Total number of items in all queues"
|
||||||
|
)
|
||||||
|
pending_count: int = Field(0, ge=0, description="Number of pending items")
|
||||||
|
active_count: int = Field(
|
||||||
|
0, ge=0, description="Number of active downloads"
|
||||||
|
)
|
||||||
|
completed_count: int = Field(
|
||||||
|
0, ge=0, description="Number of completed downloads"
|
||||||
|
)
|
||||||
|
failed_count: int = Field(
|
||||||
|
0, ge=0, description="Number of failed downloads"
|
||||||
|
)
|
||||||
|
|
||||||
|
total_downloaded_mb: float = Field(
|
||||||
|
0.0, ge=0.0, description="Total megabytes downloaded"
|
||||||
|
)
|
||||||
|
average_speed_mbps: Optional[float] = Field(
|
||||||
|
None, ge=0.0, description="Average download speed in MB/s"
|
||||||
|
)
|
||||||
|
estimated_time_remaining: Optional[int] = Field(
|
||||||
|
None, ge=0, description="Estimated time to complete queue in seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadRequest(BaseModel):
|
||||||
|
"""Request to add episode(s) to the download queue."""
|
||||||
|
|
||||||
|
serie_id: str = Field(..., description="Series identifier")
|
||||||
|
serie_name: str = Field(
|
||||||
|
..., min_length=1, description="Series name for display"
|
||||||
|
)
|
||||||
|
episodes: List[EpisodeIdentifier] = Field(
|
||||||
|
..., min_length=1, description="List of episodes to download"
|
||||||
|
)
|
||||||
|
priority: DownloadPriority = Field(
|
||||||
|
DownloadPriority.NORMAL, description="Priority level for queue items"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadResponse(BaseModel):
|
||||||
|
"""Response after adding items to the download queue."""
|
||||||
|
|
||||||
|
status: str = Field(..., description="Status of the request")
|
||||||
|
message: str = Field(..., description="Human-readable status message")
|
||||||
|
added_items: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="IDs of successfully added download items"
|
||||||
|
)
|
||||||
|
failed_items: List[str] = Field(
|
||||||
|
default_factory=list, description="Episodes that failed to be added"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QueueOperationRequest(BaseModel):
|
||||||
|
"""Request to perform operations on queue items."""
|
||||||
|
|
||||||
|
item_ids: List[str] = Field(
|
||||||
|
..., min_length=1, description="List of download item IDs"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QueueReorderRequest(BaseModel):
|
||||||
|
"""Request to reorder items in the pending queue."""
|
||||||
|
|
||||||
|
item_id: str = Field(..., description="Download item ID to move")
|
||||||
|
new_position: int = Field(
|
||||||
|
..., ge=0, description="New position in queue (0-based)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QueueStatusResponse(BaseModel):
|
||||||
|
"""Complete response for queue status endpoint."""
|
||||||
|
|
||||||
|
status: QueueStatus = Field(..., description="Current queue status")
|
||||||
|
statistics: QueueStats = Field(..., description="Queue statistics")
|
||||||
550
tests/unit/test_download_models.py
Normal file
550
tests/unit/test_download_models.py
Normal file
@ -0,0 +1,550 @@
|
|||||||
|
"""Unit tests for download queue Pydantic models.
|
||||||
|
|
||||||
|
This module tests all download-related models including validation,
|
||||||
|
serialization, and field constraints.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from src.server.models.download import (
|
||||||
|
DownloadItem,
|
||||||
|
DownloadPriority,
|
||||||
|
DownloadProgress,
|
||||||
|
DownloadRequest,
|
||||||
|
DownloadResponse,
|
||||||
|
DownloadStatus,
|
||||||
|
EpisodeIdentifier,
|
||||||
|
QueueOperationRequest,
|
||||||
|
QueueReorderRequest,
|
||||||
|
QueueStats,
|
||||||
|
QueueStatus,
|
||||||
|
QueueStatusResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownloadStatus:
|
||||||
|
"""Test DownloadStatus enum."""
|
||||||
|
|
||||||
|
def test_all_statuses_exist(self):
|
||||||
|
"""Test that all expected statuses are defined."""
|
||||||
|
assert DownloadStatus.PENDING == "pending"
|
||||||
|
assert DownloadStatus.DOWNLOADING == "downloading"
|
||||||
|
assert DownloadStatus.PAUSED == "paused"
|
||||||
|
assert DownloadStatus.COMPLETED == "completed"
|
||||||
|
assert DownloadStatus.FAILED == "failed"
|
||||||
|
assert DownloadStatus.CANCELLED == "cancelled"
|
||||||
|
|
||||||
|
def test_status_values(self):
|
||||||
|
"""Test that status values are lowercase strings."""
|
||||||
|
for status in DownloadStatus:
|
||||||
|
assert isinstance(status.value, str)
|
||||||
|
assert status.value.islower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownloadPriority:
|
||||||
|
"""Test DownloadPriority enum."""
|
||||||
|
|
||||||
|
def test_all_priorities_exist(self):
|
||||||
|
"""Test that all expected priorities are defined."""
|
||||||
|
assert DownloadPriority.LOW == "low"
|
||||||
|
assert DownloadPriority.NORMAL == "normal"
|
||||||
|
assert DownloadPriority.HIGH == "high"
|
||||||
|
|
||||||
|
def test_priority_values(self):
|
||||||
|
"""Test that priority values are lowercase strings."""
|
||||||
|
for priority in DownloadPriority:
|
||||||
|
assert isinstance(priority.value, str)
|
||||||
|
assert priority.value.islower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestEpisodeIdentifier:
|
||||||
|
"""Test EpisodeIdentifier model."""
|
||||||
|
|
||||||
|
def test_valid_episode_identifier(self):
|
||||||
|
"""Test creating a valid episode identifier."""
|
||||||
|
episode = EpisodeIdentifier(
|
||||||
|
season=1,
|
||||||
|
episode=5,
|
||||||
|
title="Test Episode"
|
||||||
|
)
|
||||||
|
assert episode.season == 1
|
||||||
|
assert episode.episode == 5
|
||||||
|
assert episode.title == "Test Episode"
|
||||||
|
|
||||||
|
def test_episode_identifier_without_title(self):
|
||||||
|
"""Test creating episode identifier without title."""
|
||||||
|
episode = EpisodeIdentifier(season=2, episode=10)
|
||||||
|
assert episode.season == 2
|
||||||
|
assert episode.episode == 10
|
||||||
|
assert episode.title is None
|
||||||
|
|
||||||
|
def test_invalid_season_number(self):
|
||||||
|
"""Test that season must be positive."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
EpisodeIdentifier(season=0, episode=1)
|
||||||
|
errors = exc_info.value.errors()
|
||||||
|
assert any("season" in str(e["loc"]) for e in errors)
|
||||||
|
|
||||||
|
def test_invalid_episode_number(self):
|
||||||
|
"""Test that episode must be positive."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
EpisodeIdentifier(season=1, episode=0)
|
||||||
|
errors = exc_info.value.errors()
|
||||||
|
assert any("episode" in str(e["loc"]) for e in errors)
|
||||||
|
|
||||||
|
def test_negative_season_rejected(self):
|
||||||
|
"""Test that negative season is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
EpisodeIdentifier(season=-1, episode=1)
|
||||||
|
|
||||||
|
def test_negative_episode_rejected(self):
|
||||||
|
"""Test that negative episode is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
EpisodeIdentifier(season=1, episode=-1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownloadProgress:
|
||||||
|
"""Test DownloadProgress model."""
|
||||||
|
|
||||||
|
def test_valid_progress(self):
|
||||||
|
"""Test creating valid progress information."""
|
||||||
|
progress = DownloadProgress(
|
||||||
|
percent=45.5,
|
||||||
|
downloaded_mb=100.0,
|
||||||
|
total_mb=220.0,
|
||||||
|
speed_mbps=5.5,
|
||||||
|
eta_seconds=120
|
||||||
|
)
|
||||||
|
assert progress.percent == 45.5
|
||||||
|
assert progress.downloaded_mb == 100.0
|
||||||
|
assert progress.total_mb == 220.0
|
||||||
|
assert progress.speed_mbps == 5.5
|
||||||
|
assert progress.eta_seconds == 120
|
||||||
|
|
||||||
|
def test_progress_defaults(self):
|
||||||
|
"""Test default values for progress."""
|
||||||
|
progress = DownloadProgress()
|
||||||
|
assert progress.percent == 0.0
|
||||||
|
assert progress.downloaded_mb == 0.0
|
||||||
|
assert progress.total_mb is None
|
||||||
|
assert progress.speed_mbps is None
|
||||||
|
assert progress.eta_seconds is None
|
||||||
|
|
||||||
|
def test_percent_range_validation(self):
|
||||||
|
"""Test that percent must be between 0 and 100."""
|
||||||
|
# Valid boundary values
|
||||||
|
DownloadProgress(percent=0.0)
|
||||||
|
DownloadProgress(percent=100.0)
|
||||||
|
|
||||||
|
# Invalid values
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
DownloadProgress(percent=-0.1)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
DownloadProgress(percent=100.1)
|
||||||
|
|
||||||
|
def test_negative_downloaded_mb_rejected(self):
|
||||||
|
"""Test that negative downloaded_mb is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
DownloadProgress(downloaded_mb=-1.0)
|
||||||
|
|
||||||
|
def test_negative_total_mb_rejected(self):
|
||||||
|
"""Test that negative total_mb is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
DownloadProgress(total_mb=-1.0)
|
||||||
|
|
||||||
|
def test_negative_speed_rejected(self):
|
||||||
|
"""Test that negative speed is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
DownloadProgress(speed_mbps=-1.0)
|
||||||
|
|
||||||
|
def test_negative_eta_rejected(self):
|
||||||
|
"""Test that negative ETA is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
DownloadProgress(eta_seconds=-1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownloadItem:
|
||||||
|
"""Test DownloadItem model."""
|
||||||
|
|
||||||
|
def test_valid_download_item(self):
|
||||||
|
"""Test creating a valid download item."""
|
||||||
|
episode = EpisodeIdentifier(season=1, episode=5)
|
||||||
|
item = DownloadItem(
|
||||||
|
id="download_123",
|
||||||
|
serie_id="serie_456",
|
||||||
|
serie_name="Test Series",
|
||||||
|
episode=episode,
|
||||||
|
status=DownloadStatus.PENDING,
|
||||||
|
priority=DownloadPriority.HIGH
|
||||||
|
)
|
||||||
|
assert item.id == "download_123"
|
||||||
|
assert item.serie_id == "serie_456"
|
||||||
|
assert item.serie_name == "Test Series"
|
||||||
|
assert item.episode == episode
|
||||||
|
assert item.status == DownloadStatus.PENDING
|
||||||
|
assert item.priority == DownloadPriority.HIGH
|
||||||
|
|
||||||
|
def test_download_item_defaults(self):
|
||||||
|
"""Test default values for download item."""
|
||||||
|
episode = EpisodeIdentifier(season=1, episode=1)
|
||||||
|
item = DownloadItem(
|
||||||
|
id="test_id",
|
||||||
|
serie_id="serie_id",
|
||||||
|
serie_name="Test",
|
||||||
|
episode=episode
|
||||||
|
)
|
||||||
|
assert item.status == DownloadStatus.PENDING
|
||||||
|
assert item.priority == DownloadPriority.NORMAL
|
||||||
|
assert item.started_at is None
|
||||||
|
assert item.completed_at is None
|
||||||
|
assert item.progress is None
|
||||||
|
assert item.error is None
|
||||||
|
assert item.retry_count == 0
|
||||||
|
assert item.source_url is None
|
||||||
|
|
||||||
|
def test_download_item_with_progress(self):
|
||||||
|
"""Test download item with progress information."""
|
||||||
|
episode = EpisodeIdentifier(season=1, episode=1)
|
||||||
|
progress = DownloadProgress(percent=50.0, downloaded_mb=100.0)
|
||||||
|
item = DownloadItem(
|
||||||
|
id="test_id",
|
||||||
|
serie_id="serie_id",
|
||||||
|
serie_name="Test",
|
||||||
|
episode=episode,
|
||||||
|
progress=progress
|
||||||
|
)
|
||||||
|
assert item.progress is not None
|
||||||
|
assert item.progress.percent == 50.0
|
||||||
|
|
||||||
|
def test_download_item_with_timestamps(self):
|
||||||
|
"""Test download item with timestamp fields."""
|
||||||
|
episode = EpisodeIdentifier(season=1, episode=1)
|
||||||
|
now = datetime.utcnow()
|
||||||
|
item = DownloadItem(
|
||||||
|
id="test_id",
|
||||||
|
serie_id="serie_id",
|
||||||
|
serie_name="Test",
|
||||||
|
episode=episode,
|
||||||
|
started_at=now,
|
||||||
|
completed_at=now + timedelta(minutes=5)
|
||||||
|
)
|
||||||
|
assert item.started_at == now
|
||||||
|
assert item.completed_at == now + timedelta(minutes=5)
|
||||||
|
|
||||||
|
def test_empty_serie_name_rejected(self):
|
||||||
|
"""Test that empty serie name is rejected."""
|
||||||
|
episode = EpisodeIdentifier(season=1, episode=1)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
DownloadItem(
|
||||||
|
id="test_id",
|
||||||
|
serie_id="serie_id",
|
||||||
|
serie_name="",
|
||||||
|
episode=episode
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_negative_retry_count_rejected(self):
|
||||||
|
"""Test that negative retry count is rejected."""
|
||||||
|
episode = EpisodeIdentifier(season=1, episode=1)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
DownloadItem(
|
||||||
|
id="test_id",
|
||||||
|
serie_id="serie_id",
|
||||||
|
serie_name="Test",
|
||||||
|
episode=episode,
|
||||||
|
retry_count=-1
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_added_at_auto_generated(self):
|
||||||
|
"""Test that added_at is automatically set."""
|
||||||
|
episode = EpisodeIdentifier(season=1, episode=1)
|
||||||
|
before = datetime.utcnow()
|
||||||
|
item = DownloadItem(
|
||||||
|
id="test_id",
|
||||||
|
serie_id="serie_id",
|
||||||
|
serie_name="Test",
|
||||||
|
episode=episode
|
||||||
|
)
|
||||||
|
after = datetime.utcnow()
|
||||||
|
assert before <= item.added_at <= after
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueueStatus:
|
||||||
|
"""Test QueueStatus model."""
|
||||||
|
|
||||||
|
def test_valid_queue_status(self):
|
||||||
|
"""Test creating valid queue status."""
|
||||||
|
episode = EpisodeIdentifier(season=1, episode=1)
|
||||||
|
item = DownloadItem(
|
||||||
|
id="test_id",
|
||||||
|
serie_id="serie_id",
|
||||||
|
serie_name="Test",
|
||||||
|
episode=episode
|
||||||
|
)
|
||||||
|
status = QueueStatus(
|
||||||
|
is_running=True,
|
||||||
|
is_paused=False,
|
||||||
|
active_downloads=[item],
|
||||||
|
pending_queue=[item],
|
||||||
|
completed_downloads=[],
|
||||||
|
failed_downloads=[]
|
||||||
|
)
|
||||||
|
assert status.is_running is True
|
||||||
|
assert status.is_paused is False
|
||||||
|
assert len(status.active_downloads) == 1
|
||||||
|
assert len(status.pending_queue) == 1
|
||||||
|
|
||||||
|
def test_queue_status_defaults(self):
|
||||||
|
"""Test default values for queue status."""
|
||||||
|
status = QueueStatus()
|
||||||
|
assert status.is_running is False
|
||||||
|
assert status.is_paused is False
|
||||||
|
assert status.active_downloads == []
|
||||||
|
assert status.pending_queue == []
|
||||||
|
assert status.completed_downloads == []
|
||||||
|
assert status.failed_downloads == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueueStats:
|
||||||
|
"""Test QueueStats model."""
|
||||||
|
|
||||||
|
def test_valid_queue_stats(self):
|
||||||
|
"""Test creating valid queue statistics."""
|
||||||
|
stats = QueueStats(
|
||||||
|
total_items=10,
|
||||||
|
pending_count=3,
|
||||||
|
active_count=2,
|
||||||
|
completed_count=4,
|
||||||
|
failed_count=1,
|
||||||
|
total_downloaded_mb=500.5,
|
||||||
|
average_speed_mbps=5.0,
|
||||||
|
estimated_time_remaining=120
|
||||||
|
)
|
||||||
|
assert stats.total_items == 10
|
||||||
|
assert stats.pending_count == 3
|
||||||
|
assert stats.active_count == 2
|
||||||
|
assert stats.completed_count == 4
|
||||||
|
assert stats.failed_count == 1
|
||||||
|
assert stats.total_downloaded_mb == 500.5
|
||||||
|
assert stats.average_speed_mbps == 5.0
|
||||||
|
assert stats.estimated_time_remaining == 120
|
||||||
|
|
||||||
|
def test_queue_stats_defaults(self):
|
||||||
|
"""Test default values for queue stats."""
|
||||||
|
stats = QueueStats()
|
||||||
|
assert stats.total_items == 0
|
||||||
|
assert stats.pending_count == 0
|
||||||
|
assert stats.active_count == 0
|
||||||
|
assert stats.completed_count == 0
|
||||||
|
assert stats.failed_count == 0
|
||||||
|
assert stats.total_downloaded_mb == 0.0
|
||||||
|
assert stats.average_speed_mbps is None
|
||||||
|
assert stats.estimated_time_remaining is None
|
||||||
|
|
||||||
|
def test_negative_counts_rejected(self):
|
||||||
|
"""Test that negative counts are rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
QueueStats(total_items=-1)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
QueueStats(pending_count=-1)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
QueueStats(active_count=-1)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
QueueStats(completed_count=-1)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
QueueStats(failed_count=-1)
|
||||||
|
|
||||||
|
def test_negative_speed_rejected(self):
|
||||||
|
"""Test that negative speed is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
QueueStats(average_speed_mbps=-1.0)
|
||||||
|
|
||||||
|
def test_negative_eta_rejected(self):
|
||||||
|
"""Test that negative ETA is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
QueueStats(estimated_time_remaining=-1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownloadRequest:
|
||||||
|
"""Test DownloadRequest model."""
|
||||||
|
|
||||||
|
def test_valid_download_request(self):
|
||||||
|
"""Test creating a valid download request."""
|
||||||
|
episode1 = EpisodeIdentifier(season=1, episode=1)
|
||||||
|
episode2 = EpisodeIdentifier(season=1, episode=2)
|
||||||
|
request = DownloadRequest(
|
||||||
|
serie_id="serie_123",
|
||||||
|
serie_name="Test Series",
|
||||||
|
episodes=[episode1, episode2],
|
||||||
|
priority=DownloadPriority.HIGH
|
||||||
|
)
|
||||||
|
assert request.serie_id == "serie_123"
|
||||||
|
assert request.serie_name == "Test Series"
|
||||||
|
assert len(request.episodes) == 2
|
||||||
|
assert request.priority == DownloadPriority.HIGH
|
||||||
|
|
||||||
|
def test_download_request_default_priority(self):
|
||||||
|
"""Test default priority for download request."""
|
||||||
|
episode = EpisodeIdentifier(season=1, episode=1)
|
||||||
|
request = DownloadRequest(
|
||||||
|
serie_id="serie_123",
|
||||||
|
serie_name="Test Series",
|
||||||
|
episodes=[episode]
|
||||||
|
)
|
||||||
|
assert request.priority == DownloadPriority.NORMAL
|
||||||
|
|
||||||
|
def test_empty_episodes_list_rejected(self):
|
||||||
|
"""Test that empty episodes list is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
DownloadRequest(
|
||||||
|
serie_id="serie_123",
|
||||||
|
serie_name="Test Series",
|
||||||
|
episodes=[]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_serie_name_rejected(self):
|
||||||
|
"""Test that empty serie name is rejected."""
|
||||||
|
episode = EpisodeIdentifier(season=1, episode=1)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
DownloadRequest(
|
||||||
|
serie_id="serie_123",
|
||||||
|
serie_name="",
|
||||||
|
episodes=[episode]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownloadResponse:
|
||||||
|
"""Test DownloadResponse model."""
|
||||||
|
|
||||||
|
def test_valid_download_response(self):
|
||||||
|
"""Test creating a valid download response."""
|
||||||
|
response = DownloadResponse(
|
||||||
|
status="success",
|
||||||
|
message="Added 2 episodes to queue",
|
||||||
|
added_items=["item1", "item2"],
|
||||||
|
failed_items=[]
|
||||||
|
)
|
||||||
|
assert response.status == "success"
|
||||||
|
assert response.message == "Added 2 episodes to queue"
|
||||||
|
assert len(response.added_items) == 2
|
||||||
|
assert response.failed_items == []
|
||||||
|
|
||||||
|
def test_download_response_defaults(self):
|
||||||
|
"""Test default values for download response."""
|
||||||
|
response = DownloadResponse(
|
||||||
|
status="success",
|
||||||
|
message="Test message"
|
||||||
|
)
|
||||||
|
assert response.added_items == []
|
||||||
|
assert response.failed_items == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueueOperationRequest:
|
||||||
|
"""Test QueueOperationRequest model."""
|
||||||
|
|
||||||
|
def test_valid_operation_request(self):
|
||||||
|
"""Test creating a valid operation request."""
|
||||||
|
request = QueueOperationRequest(
|
||||||
|
item_ids=["item1", "item2", "item3"]
|
||||||
|
)
|
||||||
|
assert len(request.item_ids) == 3
|
||||||
|
assert "item1" in request.item_ids
|
||||||
|
|
||||||
|
def test_empty_item_ids_rejected(self):
|
||||||
|
"""Test that empty item_ids list is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
QueueOperationRequest(item_ids=[])
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueueReorderRequest:
|
||||||
|
"""Test QueueReorderRequest model."""
|
||||||
|
|
||||||
|
def test_valid_reorder_request(self):
|
||||||
|
"""Test creating a valid reorder request."""
|
||||||
|
request = QueueReorderRequest(
|
||||||
|
item_id="item_123",
|
||||||
|
new_position=5
|
||||||
|
)
|
||||||
|
assert request.item_id == "item_123"
|
||||||
|
assert request.new_position == 5
|
||||||
|
|
||||||
|
def test_zero_position_allowed(self):
|
||||||
|
"""Test that position zero is allowed."""
|
||||||
|
request = QueueReorderRequest(
|
||||||
|
item_id="item_123",
|
||||||
|
new_position=0
|
||||||
|
)
|
||||||
|
assert request.new_position == 0
|
||||||
|
|
||||||
|
def test_negative_position_rejected(self):
|
||||||
|
"""Test that negative position is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
QueueReorderRequest(
|
||||||
|
item_id="item_123",
|
||||||
|
new_position=-1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueueStatusResponse:
|
||||||
|
"""Test QueueStatusResponse model."""
|
||||||
|
|
||||||
|
def test_valid_status_response(self):
|
||||||
|
"""Test creating a valid status response."""
|
||||||
|
status = QueueStatus()
|
||||||
|
stats = QueueStats()
|
||||||
|
response = QueueStatusResponse(
|
||||||
|
status=status,
|
||||||
|
statistics=stats
|
||||||
|
)
|
||||||
|
assert response.status is not None
|
||||||
|
assert response.statistics is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelSerialization:
|
||||||
|
"""Test model serialization and deserialization."""
|
||||||
|
|
||||||
|
def test_download_item_to_dict(self):
|
||||||
|
"""Test serializing download item to dict."""
|
||||||
|
episode = EpisodeIdentifier(season=1, episode=5, title="Test")
|
||||||
|
item = DownloadItem(
|
||||||
|
id="test_id",
|
||||||
|
serie_id="serie_id",
|
||||||
|
serie_name="Test Series",
|
||||||
|
episode=episode
|
||||||
|
)
|
||||||
|
data = item.model_dump()
|
||||||
|
assert data["id"] == "test_id"
|
||||||
|
assert data["serie_name"] == "Test Series"
|
||||||
|
assert data["episode"]["season"] == 1
|
||||||
|
assert data["episode"]["episode"] == 5
|
||||||
|
|
||||||
|
def test_download_item_from_dict(self):
|
||||||
|
"""Test deserializing download item from dict."""
|
||||||
|
data = {
|
||||||
|
"id": "test_id",
|
||||||
|
"serie_id": "serie_id",
|
||||||
|
"serie_name": "Test Series",
|
||||||
|
"episode": {
|
||||||
|
"season": 1,
|
||||||
|
"episode": 5,
|
||||||
|
"title": "Test Episode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item = DownloadItem(**data)
|
||||||
|
assert item.id == "test_id"
|
||||||
|
assert item.serie_name == "Test Series"
|
||||||
|
assert item.episode.season == 1
|
||||||
|
|
||||||
|
def test_queue_status_to_json(self):
|
||||||
|
"""Test serializing queue status to JSON."""
|
||||||
|
status = QueueStatus(is_running=True)
|
||||||
|
json_str = status.model_dump_json()
|
||||||
|
assert '"is_running":true' in json_str.lower()
|
||||||
|
|
||||||
|
def test_queue_stats_from_json(self):
|
||||||
|
"""Test deserializing queue stats from JSON."""
|
||||||
|
json_str = '{"total_items": 5, "pending_count": 3}'
|
||||||
|
stats = QueueStats.model_validate_json(json_str)
|
||||||
|
assert stats.total_items == 5
|
||||||
|
assert stats.pending_count == 3
|
||||||
Loading…
x
Reference in New Issue
Block a user