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:
Lukas 2025-11-27 20:01:33 +01:00
parent 3c8ba1d48c
commit 6d2a791a9d
4 changed files with 273 additions and 96 deletions

View File

@ -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.
id: str = Field(..., description="Unique series identifier") 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.
"""
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.
id: str Note on identifiers:
title: str - key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
snippet: Optional[str] = None This is the unique key used for all lookups and operations.
thumbnail: Optional[HttpUrl] = None - folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
score: Optional[float] = None Used only for display and filesystem operations.
"""
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")
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

View File

@ -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."""

View File

@ -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

View File

@ -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)