mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
369 lines
12 KiB
Python
369 lines
12 KiB
Python
"""TmdbApi 同步与异步入口共享业务决策的回归测试。"""
|
|
|
|
import asyncio
|
|
from copy import deepcopy
|
|
from types import SimpleNamespace
|
|
from typing import Optional, Union
|
|
from unittest.mock import AsyncMock, Mock, call
|
|
|
|
import pytest
|
|
|
|
from app.modules.themoviedb.tmdbapi import TmdbApi
|
|
from app.modules.themoviedb.tmdbv3api.exceptions import (
|
|
TMDbConnectionError,
|
|
TMDbException,
|
|
)
|
|
from app.schemas.types import MediaType
|
|
|
|
|
|
def _api() -> TmdbApi:
|
|
"""构造不初始化真实 TMDB 客户端的最小 API 实例。"""
|
|
api = object.__new__(TmdbApi)
|
|
api.tmdb = SimpleNamespace(language="en")
|
|
api.search = SimpleNamespace(
|
|
total_results=0,
|
|
movies=Mock(),
|
|
async_movies=AsyncMock(),
|
|
tv_shows=Mock(),
|
|
async_tv_shows=AsyncMock(),
|
|
multi=Mock(),
|
|
async_multi=AsyncMock(),
|
|
collections=Mock(),
|
|
async_collections=AsyncMock(),
|
|
)
|
|
api.movie = SimpleNamespace(details=Mock(), async_details=AsyncMock())
|
|
api.tv = SimpleNamespace(details=Mock(), async_details=AsyncMock())
|
|
return api
|
|
|
|
|
|
def _movie_detail(tmdbid: int, title: str = "展示标题") -> dict:
|
|
"""构造包含别名、分级和多语言标题的电影详情。"""
|
|
return {
|
|
"id": tmdbid,
|
|
"title": title,
|
|
"original_title": "Original Title",
|
|
"original_language": "en",
|
|
"release_date": "2024-06-01",
|
|
"genres": [{"id": 18}, {"id": 35}],
|
|
"alternative_titles": {"titles": [{"iso_3166_1": "US", "title": "目标别名"}]},
|
|
"translations": {
|
|
"translations": [
|
|
{
|
|
"iso_3166_1": "HK",
|
|
"data": {"title": "香港标题"},
|
|
}
|
|
]
|
|
},
|
|
"release_dates": {
|
|
"results": [
|
|
{
|
|
"iso_3166_1": "US",
|
|
"release_dates": [{"certification": "PG-13"}],
|
|
}
|
|
]
|
|
},
|
|
}
|
|
|
|
|
|
def _tv_detail(tmdbid: int, name: str = "目标剧") -> dict:
|
|
"""构造包含季年份和译名的电视剧详情。"""
|
|
return {
|
|
"id": tmdbid,
|
|
"name": name,
|
|
"original_name": "Original Series",
|
|
"original_language": "en",
|
|
"first_air_date": "2018-01-01",
|
|
"genres": [{"id": 18}],
|
|
"seasons": [{"season_number": 3, "air_date": "2024-03-02"}],
|
|
"alternative_titles": {"results": [{"iso_3166_1": "US", "title": "剧集别名"}]},
|
|
"translations": {"translations": []},
|
|
"content_ratings": {"results": []},
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mtype", "sync_name", "async_name", "title_field", "year_kwargs"),
|
|
[
|
|
(
|
|
MediaType.MOVIE,
|
|
"search_movies",
|
|
"async_search_movies",
|
|
"title",
|
|
{"term": "目标", "year": "2024"},
|
|
),
|
|
(
|
|
MediaType.TV,
|
|
"search_tvs",
|
|
"async_search_tvs",
|
|
"name",
|
|
{"term": "目标", "release_year": "2024"},
|
|
),
|
|
],
|
|
)
|
|
def test_search_lists_share_query_and_projection(
|
|
mtype: MediaType,
|
|
sync_name: str,
|
|
async_name: str,
|
|
title_field: str,
|
|
year_kwargs: dict[str, str],
|
|
) -> None:
|
|
"""电影和剧集列表双入口应共享参数、标题过滤和类型投影。"""
|
|
api = _api()
|
|
sync_items = [
|
|
{"id": 1, title_field: "目标作品"},
|
|
{"id": 2, title_field: "其他作品"},
|
|
]
|
|
async_items = deepcopy(sync_items)
|
|
sync_client = api.search.movies if mtype == MediaType.MOVIE else api.search.tv_shows
|
|
async_client = api.search.async_movies if mtype == MediaType.MOVIE else api.search.async_tv_shows
|
|
sync_client.return_value = sync_items
|
|
async_client.return_value = async_items
|
|
|
|
sync_result = getattr(api, sync_name)("目标", "2024")
|
|
async_result = asyncio.run(getattr(api, async_name)("目标", "2024"))
|
|
|
|
assert sync_result == async_result == [{"id": 1, title_field: "目标作品", "media_type": mtype}]
|
|
sync_client.assert_called_once_with(**year_kwargs)
|
|
async_client.assert_awaited_once_with(**year_kwargs)
|
|
|
|
|
|
def test_match_movie_sync_async_share_year_fallback_and_original_title() -> None:
|
|
"""电影匹配双入口应按当前、下一年顺序回退并采信原标题。"""
|
|
api = _api()
|
|
sync_hit = {
|
|
"id": 10,
|
|
"title": "本地标题",
|
|
"original_title": "Target Movie",
|
|
"release_date": "2025-02-01",
|
|
}
|
|
async_hit = deepcopy(sync_hit)
|
|
api.search.movies.side_effect = [[], [sync_hit]]
|
|
api.search.async_movies.side_effect = [[], [async_hit]]
|
|
|
|
sync_result = api.match("Target Movie", MediaType.MOVIE, year="2024")
|
|
async_result = asyncio.run(api.async_match("Target Movie", MediaType.MOVIE, year="2024"))
|
|
|
|
assert sync_result == async_result
|
|
assert sync_result["media_type"] == MediaType.MOVIE
|
|
expected_calls = [
|
|
call(term="Target Movie", year="2024"),
|
|
call(term="Target Movie", year="2025"),
|
|
]
|
|
assert api.search.movies.call_args_list == expected_calls
|
|
assert api.search.async_movies.call_args_list == expected_calls
|
|
|
|
|
|
def test_match_movie_sync_async_share_alias_detail_fallback() -> None:
|
|
"""标题未命中时双入口应同序查询详情并使用别名。"""
|
|
api = _api()
|
|
sync_candidate = {
|
|
"id": 11,
|
|
"title": "展示标题",
|
|
"original_title": "Original Title",
|
|
"release_date": "2024-06-01",
|
|
}
|
|
async_candidate = deepcopy(sync_candidate)
|
|
sync_detail = _movie_detail(11)
|
|
async_detail = deepcopy(sync_detail)
|
|
api.search.movies.return_value = [sync_candidate]
|
|
api.search.async_movies.return_value = [async_candidate]
|
|
api.movie.details.return_value = sync_detail
|
|
api.movie.async_details.return_value = async_detail
|
|
|
|
sync_result = api.match("目标别名", MediaType.MOVIE, year="2024")
|
|
async_result = asyncio.run(api.async_match("目标别名", MediaType.MOVIE, year="2024"))
|
|
|
|
assert sync_result == async_result
|
|
assert sync_result["id"] == 11
|
|
assert "目标别名" in sync_result["names"]
|
|
api.movie.details.assert_called_once()
|
|
api.movie.async_details.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"group_seasons",
|
|
[
|
|
None,
|
|
[{"order": 3, "episodes": [{"air_date": "2024-04-01"}]}],
|
|
],
|
|
)
|
|
def test_match_tv_season_sync_async_share_detail_year_decision(
|
|
group_seasons: Optional[list[dict]],
|
|
) -> None:
|
|
"""首播年份不同的剧集应由同一季年份规则在双入口命中。"""
|
|
api = _api()
|
|
sync_candidate = {
|
|
"id": 20,
|
|
"name": "目标剧",
|
|
"original_name": "Original Series",
|
|
"first_air_date": "2018-01-01",
|
|
}
|
|
async_candidate = deepcopy(sync_candidate)
|
|
sync_detail = _tv_detail(20)
|
|
async_detail = deepcopy(sync_detail)
|
|
api.search.tv_shows.return_value = [sync_candidate]
|
|
api.search.async_tv_shows.return_value = [async_candidate]
|
|
api.tv.details.return_value = sync_detail
|
|
api.tv.async_details.return_value = async_detail
|
|
|
|
sync_result = api.match(
|
|
"目标剧",
|
|
MediaType.TV,
|
|
year="2018",
|
|
season_year="2024",
|
|
season_number=3,
|
|
group_seasons=group_seasons,
|
|
)
|
|
async_result = asyncio.run(
|
|
api.async_match(
|
|
"目标剧",
|
|
MediaType.TV,
|
|
year="2018",
|
|
season_year="2024",
|
|
season_number=3,
|
|
group_seasons=group_seasons,
|
|
)
|
|
)
|
|
|
|
assert sync_result == async_result
|
|
assert sync_result["id"] == 20
|
|
assert api.search.tv_shows.call_args_list == [call(term="目标剧")]
|
|
assert api.search.async_tv_shows.call_args_list == [call(term="目标剧")]
|
|
api.tv.details.assert_called_once()
|
|
api.tv.async_details.assert_awaited_once()
|
|
|
|
|
|
def test_match_multi_sync_async_share_sorting_and_alias_type_lookup() -> None:
|
|
"""多类型别名匹配双入口应跳过无关类型并按候选类型查详情。"""
|
|
api = _api()
|
|
sync_multis = [
|
|
{"id": 1, "media_type": "person", "name": "目标别名"},
|
|
{
|
|
"id": 21,
|
|
"media_type": "tv",
|
|
"name": "展示剧名",
|
|
"original_name": "Original Series",
|
|
"first_air_date": "2024-01-01",
|
|
},
|
|
]
|
|
async_multis = deepcopy(sync_multis)
|
|
sync_detail = _tv_detail(21, name="展示剧名")
|
|
sync_detail["alternative_titles"]["results"][0]["title"] = "目标别名"
|
|
async_detail = deepcopy(sync_detail)
|
|
api.search.multi.return_value = sync_multis
|
|
api.search.async_multi.return_value = async_multis
|
|
api.tv.details.return_value = sync_detail
|
|
api.tv.async_details.return_value = async_detail
|
|
|
|
sync_result = api.match_multi("目标别名")
|
|
async_result = asyncio.run(api.async_match_multi("目标别名"))
|
|
|
|
assert sync_result == async_result
|
|
assert sync_result["id"] == 21
|
|
assert sync_result["media_type"] == MediaType.TV
|
|
api.movie.details.assert_not_called()
|
|
api.movie.async_details.assert_not_awaited()
|
|
api.tv.details.assert_called_once()
|
|
api.tv.async_details.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("outcome", "expected"),
|
|
[([], {}), (TMDbException("查询失败"), None)],
|
|
)
|
|
def test_match_multi_sync_async_share_empty_and_exception_results(
|
|
outcome: Union[list, Exception], expected: Optional[dict]
|
|
) -> None:
|
|
"""多类型搜索的空结果和异常在双入口应保留相同三态语义。"""
|
|
api = _api()
|
|
if isinstance(outcome, Exception):
|
|
api.search.multi.side_effect = outcome
|
|
api.search.async_multi.side_effect = outcome
|
|
else:
|
|
api.search.multi.return_value = outcome
|
|
api.search.async_multi.return_value = deepcopy(outcome)
|
|
|
|
assert api.match_multi("目标") == expected
|
|
assert asyncio.run(api.async_match_multi("目标")) == expected
|
|
|
|
|
|
def test_get_info_sync_async_share_projection() -> None:
|
|
"""详情双入口应共享类型、别名、分级和多语言字段投影。"""
|
|
api = _api()
|
|
sync_detail = _movie_detail(30)
|
|
async_detail = deepcopy(sync_detail)
|
|
api.movie.details.return_value = sync_detail
|
|
api.movie.async_details.return_value = async_detail
|
|
|
|
sync_result = api.get_info(MediaType.MOVIE, 30)
|
|
async_result = asyncio.run(api.async_get_info(MediaType.MOVIE, 30))
|
|
|
|
assert sync_result == async_result
|
|
assert sync_result["media_type"] == MediaType.MOVIE
|
|
assert sync_result["genre_ids"] == [18, 35]
|
|
assert sync_result["content_rating"] == "PG-13"
|
|
assert sync_result["en_title"] == "Original Title"
|
|
assert "目标别名" in sync_result["names"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("tv_exists", "movie_exists", "expected_type"),
|
|
[
|
|
(True, False, MediaType.TV),
|
|
(False, True, MediaType.MOVIE),
|
|
(True, True, None),
|
|
(False, False, None),
|
|
],
|
|
)
|
|
def test_get_info_unknown_type_sync_async_share_lookup_order(
|
|
tv_exists: bool,
|
|
movie_exists: bool,
|
|
expected_type: Optional[MediaType],
|
|
) -> None:
|
|
"""未知类型详情双入口应先查剧集再查电影并统一判断歧义。"""
|
|
api = _api()
|
|
sync_calls = []
|
|
async_calls = []
|
|
|
|
def sync_tv(*args, **kwargs):
|
|
sync_calls.append("tv")
|
|
return _tv_detail(40) if tv_exists else {}
|
|
|
|
def sync_movie(*args, **kwargs):
|
|
sync_calls.append("movie")
|
|
return _movie_detail(40) if movie_exists else {}
|
|
|
|
async def async_tv(*args, **kwargs):
|
|
async_calls.append("tv")
|
|
return _tv_detail(40) if tv_exists else {}
|
|
|
|
async def async_movie(*args, **kwargs):
|
|
async_calls.append("movie")
|
|
return _movie_detail(40) if movie_exists else {}
|
|
|
|
api.tv.details.side_effect = sync_tv
|
|
api.movie.details.side_effect = sync_movie
|
|
api.tv.async_details.side_effect = async_tv
|
|
api.movie.async_details.side_effect = async_movie
|
|
|
|
sync_result = api.get_info(None, 40)
|
|
async_result = asyncio.run(api.async_get_info(None, 40))
|
|
|
|
assert sync_calls == async_calls == ["tv", "movie"]
|
|
assert (sync_result or {}).get("media_type") == expected_type
|
|
assert (async_result or {}).get("media_type") == expected_type
|
|
assert bool(sync_result) == bool(async_result)
|
|
|
|
|
|
def test_get_info_sync_async_propagate_connection_error_at_same_boundary() -> None:
|
|
"""显式连接错误传播开关在同步与异步详情入口应保持一致。"""
|
|
api = _api()
|
|
error = TMDbConnectionError("无法连接 TMDB")
|
|
api.movie.details.side_effect = error
|
|
api.movie.async_details.side_effect = error
|
|
|
|
with pytest.raises(TMDbConnectionError):
|
|
api.get_info(MediaType.MOVIE, 50, raise_on_connection_error=True)
|
|
with pytest.raises(TMDbConnectionError):
|
|
asyncio.run(api.async_get_info(MediaType.MOVIE, 50, raise_on_connection_error=True))
|