Files
MoviePilot/tests/test_media_response_models.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

352 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
验证媒体响应模型的兼容字段、音乐实体区分和结果投影。
"""
from unittest.mock import AsyncMock, Mock
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from app import schemas
from app.api.endpoints import media as media_endpoint
from app.api.endpoints import mediaserver as mediaserver_endpoint
from app.api.response import ResponseAPIRouter
from app.domain.context import MediaInfo as CoreMediaInfo
from app.domain.context import MusicArtistInfo as CoreMusicArtistInfo
from app.domain.context import MusicInfo as CoreMusicInfo
from app.schemas.types import MediaSource, MediaType
@pytest.mark.asyncio
async def test_media_search_response_preserves_core_collection_fields() -> None:
"""
媒体搜索响应模型应保留 Core MediaInfo 合集输出的全部兼容字段。
"""
media = CoreMediaInfo(tmdb_info={
"id": 42,
"media_type": MediaType.COLLECTION,
"collection_id": 42,
"name": "示例合集",
"original_name": "Example Collection",
})
media.hk_title = "香港标题"
media.tw_title = "台灣標題"
media.sg_title = "新加坡标题"
media.logo_path = "/logo.png"
media.content_rating = "PG-13"
media.season_years = {1: "2024"}
payload = media.to_dict()
assert payload["media_source"] == MediaSource.TMDB.value
router = ResponseAPIRouter()
@router.get("/media/search", response_model=schemas.MediaSearchResults)
def search_media() -> list[dict]:
"""
返回代表性的 Core MediaInfo 合集序列化结果。
"""
return [payload]
app = FastAPI()
app.include_router(router)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.get("/media/search")
assert response.status_code == 200
result = response.json()["data"][0]
assert result["hk_title"] == "香港标题"
assert result["tw_title"] == "台灣標題"
assert result["sg_title"] == "新加坡标题"
assert result["logo_path"] == "/logo.png"
assert result["content_rating"] == "PG-13"
assert result["season_years"] == {"1": "2024"}
assert "tmdb_info" in result
assert "douban_info" in result
assert "bangumi_info" in result
assert "anilist_info" in result
@pytest.mark.asyncio
async def test_media_search_response_preserves_music_artist_result_shape() -> None:
"""
统一搜索响应应把 MusicBrainz 艺术家解析为艺术家模型而非普通音乐条目。
"""
artist = CoreMusicArtistInfo(
media_source=MediaSource.MusicBrainz,
media_id="artist-1",
name="示例艺术家",
artist_type="Person",
)
router = ResponseAPIRouter()
@router.get("/media/search", response_model=schemas.MediaSearchResults)
def search_media() -> list[dict]:
"""
返回代表性的 MusicBrainz 艺术家搜索结果。
"""
return [artist.to_dict()]
app = FastAPI()
app.include_router(router)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.get("/media/search")
assert response.status_code == 200
result = response.json()["data"][0]
assert result["music_type"] == "artist"
assert result["media_source"] == MediaSource.MusicBrainz.value
assert result["artist_type"] == "Person"
@pytest.mark.asyncio
async def test_media_search_response_keeps_legacy_music_artist_shape() -> None:
"""
显式音乐艺术家选择的旧 MusicInfo 结果仍应保持音乐条目兼容字段。
"""
music = CoreMusicInfo(
media_source=MediaSource.MusicBrainz,
media_id="artist-1",
music_type="artist",
title="兼容艺术家",
)
router = ResponseAPIRouter()
@router.get("/media/search", response_model=schemas.MediaSearchResults)
def search_media() -> list[dict]:
"""
返回显式音乐实体选择的旧艺术家结果。
"""
return [music.to_dict()]
app = FastAPI()
app.include_router(router)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.get("/media/search")
assert response.status_code == 200
result = response.json()["data"][0]
assert result["music_type"] == "artist"
assert result["title"] == "兼容艺术家"
assert "name" not in result
@pytest.mark.asyncio
async def test_media_response_accepts_cross_source_credit_shapes() -> None:
"""
媒体响应应同时保留豆瓣姓名字符串和 TMDB 演职员对象。
"""
router = ResponseAPIRouter()
@router.get("/recommend", response_model=list[schemas.MediaInfo])
def recommend_media() -> list[dict]:
"""
返回跨来源的演职员字段形态。
"""
return [
{
"media_source": MediaSource.Douban,
"media_id": "1292052",
"title": "示例电影",
"actors": ["演员甲", {"id": 2, "name": "演员乙"}],
"directors": ["导演甲", {"id": 3, "name": "导演乙"}],
}
]
app = FastAPI()
app.include_router(router)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.get("/recommend")
assert response.status_code == 200
media = response.json()["data"][0]
assert media["actors"][0] == "演员甲"
assert media["actors"][1]["id"] == 2
assert media["actors"][1]["name"] == "演员乙"
assert media["directors"][0] == "导演甲"
assert media["directors"][1]["id"] == 3
assert media["directors"][1]["name"] == "导演乙"
@pytest.mark.asyncio
async def test_douban_media_response_filters_unknown_season_years() -> None:
"""
豆瓣榜单的未知季年份不应导致整个媒体列表响应校验失败。
"""
media = CoreMediaInfo(douban_info={
"id": "1292052",
"title": "示例电影",
"type": "movie",
"season_years": {1: None, 2: 2025},
})
router = ResponseAPIRouter()
@router.get("/discover/douban_movies", response_model=list[schemas.MediaInfo])
def discover_douban_movies() -> list[dict]:
"""
返回包含未知季年份的豆瓣榜单条目。
"""
return [media.to_dict()]
app = FastAPI()
app.include_router(router)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.get("/discover/douban_movies")
assert response.status_code == 200
assert response.json()["data"][0]["season_years"] == {"2": "2025"}
@pytest.mark.asyncio
async def test_media_detail_response_accepts_music_numeric_year(monkeypatch) -> None:
"""
通用详情接口应按音乐模型序列化 MusicBrainz 的整数年份。
"""
media_id = "b79e2ffd-7e44-4dbf-91f9-167c05d1fc91"
music = CoreMusicInfo(
media_source=MediaSource.MusicBrainz,
media_id=media_id,
title="示例单曲",
artists=["示例歌手"],
album="示例专辑",
year=2004,
)
media_chain = Mock()
media_chain.async_recognize_media = AsyncMock(return_value=music)
media_chain.async_obtain_images = AsyncMock(return_value=None)
monkeypatch.setattr(media_endpoint, "MediaChain", Mock(return_value=media_chain))
app = FastAPI()
app.dependency_overrides[media_endpoint.verify_token] = lambda: None
app.include_router(media_endpoint.router, prefix="/api/v1/media")
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.get(
f"/api/v1/media/{media_id}",
params={"media_source": "musicbrainz", "type_name": "音乐"},
)
assert response.status_code == 200
result = response.json()["data"]
assert result["type"] == MediaType.MUSIC.value
assert result["music_type"] == "recording"
assert result["artists"] == ["示例歌手"]
assert result["year"] == 2004
@pytest.mark.asyncio
async def test_media_response_accepts_legacy_source_key() -> None:
"""
媒体身份重构前缓存的旧格式条目(source + media_id)应被归一化并正常响应。
"""
router = ResponseAPIRouter()
@router.get("/recommend", response_model=list[schemas.MediaInfo])
def recommend_media() -> list[dict]:
"""
返回媒体身份重构前缓存的旧格式推荐条目。
"""
return [
{
"source": "themoviedb",
"media_id": "1368337",
"type": "电影",
"title": "奥德赛",
"year": "2026",
"tmdb_id": 1368337,
}
]
app = FastAPI()
app.include_router(router)
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
response = await client.get("/recommend")
assert response.status_code == 200
media = response.json()["data"][0]
assert media["media_source"] == "themoviedb"
assert media["media_id"] == "1368337"
@pytest.mark.asyncio
async def test_media_exists_not_found_is_a_successful_query() -> None:
"""
媒体库未命中是查询结果,不应被统一客户端识别为接口失败。
"""
class EmptyMediaServerQueryService:
"""
返回未命中的媒体库查询桩。
"""
async def find_item_id(self, **_kwargs):
"""
模拟媒体库中不存在目标媒体。
"""
return None
response = await mediaserver_endpoint.exists_local(
title="未入库电影",
year="2026",
mtype="电影",
media_source=None,
media_id=None,
season=None,
service=EmptyMediaServerQueryService(),
_=None,
)
assert response.success is True
assert response.data == {"item": {}}
@pytest.mark.asyncio
async def test_media_exists_normalizes_chinese_season_suffix_before_query() -> None:
"""
豆瓣标题带第 x 季时,应拆成基础标题和季号再查询媒体库。
"""
service = Mock()
service.find_item_id = AsyncMock(return_value="tv-item-2")
response = await mediaserver_endpoint.exists_local(
title="跨来源剧集第二季",
year="2023",
mtype="电视剧",
media_source=MediaSource.Douban,
media_id="douban-tv-2",
season=None,
service=service,
_=None,
)
assert response.success is True
assert response.data == {"item": {"id": "tv-item-2"}}
service.find_item_id.assert_awaited_once_with(
title="跨来源剧集",
year="2023",
mtype="电视剧",
media_source=MediaSource.Douban,
media_id="douban-tv-2",
season=2,
)