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
import re
from datetime import datetime
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):
@ -31,10 +46,30 @@ class MissingEpisodeInfo(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")
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")
description: Optional[str] = Field(None, description="Short series description")
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")
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):
"""Request payload for searching series."""
query: str = Field(..., min_length=1)
limit: int = Field(10, ge=1, le=100)
include_adult: bool = Field(False)
query: str = Field(..., min_length=1, description="Search query string")
limit: int = Field(10, ge=1, le=100, description="Maximum number of results")
include_adult: bool = Field(False, description="Include adult content in results")
class SearchResult(BaseModel):
"""Search result item for a series discovery endpoint."""
"""Search result item for a series discovery endpoint.
id: str
title: str
snippet: Optional[str] = None
thumbnail: Optional[HttpUrl] = None
score: Optional[float] = None
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")
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"
)
@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):
"""Overall status of the download queue system."""
@ -218,6 +226,14 @@ class DownloadRequest(BaseModel):
return v.upper()
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):
"""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 src.server.models.anime import (
@ -9,101 +15,139 @@ from src.server.models.anime import (
)
def test_episode_info_basic():
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
class TestEpisodeInfo:
"""Tests for EpisodeInfo model."""
def test_episode_info_basic(self):
"""Test creating a basic episode info."""
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():
m = MissingEpisodeInfo(from_episode=5, to_episode=7)
assert m.count == 3
class TestMissingEpisodeInfo:
"""Tests for MissingEpisodeInfo model."""
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():
ep = EpisodeInfo(episode_number=1, title="Ep1")
series = AnimeSeriesResponse(
id="series-123",
title="My Anime",
episodes=[ep],
total_episodes=12,
)
class TestAnimeSeriesResponse:
"""Tests for AnimeSeriesResponse model."""
assert series.id == "series-123"
assert series.episodes[0].title == "Ep1"
def test_anime_series_response_with_key(self):
"""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():
# valid
req = SearchRequest(query="naruto", limit=5)
assert req.query == "naruto"
class TestSearchRequest:
"""Tests for SearchRequest model."""
# invalid: empty query
try:
SearchRequest(query="", limit=5)
raised = False
except ValidationError:
raised = True
assert raised
def test_search_request_validation(self):
"""Test valid search request."""
req = SearchRequest(query="naruto", limit=5)
assert req.query == "naruto"
assert req.limit == 5
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():
res = SearchResult(id="s1", title="T1", snippet="snip", score=0.9)
assert res.score == 0.9
class TestSearchResult:
"""Tests for SearchResult model."""
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 (
AnimeSeriesResponse,
EpisodeInfo,
MissingEpisodeInfo,
SearchRequest,
SearchResult,
)
def test_key_normalization(self):
"""Test that key is normalized to lowercase."""
res = SearchResult(key="NARUTO", title="Naruto")
assert res.key == "naruto"
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():
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_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
def test_optional_fields(self):
"""Test optional fields."""
res = SearchResult(key="s1", title="T1", snippet="snip", score=0.9)
assert res.score == 0.9
assert res.snippet == "snip"

View File

@ -187,6 +187,30 @@ class TestDownloadItem:
assert item.status == DownloadStatus.PENDING
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):
"""Test default values for download item."""
episode = EpisodeIdentifier(season=1, episode=1)
@ -393,6 +417,28 @@ class TestDownloadRequest:
assert len(request.episodes) == 2
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):
"""Test default priority for download request."""
episode = EpisodeIdentifier(season=1, episode=1)