Files
MoviePilot/tests/test_tmdb_retry_and_errors.py
2026-08-14 15:45:38 +08:00

504 lines
19 KiB
Python
Raw 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.
# -*- coding: utf-8 -*-
"""
覆盖TMDB识别链路两个已核实缺陷的回归测试。
§6.1 零退避重试:
同步 `TMDb.request` 在连接失败重建会话后立即重试两次尝试间隔为0
异步 `TMDb.async_request` 遇到连接失败时完全没有重试。
NAS+FUSE网盘环境下TMDB连接偶发的都是数秒内可自愈的瞬时抖动零退避/不重试
基本无法覆盖这类抖动窗口。
§6.2 网络故障与"条目不存在"文案混淆:
`TmdbApi.__get_movie_detail`/`__get_tv_detail` 对所有异常(含真实网络故障与
TMDB返回的404"资源不存在")一律 `except Exception: return None`,两类完全不同
性质的失败被折叠成同一个 None`TheMovieDbModule._get_info_by_tmdbid` 又用
`info_tv or info_movie or None` 把结果进一步折叠,最终识别失败统一报
"无法确定媒体类型,识别失败",用户无法判断该等网络恢复还是该确认条目不存在。
"""
import asyncio
from unittest.mock import AsyncMock, Mock
import pytest
import app.modules.themoviedb as themoviedb_module
from app.domain.metainfo import MetaInfo
from app.modules.themoviedb import TheMovieDbModule
from app.modules.themoviedb.tmdb_cache import TmdbCache
from app.modules.themoviedb.tmdbapi import TmdbApi
from app.modules.themoviedb.tmdbv3api import tmdb as tmdb_module
from app.modules.themoviedb.tmdbv3api.exceptions import TMDbConnectionError, TMDbException
from app.modules.themoviedb.tmdbv3api.tmdb import TMDb
from app.schemas.types import MediaSource, MediaType
class _FakeResponse:
"""测试用响应对象,模拟 requests/httpx 响应的最小接口。"""
def __init__(self, payload, headers: dict = None):
"""初始化响应内容。"""
self._payload = payload
self.headers = headers or {}
self.status_code = 200
self.text = ""
def json(self):
"""返回预置JSON内容。"""
return self._payload
# ---------------------------------------------------------------------------
# §6.1 零退避重试
# ---------------------------------------------------------------------------
def test_tmdb_connection_error_is_tmdb_exception_subclass():
"""
TMDbConnectionError必须是TMDbException的子类
否则现有 `except TMDbException` 代码路径会因新增异常类型而失效。
"""
assert issubclass(TMDbConnectionError, TMDbException)
def test_request_sleeps_before_retry_after_connection_failure(monkeypatch):
"""
同步请求失败后,重建会话重试前应等待 RETRY_BACKOFF_SECONDS 秒,
而不是零间隔立即重试。
"""
tmdb = TMDb()
response = _FakeResponse(payload={"id": 1})
request_results = [None, response]
tmdb._request_once = lambda method, url, data, json: request_results.pop(0)
sleep_calls = []
monkeypatch.setattr(tmdb_module.time, "sleep", lambda seconds: sleep_calls.append(seconds))
result = TMDb.request.__wrapped__(tmdb, "GET", "https://example.com", None, None)
assert result["json"] == {"id": 1}
assert sleep_calls == [tmdb_module.RETRY_BACKOFF_SECONDS]
assert 1 <= tmdb_module.RETRY_BACKOFF_SECONDS <= 3
def test_request_raises_connection_error_after_retry_exhausted(monkeypatch):
"""
重试后依旧失败时,应抛出更具体的 TMDbConnectionError而不仅是笼统的 TMDbException
供上层区分"网络故障"与TMDB业务层错误对外仍保持"最终失败抛异常"的语义不变。
"""
tmdb = TMDb()
tmdb._request_once = lambda method, url, data, json: None
monkeypatch.setattr(tmdb_module.time, "sleep", lambda seconds: None)
with pytest.raises(TMDbConnectionError, match="无法连接TheMovieDb"):
TMDb.request.__wrapped__(tmdb, "GET", "https://example.com", None, None)
def test_async_request_retries_once_after_connection_failure(monkeypatch):
"""
异步请求当前完全没有重试;修复后首次失败应等待 RETRY_BACKOFF_SECONDS 秒
后重试一次,重试成功则返回正常结果。
"""
tmdb = TMDb()
response = _FakeResponse(payload={"id": 2})
outcomes = [None, response]
async def _fake_once(method, url, data, json):
return outcomes.pop(0)
tmdb._async_request_once = _fake_once
sleep_calls = []
async def _fake_sleep(seconds):
sleep_calls.append(seconds)
monkeypatch.setattr(tmdb_module.asyncio, "sleep", _fake_sleep)
result = asyncio.run(
TMDb.async_request.__wrapped__(tmdb, "GET", "https://example.com", None, None)
)
assert result["json"] == {"id": 2}
assert sleep_calls == [tmdb_module.RETRY_BACKOFF_SECONDS]
def test_async_request_raises_connection_error_after_retry_exhausted(monkeypatch):
"""异步请求重试一次后仍失败,应抛出 TMDbConnectionError保持最终失败语义。"""
tmdb = TMDb()
async def _fake_once(method, url, data, json):
return None
tmdb._async_request_once = _fake_once
async def _fake_sleep(seconds):
return None
monkeypatch.setattr(tmdb_module.asyncio, "sleep", _fake_sleep)
with pytest.raises(TMDbConnectionError, match="无法连接TheMovieDb"):
asyncio.run(TMDb.async_request.__wrapped__(tmdb, "GET", "https://example.com", None, None))
def test_request_exception_path_is_not_cached(monkeypatch):
"""
`request`/`async_request` 带 `@cached(skip_none=True)`;抛异常时函数没有
正常返回值,缓存装饰器的 `cache_backend.set` 分支不会被执行到,天然不会缓存。
这里通过真实缓存路径(不使用 __wrapped__验证连续两次同参数请求都会真实
触发底层请求,而不是第二次直接命中一个"异常快照"
"""
tmdb = TMDb()
call_count = {"n": 0}
def _always_fail(method, url, data, json):
call_count["n"] += 1
return None
tmdb._request_once = _always_fail
tmdb._reset_owned_session = lambda: None
monkeypatch.setattr(tmdb_module.time, "sleep", lambda seconds: None)
for _ in range(2):
with pytest.raises(TMDbConnectionError):
tmdb.request("GET", "https://example.com/exc-not-cached", None, None)
# 两次调用都真实触发了底层请求(各自都经历了一次重试),说明异常没有被当作
# 缓存命中而跳过;即 2 次调用 × 2 次尝试 = 4 次底层请求。
assert call_count["n"] == 4
# ---------------------------------------------------------------------------
# §6.2 网络故障与"条目不存在"文案混淆
# ---------------------------------------------------------------------------
def test_request_obj_raises_plain_tmdb_exception_for_not_found_status():
"""
调研结论TMDB对404"资源不存在"的响应体形如
`{"success": false, "status_code": 34, "status_message": "..."}`
`TMDb._handle_errors` 据此抛出的是普通 TMDbException而不是表示传输层失败的
TMDbConnectionError。这与`request`/`async_request`在请求彻底失败返回None
时抛出的 TMDbConnectionError是两类不同的异常不应混淆处理。
"""
tmdb = TMDb()
snapshot = {
TMDb._RESPONSE_SNAPSHOT_MARKER: True,
"headers": {},
"json": {
"success": False,
"status_code": 34,
"status_message": "The resource you requested could not be found.",
},
}
tmdb.request = lambda *args, **kwargs: snapshot
with pytest.raises(TMDbException) as exc_info:
tmdb._request_obj("/movie/999999999")
assert not isinstance(exc_info.value, TMDbConnectionError)
def test_get_movie_detail_propagates_connection_error_when_requested():
"""
`TmdbApi.get_info(..., raise_on_connection_error=True)` 遇到TMDB连接失败时
应该把 TMDbConnectionError 传播出去,而不是像默认那样吞掉返回 None。
"""
tmdb_api = TmdbApi()
tmdb_api.movie.details = Mock(
side_effect=TMDbConnectionError("无法连接TheMovieDb请检查网络连接")
)
with pytest.raises(TMDbConnectionError):
tmdb_api.get_info(mtype=MediaType.MOVIE, tmdbid=1, raise_on_connection_error=True)
def test_get_movie_detail_swallows_connection_error_by_default():
"""
不传 `raise_on_connection_error` 时默认False行为必须与修复前完全一致
任何异常都吞掉返回 None不破坏现有调用方。
"""
tmdb_api = TmdbApi()
tmdb_api.movie.details = Mock(
side_effect=TMDbConnectionError("无法连接TheMovieDb请检查网络连接")
)
assert tmdb_api.get_info(mtype=MediaType.MOVIE, tmdbid=1) is None
def test_get_tv_detail_not_found_error_still_returns_none_even_with_flag():
"""
即使显式要求 `raise_on_connection_error=True`TMDB业务层返回的"资源不存在"
普通TMDbException非TMDbConnectionError也不应被当作连接失败传播出去
应继续按现有语义返回 None。
"""
tmdb_api = TmdbApi()
tmdb_api.tv.details = Mock(
side_effect=TMDbException("The resource you requested could not be found.")
)
result = tmdb_api.get_info(mtype=MediaType.TV, tmdbid=1, raise_on_connection_error=True)
assert result is None
def test_async_get_info_propagates_connection_error_when_requested():
"""异步版本的详情查询同样要支持连接失败的显式传播。"""
tmdb_api = TmdbApi()
tmdb_api.movie.async_details = AsyncMock(
side_effect=TMDbConnectionError("无法连接TheMovieDb请检查网络连接")
)
with pytest.raises(TMDbConnectionError):
asyncio.run(
tmdb_api.async_get_info(mtype=MediaType.MOVIE, tmdbid=1, raise_on_connection_error=True)
)
def test_async_get_info_swallows_connection_error_by_default():
"""异步默认行为同样必须保持向后兼容。"""
tmdb_api = TmdbApi()
tmdb_api.movie.async_details = AsyncMock(
side_effect=TMDbConnectionError("无法连接TheMovieDb请检查网络连接")
)
result = asyncio.run(tmdb_api.async_get_info(mtype=MediaType.MOVIE, tmdbid=1))
assert result is None
class _FakeTmdbApi:
"""
模拟 TmdbApi.get_info/async_get_info用给定结果驱动
`TheMovieDbModule._get_info_by_tmdbid` 的三种分支:确定命中、确认不存在、连接失败。
"""
def __init__(self, tv_outcome, movie_outcome):
"""使用电视剧、电影两路各自的结果初始化。"""
self.tv_outcome = tv_outcome
self.movie_outcome = movie_outcome
self.calls = []
def _resolve(self, outcome, raise_on_connection_error):
if isinstance(outcome, Exception):
if raise_on_connection_error and isinstance(outcome, TMDbConnectionError):
raise outcome
return None
return outcome
def match_multi(self, name: str):
"""
多类型名称匹配。真实实现tmdbapi.match_multi会吞掉所有异常并返回 None
连接失败与「未找到」在这条路径上不可区分fake 必须保持同样语义。
"""
return None
async def async_match_multi(self, name: str):
"""异步版多类型匹配,语义同上。"""
return None
async def async_match_multi(self, name: str):
"""异步版多类型匹配。"""
return self._multi_outcome()
def get_info(self, mtype, tmdbid, raise_on_connection_error=False):
"""同步查询按mtype返回预置结果忠实模拟 raise_on_connection_error 语义。"""
self.calls.append((mtype, raise_on_connection_error))
outcome = self.tv_outcome if mtype == MediaType.TV else self.movie_outcome
return self._resolve(outcome, raise_on_connection_error)
async def async_get_info(self, mtype, tmdbid, raise_on_connection_error=False):
"""异步查询:委托同步实现。"""
return self.get_info(mtype, tmdbid, raise_on_connection_error=raise_on_connection_error)
class _CacheProbe:
"""记录 update 调用参数get 恒返回未命中,用于验证网络故障路径不会触碰缓存。"""
def __init__(self):
"""初始化调用记录列表。"""
self.update_calls = []
def get(self, meta):
"""始终返回缓存未命中。"""
return {}
def update(self, meta, info):
"""记录写入尝试,供测试断言从未被调用或调用了哪些内容。"""
self.update_calls.append(info)
def _build_module(tv_outcome, movie_outcome) -> TheMovieDbModule:
"""构造绕开真实HTTP/缓存的 TheMovieDbModule 测试实例。"""
module = TheMovieDbModule()
module.tmdb = _FakeTmdbApi(tv_outcome, movie_outcome)
module.cache = _CacheProbe()
return module
def _build_meta(tmdbid: int = 98865) -> MetaInfo:
"""构造识别所需的最小元数据。"""
meta = MetaInfo(title="测试标题")
meta.tmdbid = tmdbid
return meta
def test_get_info_by_tmdbid_raises_connection_error_when_both_types_fail_to_connect():
"""
电影、电视剧两路查询都因TMDB连接失败而没有得到确定结果时
不能断言"条目不存在",应向上抛出 TMDbConnectionError。
"""
conn_err = TMDbConnectionError("无法连接TheMovieDb请检查网络连接")
module = _build_module(tv_outcome=conn_err, movie_outcome=conn_err)
with pytest.raises(TMDbConnectionError):
module._get_info_by_tmdbid(tmdbid=98865, mtype=None, meta=_build_meta())
def test_get_info_by_tmdbid_returns_none_when_both_confirmed_not_found():
"""
电影、电视剧两路查询都明确返回"未查询到"空dict非连接异常
应保持原有"无法确定媒体类型"语义,返回 None 且不抛异常。
"""
module = _build_module(tv_outcome={}, movie_outcome={})
result = module._get_info_by_tmdbid(tmdbid=98865, mtype=None, meta=_build_meta())
assert result is None
def test_get_info_by_tmdbid_prefers_positive_result_over_partial_connection_error():
"""
电视剧一路连接失败,但电影一路查到了确定结果时,应直接返回电影结果,
不能因为另一路的瞬时抖动就误判为整体失败。
"""
conn_err = TMDbConnectionError("无法连接TheMovieDb请检查网络连接")
movie_info = {
"id": 98865,
"media_type": MediaType.MOVIE,
"title": "测试电影",
"release_date": "2020-01-01",
"genres": [{"id": 28, "name": "动作"}],
}
module = _build_module(tv_outcome=conn_err, movie_outcome=movie_info)
result = module._get_info_by_tmdbid(tmdbid=98865, mtype=None, meta=_build_meta())
assert result is movie_info
def test_async_get_info_by_tmdbid_raises_connection_error_when_both_types_fail_to_connect():
"""异步版本同样要在两路都判定为连接失败时抛出 TMDbConnectionError。"""
conn_err = TMDbConnectionError("无法连接TheMovieDb请检查网络连接")
module = _build_module(tv_outcome=conn_err, movie_outcome=conn_err)
with pytest.raises(TMDbConnectionError):
asyncio.run(
module._async_get_info_by_tmdbid(tmdbid=98865, mtype=None, meta=_build_meta())
)
def test_recognize_media_reports_network_error_message_and_skips_cache_on_connection_failure(
monkeypatch,
):
"""
端到端回归复现因果链中的场景tmdb_id:98865 双路查询均连接失败)。
识别应返回 None日志应给出明确的网络故障文案而不是"无法确定媒体类型"
且绝不能写入任何缓存(正缓存、负缓存都不能写)。
"""
conn_err = TMDbConnectionError("无法连接TheMovieDb请检查网络连接")
module = _build_module(tv_outcome=conn_err, movie_outcome=conn_err)
mock_logger = Mock()
monkeypatch.setattr(themoviedb_module, "logger", mock_logger)
meta = MetaInfo(title="测试标题")
result = module.recognize_media(meta=meta, media_source=MediaSource.TMDB,
media_id="98865", cache=True)
assert result is None
# 网络故障场景不写入任何缓存条目(正缓存/负缓存皆不写)
assert module.cache.update_calls == []
logged_messages = " ".join(
str(call.args[0]) if call.args else "" for call in mock_logger.error.call_args_list
)
assert "连接TheMovieDb失败" in logged_messages or "连接 TheMovieDb 失败" in logged_messages
assert "无法确定媒体类型" not in logged_messages
def test_recognize_media_keeps_not_found_message_when_both_types_confirmed_absent(monkeypatch):
"""
电影、电视剧都确认查无此项(非连接失败)时,应维持原有的
"无法确定媒体类型,识别失败"文案,不能被网络错误文案顶替。
"""
module = _build_module(tv_outcome={}, movie_outcome={})
mock_logger = Mock()
monkeypatch.setattr(themoviedb_module, "logger", mock_logger)
meta = MetaInfo(title="测试标题")
result = module.recognize_media(meta=meta, media_source=MediaSource.TMDB,
media_id="98865", cache=True)
assert result is None
assert module.cache.update_calls == []
logged_messages = " ".join(
str(call.args[0]) if call.args else "" for call in mock_logger.warn.call_args_list
)
assert "无法确定媒体类型" in logged_messages
def test_async_recognize_media_reports_network_error_message_and_skips_cache_on_connection_failure(
monkeypatch,
):
"""异步识别路径同样要区分网络故障文案,且不得写入缓存。"""
conn_err = TMDbConnectionError("无法连接TheMovieDb请检查网络连接")
module = _build_module(tv_outcome=conn_err, movie_outcome=conn_err)
mock_logger = Mock()
monkeypatch.setattr(themoviedb_module, "logger", mock_logger)
meta = MetaInfo(title="测试标题")
result = asyncio.run(
module.async_recognize_media(meta=meta, media_source=MediaSource.TMDB,
media_id="98865", cache=True)
)
assert result is None
assert module.cache.update_calls == []
logged_messages = " ".join(
str(call.args[0]) if call.args else "" for call in mock_logger.error.call_args_list
)
assert "连接TheMovieDb失败" in logged_messages or "连接 TheMovieDb 失败" in logged_messages
assert "无法确定媒体类型" not in logged_messages
def test_cache_update_writes_nothing_for_connection_error_sentinel():
"""
回归锁定 `TmdbCache.update` 的既有安全语义info=None网络错误场景对应的
取值)不应写入任何缓存条目,正、负缓存都不写。
"""
cache = object.__new__(TmdbCache)
cache._cache = Mock()
meta = MetaInfo(title="测试标题")
meta.tmdbid = 98865
cache.update(meta, None)
cache._cache.set.assert_not_called()
def test_cache_update_still_writes_negative_cache_for_confirmed_empty_result():
"""
对照组info={}(确认查无此项)时仍应写入负缓存 `{"id": 0}`
与 info=None网络错误的行为形成对照证明二者语义未被本次修复混淆。
"""
cache = object.__new__(TmdbCache)
cache._cache = Mock()
meta = MetaInfo(title="测试标题")
meta.tmdbid = 98865
cache.update(meta, {})
cache._cache.set.assert_called_once()
args, _kwargs = cache._cache.set.call_args
assert args[1] == {"id": 0}