From 6d2a791a9d3c7395d8e5009c3fcc04a0d2da8859 Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 27 Nov 2025 20:01:33 +0100 Subject: [PATCH] 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. --- src/server/models/anime.py | 95 +++++++++++-- src/server/models/download.py | 16 +++ tests/unit/test_anime_models.py | 212 +++++++++++++++++------------ tests/unit/test_download_models.py | 46 +++++++ 4 files changed, 273 insertions(+), 96 deletions(-) diff --git a/src/server/models/anime.py b/src/server/models/anime.py index d86eb5e..1a3aed3 100644 --- a/src/server/models/anime.py +++ b/src/server/models/anime.py @@ -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. + + 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") + 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. + + 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 - title: str - snippet: Optional[str] = None - thumbnail: Optional[HttpUrl] = None - score: Optional[float] = None + 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 diff --git a/src/server/models/download.py b/src/server/models/download.py index 89c169d..59da75f 100644 --- a/src/server/models/download.py +++ b/src/server/models/download.py @@ -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.""" diff --git a/tests/unit/test_anime_models.py b/tests/unit/test_anime_models.py index e3007c0..71d098f 100644 --- a/tests/unit/test_anime_models.py +++ b/tests/unit/test_anime_models.py @@ -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" diff --git a/tests/unit/test_download_models.py b/tests/unit/test_download_models.py index 5cbdbe9..1bbbf56 100644 --- a/tests/unit/test_download_models.py +++ b/tests/unit/test_download_models.py @@ -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)