Task 4.5: Update Pydantic models to use key as primary identifier
- Updated AnimeSeriesResponse and SearchResult models in anime.py: - Changed 'id' field to 'key' as the primary series identifier - Added 'folder' as optional metadata field - Added field validator to normalize key to lowercase and strip whitespace - Added comprehensive docstrings explaining identifier usage - Updated DownloadItem and DownloadRequest models in download.py: - Added field validator for serie_id normalization (lowercase, stripped) - Improved documentation for serie_id (primary identifier) vs serie_folder (metadata) - Updated test_anime_models.py with comprehensive tests: - Tests for key normalization and whitespace stripping - Tests for folder as optional metadata - Reorganized tests into proper class structure - Updated test_download_models.py with validator tests: - Tests for serie_id normalization in DownloadItem - Tests for serie_id normalization in DownloadRequest All 885 tests pass.
This commit is contained in:
parent
3c8ba1d48c
commit
6d2a791a9d
@ -1,9 +1,24 @@
|
|||||||
|
"""Anime Pydantic models for the Aniworld web application.
|
||||||
|
|
||||||
|
This module defines request/response models used by the anime API
|
||||||
|
and services. Models are focused on serialization, validation,
|
||||||
|
and OpenAPI documentation.
|
||||||
|
|
||||||
|
Note on identifiers:
|
||||||
|
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
|
||||||
|
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, HttpUrl
|
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
# Regex pattern for valid series keys (URL-safe, lowercase with hyphens)
|
||||||
|
KEY_PATTERN = re.compile(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$')
|
||||||
|
|
||||||
|
|
||||||
class EpisodeInfo(BaseModel):
|
class EpisodeInfo(BaseModel):
|
||||||
@ -31,10 +46,30 @@ class MissingEpisodeInfo(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AnimeSeriesResponse(BaseModel):
|
class AnimeSeriesResponse(BaseModel):
|
||||||
"""Response model for a series with metadata and episodes."""
|
"""Response model for a series with metadata and episodes.
|
||||||
|
|
||||||
|
Note on identifiers:
|
||||||
|
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
|
||||||
|
This is the unique key used for all lookups and operations.
|
||||||
|
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
|
||||||
|
Used only for display and filesystem operations.
|
||||||
|
"""
|
||||||
|
|
||||||
id: str = Field(..., description="Unique series identifier")
|
key: str = Field(
|
||||||
|
...,
|
||||||
|
description=(
|
||||||
|
"Series key (primary identifier) - provider-assigned URL-safe "
|
||||||
|
"key (e.g., 'attack-on-titan'). Used for lookups/identification."
|
||||||
|
)
|
||||||
|
)
|
||||||
title: str = Field(..., description="Series title")
|
title: str = Field(..., description="Series title")
|
||||||
|
folder: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description=(
|
||||||
|
"Series folder name on disk (metadata only) "
|
||||||
|
"(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only."
|
||||||
|
)
|
||||||
|
)
|
||||||
alt_titles: List[str] = Field(default_factory=list, description="Alternative titles")
|
alt_titles: List[str] = Field(default_factory=list, description="Alternative titles")
|
||||||
description: Optional[str] = Field(None, description="Short series description")
|
description: Optional[str] = Field(None, description="Short series description")
|
||||||
total_episodes: Optional[int] = Field(None, ge=0, description="Declared total episode count if known")
|
total_episodes: Optional[int] = Field(None, ge=0, description="Declared total episode count if known")
|
||||||
@ -42,20 +77,56 @@ class AnimeSeriesResponse(BaseModel):
|
|||||||
missing_episodes: List[MissingEpisodeInfo] = Field(default_factory=list, description="Detected missing episode ranges")
|
missing_episodes: List[MissingEpisodeInfo] = Field(default_factory=list, description="Detected missing episode ranges")
|
||||||
thumbnail: Optional[HttpUrl] = Field(None, description="Optional thumbnail image URL")
|
thumbnail: Optional[HttpUrl] = Field(None, description="Optional thumbnail image URL")
|
||||||
|
|
||||||
|
@field_validator('key', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def normalize_key(cls, v: str) -> str:
|
||||||
|
"""Normalize key to lowercase."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v.lower().strip()
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class SearchRequest(BaseModel):
|
class SearchRequest(BaseModel):
|
||||||
"""Request payload for searching series."""
|
"""Request payload for searching series."""
|
||||||
|
|
||||||
query: str = Field(..., min_length=1)
|
query: str = Field(..., min_length=1, description="Search query string")
|
||||||
limit: int = Field(10, ge=1, le=100)
|
limit: int = Field(10, ge=1, le=100, description="Maximum number of results")
|
||||||
include_adult: bool = Field(False)
|
include_adult: bool = Field(False, description="Include adult content in results")
|
||||||
|
|
||||||
|
|
||||||
class SearchResult(BaseModel):
|
class SearchResult(BaseModel):
|
||||||
"""Search result item for a series discovery endpoint."""
|
"""Search result item for a series discovery endpoint.
|
||||||
|
|
||||||
|
Note on identifiers:
|
||||||
|
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
|
||||||
|
This is the unique key used for all lookups and operations.
|
||||||
|
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
|
||||||
|
Used only for display and filesystem operations.
|
||||||
|
"""
|
||||||
|
|
||||||
id: str
|
key: str = Field(
|
||||||
title: str
|
...,
|
||||||
snippet: Optional[str] = None
|
description=(
|
||||||
thumbnail: Optional[HttpUrl] = None
|
"Series key (primary identifier) - provider-assigned URL-safe "
|
||||||
score: Optional[float] = None
|
"key (e.g., 'attack-on-titan'). Used for lookups/identification."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
title: str = Field(..., description="Series title")
|
||||||
|
folder: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description=(
|
||||||
|
"Series folder name on disk (metadata only) "
|
||||||
|
"(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
snippet: Optional[str] = Field(None, description="Short description or snippet")
|
||||||
|
thumbnail: Optional[HttpUrl] = Field(None, description="Thumbnail image URL")
|
||||||
|
score: Optional[float] = Field(None, ge=0.0, le=1.0, description="Search relevance score (0-1)")
|
||||||
|
|
||||||
|
@field_validator('key', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def normalize_key(cls, v: str) -> str:
|
||||||
|
"""Normalize key to lowercase."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v.lower().strip()
|
||||||
|
return v
|
||||||
|
|||||||
@ -126,6 +126,14 @@ class DownloadItem(BaseModel):
|
|||||||
None, description="Source URL for download"
|
None, description="Source URL for download"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@field_validator('serie_id', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def normalize_serie_id(cls, v: str) -> str:
|
||||||
|
"""Normalize serie_id (key) to lowercase and stripped."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v.lower().strip()
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class QueueStatus(BaseModel):
|
class QueueStatus(BaseModel):
|
||||||
"""Overall status of the download queue system."""
|
"""Overall status of the download queue system."""
|
||||||
@ -218,6 +226,14 @@ class DownloadRequest(BaseModel):
|
|||||||
return v.upper()
|
return v.upper()
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator('serie_id', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def normalize_serie_id(cls, v: str) -> str:
|
||||||
|
"""Normalize serie_id (key) to lowercase and stripped."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v.lower().strip()
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class DownloadResponse(BaseModel):
|
class DownloadResponse(BaseModel):
|
||||||
"""Response after adding items to the download queue."""
|
"""Response after adding items to the download queue."""
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
"""Unit tests for anime Pydantic models.
|
||||||
|
|
||||||
|
This module tests all anime-related models including validation,
|
||||||
|
serialization, and field constraints.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from src.server.models.anime import (
|
from src.server.models.anime import (
|
||||||
@ -9,101 +15,139 @@ from src.server.models.anime import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_episode_info_basic():
|
class TestEpisodeInfo:
|
||||||
ep = EpisodeInfo(episode_number=1, title="Pilot", duration_seconds=1500)
|
"""Tests for EpisodeInfo model."""
|
||||||
assert ep.episode_number == 1
|
|
||||||
assert ep.title == "Pilot"
|
def test_episode_info_basic(self):
|
||||||
assert ep.duration_seconds == 1500
|
"""Test creating a basic episode info."""
|
||||||
assert ep.available is True
|
ep = EpisodeInfo(episode_number=1, title="Pilot", duration_seconds=1500)
|
||||||
|
assert ep.episode_number == 1
|
||||||
|
assert ep.title == "Pilot"
|
||||||
|
assert ep.duration_seconds == 1500
|
||||||
|
assert ep.available is True
|
||||||
|
|
||||||
|
def test_episode_info_without_optional_fields(self):
|
||||||
|
"""Test episode info with only required fields."""
|
||||||
|
ep = EpisodeInfo(episode_number=5)
|
||||||
|
assert ep.episode_number == 5
|
||||||
|
assert ep.title is None
|
||||||
|
assert ep.duration_seconds is None
|
||||||
|
assert ep.available is True
|
||||||
|
|
||||||
|
def test_invalid_episode_number(self):
|
||||||
|
"""Test that episode number must be positive."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
EpisodeInfo(episode_number=0)
|
||||||
|
|
||||||
|
|
||||||
def test_missing_episode_count():
|
class TestMissingEpisodeInfo:
|
||||||
m = MissingEpisodeInfo(from_episode=5, to_episode=7)
|
"""Tests for MissingEpisodeInfo model."""
|
||||||
assert m.count == 3
|
|
||||||
|
def test_missing_episode_count(self):
|
||||||
|
"""Test count property calculation."""
|
||||||
|
m = MissingEpisodeInfo(from_episode=5, to_episode=7)
|
||||||
|
assert m.count == 3
|
||||||
|
|
||||||
|
def test_single_missing_episode(self):
|
||||||
|
"""Test count for single missing episode."""
|
||||||
|
m = MissingEpisodeInfo(from_episode=5, to_episode=5)
|
||||||
|
assert m.count == 1
|
||||||
|
|
||||||
|
|
||||||
def test_anime_series_response():
|
class TestAnimeSeriesResponse:
|
||||||
ep = EpisodeInfo(episode_number=1, title="Ep1")
|
"""Tests for AnimeSeriesResponse model."""
|
||||||
series = AnimeSeriesResponse(
|
|
||||||
id="series-123",
|
|
||||||
title="My Anime",
|
|
||||||
episodes=[ep],
|
|
||||||
total_episodes=12,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert series.id == "series-123"
|
def test_anime_series_response_with_key(self):
|
||||||
assert series.episodes[0].title == "Ep1"
|
"""Test creating series response with key as identifier."""
|
||||||
|
ep = EpisodeInfo(episode_number=1, title="Ep1")
|
||||||
|
series = AnimeSeriesResponse(
|
||||||
|
key="attack-on-titan",
|
||||||
|
title="Attack on Titan",
|
||||||
|
folder="Attack on Titan (2013)",
|
||||||
|
episodes=[ep],
|
||||||
|
total_episodes=12,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert series.key == "attack-on-titan"
|
||||||
|
assert series.title == "Attack on Titan"
|
||||||
|
assert series.folder == "Attack on Titan (2013)"
|
||||||
|
assert series.episodes[0].title == "Ep1"
|
||||||
|
|
||||||
|
def test_key_normalization(self):
|
||||||
|
"""Test that key is normalized to lowercase."""
|
||||||
|
series = AnimeSeriesResponse(
|
||||||
|
key="ATTACK-ON-TITAN",
|
||||||
|
title="Attack on Titan"
|
||||||
|
)
|
||||||
|
assert series.key == "attack-on-titan"
|
||||||
|
|
||||||
|
def test_key_whitespace_stripped(self):
|
||||||
|
"""Test that key whitespace is stripped."""
|
||||||
|
series = AnimeSeriesResponse(
|
||||||
|
key=" attack-on-titan ",
|
||||||
|
title="Attack on Titan"
|
||||||
|
)
|
||||||
|
assert series.key == "attack-on-titan"
|
||||||
|
|
||||||
|
def test_folder_is_optional(self):
|
||||||
|
"""Test that folder is optional metadata."""
|
||||||
|
series = AnimeSeriesResponse(
|
||||||
|
key="my-anime",
|
||||||
|
title="My Anime"
|
||||||
|
)
|
||||||
|
assert series.folder is None
|
||||||
|
|
||||||
|
|
||||||
def test_search_request_validation():
|
class TestSearchRequest:
|
||||||
# valid
|
"""Tests for SearchRequest model."""
|
||||||
req = SearchRequest(query="naruto", limit=5)
|
|
||||||
assert req.query == "naruto"
|
|
||||||
|
|
||||||
# invalid: empty query
|
def test_search_request_validation(self):
|
||||||
try:
|
"""Test valid search request."""
|
||||||
SearchRequest(query="", limit=5)
|
req = SearchRequest(query="naruto", limit=5)
|
||||||
raised = False
|
assert req.query == "naruto"
|
||||||
except ValidationError:
|
assert req.limit == 5
|
||||||
raised = True
|
|
||||||
assert raised
|
def test_search_request_empty_query_rejected(self):
|
||||||
|
"""Test that empty query is rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
SearchRequest(query="", limit=5)
|
||||||
|
|
||||||
|
def test_search_request_defaults(self):
|
||||||
|
"""Test default values."""
|
||||||
|
req = SearchRequest(query="test")
|
||||||
|
assert req.limit == 10
|
||||||
|
assert req.include_adult is False
|
||||||
|
|
||||||
|
|
||||||
def test_search_result_optional_fields():
|
class TestSearchResult:
|
||||||
res = SearchResult(id="s1", title="T1", snippet="snip", score=0.9)
|
"""Tests for SearchResult model."""
|
||||||
assert res.score == 0.9
|
|
||||||
|
|
||||||
from pydantic import ValidationError
|
def test_search_result_with_key(self):
|
||||||
|
"""Test search result with key as identifier."""
|
||||||
|
res = SearchResult(
|
||||||
|
key="naruto",
|
||||||
|
title="Naruto",
|
||||||
|
folder="Naruto (2002)",
|
||||||
|
snippet="A ninja story",
|
||||||
|
score=0.9
|
||||||
|
)
|
||||||
|
assert res.key == "naruto"
|
||||||
|
assert res.title == "Naruto"
|
||||||
|
assert res.folder == "Naruto (2002)"
|
||||||
|
assert res.score == 0.9
|
||||||
|
|
||||||
from src.server.models.anime import (
|
def test_key_normalization(self):
|
||||||
AnimeSeriesResponse,
|
"""Test that key is normalized to lowercase."""
|
||||||
EpisodeInfo,
|
res = SearchResult(key="NARUTO", title="Naruto")
|
||||||
MissingEpisodeInfo,
|
assert res.key == "naruto"
|
||||||
SearchRequest,
|
|
||||||
SearchResult,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
def test_folder_is_optional(self):
|
||||||
|
"""Test that folder is optional metadata."""
|
||||||
|
res = SearchResult(key="test", title="Test")
|
||||||
|
assert res.folder is None
|
||||||
|
|
||||||
def test_episode_info_basic():
|
def test_optional_fields(self):
|
||||||
ep = EpisodeInfo(episode_number=1, title="Pilot", duration_seconds=1500)
|
"""Test optional fields."""
|
||||||
assert ep.episode_number == 1
|
res = SearchResult(key="s1", title="T1", snippet="snip", score=0.9)
|
||||||
assert ep.title == "Pilot"
|
assert res.score == 0.9
|
||||||
assert ep.duration_seconds == 1500
|
assert res.snippet == "snip"
|
||||||
assert ep.available is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_episode_count():
|
|
||||||
m = MissingEpisodeInfo(from_episode=5, to_episode=7)
|
|
||||||
assert m.count == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_anime_series_response():
|
|
||||||
ep = EpisodeInfo(episode_number=1, title="Ep1")
|
|
||||||
series = AnimeSeriesResponse(
|
|
||||||
id="series-123",
|
|
||||||
title="My Anime",
|
|
||||||
episodes=[ep],
|
|
||||||
total_episodes=12,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert series.id == "series-123"
|
|
||||||
assert series.episodes[0].title == "Ep1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_request_validation():
|
|
||||||
# valid
|
|
||||||
req = SearchRequest(query="naruto", limit=5)
|
|
||||||
assert req.query == "naruto"
|
|
||||||
|
|
||||||
# invalid: empty query
|
|
||||||
try:
|
|
||||||
SearchRequest(query="", limit=5)
|
|
||||||
raised = False
|
|
||||||
except ValidationError:
|
|
||||||
raised = True
|
|
||||||
assert raised
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_result_optional_fields():
|
|
||||||
res = SearchResult(id="s1", title="T1", snippet="snip", score=0.9)
|
|
||||||
assert res.score == 0.9
|
|
||||||
|
|||||||
@ -187,6 +187,30 @@ class TestDownloadItem:
|
|||||||
assert item.status == DownloadStatus.PENDING
|
assert item.status == DownloadStatus.PENDING
|
||||||
assert item.priority == DownloadPriority.HIGH
|
assert item.priority == DownloadPriority.HIGH
|
||||||
|
|
||||||
|
def test_serie_id_normalized_to_lowercase(self):
|
||||||
|
"""Test that serie_id (key) is normalized to lowercase."""
|
||||||
|
episode = EpisodeIdentifier(season=1, episode=1)
|
||||||
|
item = DownloadItem(
|
||||||
|
id="test_id",
|
||||||
|
serie_id="ATTACK-ON-TITAN",
|
||||||
|
serie_folder="Test Folder",
|
||||||
|
serie_name="Test",
|
||||||
|
episode=episode
|
||||||
|
)
|
||||||
|
assert item.serie_id == "attack-on-titan"
|
||||||
|
|
||||||
|
def test_serie_id_whitespace_stripped(self):
|
||||||
|
"""Test that serie_id whitespace is stripped."""
|
||||||
|
episode = EpisodeIdentifier(season=1, episode=1)
|
||||||
|
item = DownloadItem(
|
||||||
|
id="test_id",
|
||||||
|
serie_id=" attack-on-titan ",
|
||||||
|
serie_folder="Test Folder",
|
||||||
|
serie_name="Test",
|
||||||
|
episode=episode
|
||||||
|
)
|
||||||
|
assert item.serie_id == "attack-on-titan"
|
||||||
|
|
||||||
def test_download_item_defaults(self):
|
def test_download_item_defaults(self):
|
||||||
"""Test default values for download item."""
|
"""Test default values for download item."""
|
||||||
episode = EpisodeIdentifier(season=1, episode=1)
|
episode = EpisodeIdentifier(season=1, episode=1)
|
||||||
@ -393,6 +417,28 @@ class TestDownloadRequest:
|
|||||||
assert len(request.episodes) == 2
|
assert len(request.episodes) == 2
|
||||||
assert request.priority == DownloadPriority.HIGH
|
assert request.priority == DownloadPriority.HIGH
|
||||||
|
|
||||||
|
def test_serie_id_normalized_to_lowercase(self):
|
||||||
|
"""Test that serie_id (key) is normalized to lowercase."""
|
||||||
|
episode = EpisodeIdentifier(season=1, episode=1)
|
||||||
|
request = DownloadRequest(
|
||||||
|
serie_id="ATTACK-ON-TITAN",
|
||||||
|
serie_folder="Test Series (2023)",
|
||||||
|
serie_name="Test Series",
|
||||||
|
episodes=[episode]
|
||||||
|
)
|
||||||
|
assert request.serie_id == "attack-on-titan"
|
||||||
|
|
||||||
|
def test_serie_id_whitespace_stripped(self):
|
||||||
|
"""Test that serie_id whitespace is stripped."""
|
||||||
|
episode = EpisodeIdentifier(season=1, episode=1)
|
||||||
|
request = DownloadRequest(
|
||||||
|
serie_id=" attack-on-titan ",
|
||||||
|
serie_folder="Test Series (2023)",
|
||||||
|
serie_name="Test Series",
|
||||||
|
episodes=[episode]
|
||||||
|
)
|
||||||
|
assert request.serie_id == "attack-on-titan"
|
||||||
|
|
||||||
def test_download_request_default_priority(self):
|
def test_download_request_default_priority(self):
|
||||||
"""Test default priority for download request."""
|
"""Test default priority for download request."""
|
||||||
episode = EpisodeIdentifier(season=1, episode=1)
|
episode = EpisodeIdentifier(season=1, episode=1)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user