# -*- 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.core.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}