Files
archived-MoviePilot/tests/test_tmdb_response_cache.py
2026-05-20 17:09:31 +08:00

359 lines
12 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.
import asyncio
import importlib.util
import pickle
import sys
from contextlib import asynccontextmanager, contextmanager
from functools import wraps
from pathlib import Path
from threading import RLock
from types import ModuleType, SimpleNamespace
from unittest import TestCase
TMDB_MODULE_NAME = "app.modules.themoviedb.tmdbv3api.tmdb"
TMDB_FILE_PATH = Path(__file__).resolve().parents[1] / "app/modules/themoviedb/tmdbv3api/tmdb.py"
def _ensure_package(name: str) -> ModuleType:
module = sys.modules.get(name)
if module is None:
module = ModuleType(name)
module.__path__ = []
sys.modules[name] = module
return module
def _install_tmdb_test_stubs() -> None:
for package_name in [
"app",
"app.core",
"app.utils",
"app.modules",
"app.modules.themoviedb",
"app.modules.themoviedb.tmdbv3api",
]:
_ensure_package(package_name)
cache_module = ModuleType("app.core.cache")
def cached(*args, **kwargs):
def decorator(func):
if asyncio.iscoroutinefunction(func):
@wraps(func)
async def async_wrapper(*wrapper_args, **wrapper_kwargs):
return await func(*wrapper_args, **wrapper_kwargs)
return async_wrapper
@wraps(func)
def wrapper(*wrapper_args, **wrapper_kwargs):
return func(*wrapper_args, **wrapper_kwargs)
return wrapper
return decorator
@contextmanager
def fresh(*args, **kwargs):
yield
@asynccontextmanager
async def async_fresh(*args, **kwargs):
yield
cache_module.cached = cached
cache_module.fresh = fresh
cache_module.async_fresh = async_fresh
sys.modules[cache_module.__name__] = cache_module
config_module = ModuleType("app.core.config")
config_module.settings = SimpleNamespace(
TMDB_API_KEY="dummy-key",
TMDB_LOCALE="en-US",
PROXY=None,
TMDB_API_DOMAIN="example.com",
NORMAL_USER_AGENT="MoviePilot-Test-UA",
CONF=SimpleNamespace(tmdb=8, meta=60),
)
sys.modules[config_module.__name__] = config_module
http_module = ModuleType("app.utils.http")
class RequestUtils:
def __init__(self, *args, **kwargs):
pass
def get_res(self, *args, **kwargs): # pragma: no cover - 测试中会替换
raise NotImplementedError
def post_res(self, *args, **kwargs): # pragma: no cover - 测试中会替换
raise NotImplementedError
class AsyncRequestUtils:
def __init__(self, *args, **kwargs):
pass
async def get_res(self, *args, **kwargs): # pragma: no cover - 测试中会替换
raise NotImplementedError
async def post_res(self, *args, **kwargs): # pragma: no cover - 测试中会替换
raise NotImplementedError
http_module.RequestUtils = RequestUtils
http_module.AsyncRequestUtils = AsyncRequestUtils
sys.modules[http_module.__name__] = http_module
exceptions_module = ModuleType("app.modules.themoviedb.tmdbv3api.exceptions")
class TMDbException(Exception):
pass
exceptions_module.TMDbException = TMDbException
sys.modules[exceptions_module.__name__] = exceptions_module
def _load_tmdb_class():
_install_tmdb_test_stubs()
sys.modules.pop(TMDB_MODULE_NAME, None)
spec = importlib.util.spec_from_file_location(TMDB_MODULE_NAME, TMDB_FILE_PATH)
module = importlib.util.module_from_spec(spec)
sys.modules[TMDB_MODULE_NAME] = module
assert spec and spec.loader
spec.loader.exec_module(module)
return module.TMDb
TMDb = _load_tmdb_class()
TMDbException = sys.modules["app.modules.themoviedb.tmdbv3api.exceptions"].TMDbException
class _FakeResponse:
def __init__(self, payload, headers: dict, status_code: int = 200, text: str = ""):
self._payload = payload
self.headers = headers
self.status_code = status_code
self.text = text
self._lock = RLock()
def json(self):
return self._payload
class _UnicodeDecodeErrorResponse:
"""
模拟 httpx.Response.json() 直接抛 UnicodeDecodeError 的异常响应。
"""
def __init__(self, content: bytes = b"\x8b", text: str = ""):
"""
初始化一个带有压缩响应特征的伪响应对象。
"""
self.headers = {"Content-Type": "application/json", "Content-Encoding": "gzip"}
self.status_code = 200
self.text = text
self.content = content
def json(self):
"""
模拟 httpx.Response.json() 在遇到错误编码响应时直接抛出 UnicodeDecodeError。
"""
raise UnicodeDecodeError("utf-8", b"\x8b", 1, 2, "invalid start byte")
class TmdbResponseCacheTest(TestCase):
def test_request_returns_pickleable_snapshot(self):
tmdb = TMDb()
response = _FakeResponse(
payload={"id": 1, "page": 2},
headers={"X-RateLimit-Remaining": "39", "X-RateLimit-Reset": "1234567890"},
)
tmdb._req.get_res = lambda *args, **kwargs: response
result = TMDb.request.__wrapped__(tmdb, "GET", "https://example.com", None, None)
self.assertTrue(result[TMDb._RESPONSE_SNAPSHOT_MARKER])
self.assertEqual(result["json"], {"id": 1, "page": 2})
self.assertEqual(result["headers"]["X-RateLimit-Remaining"], "39")
pickle.dumps(result)
def test_request_rejects_scalar_json_response(self):
"""
标量JSON响应不应进入TMDB响应缓存避免后续按对象解析崩溃。
"""
tmdb = TMDb()
response = _FakeResponse(payload="upstream error", headers={})
tmdb._req.get_res = lambda *args, **kwargs: response
with self.assertRaisesRegex(TMDbException, "返回数据格式异常"):
TMDb.request.__wrapped__(tmdb, "GET", "https://example.com", None, None)
def test_request_rejects_invalid_json_response(self):
"""
非JSON响应应转换为TMDbException调用方可按连接异常统一处理。
"""
class _InvalidJsonResponse:
headers = {"Content-Type": "text/html"}
status_code = 502
text = "<html>bad gateway</html>"
def json(self):
"""
模拟上游返回无法解析为JSON的响应体。
"""
raise ValueError("invalid json")
tmdb = TMDb()
tmdb._req.get_res = lambda *args, **kwargs: _InvalidJsonResponse()
with self.assertRaisesRegex(TMDbException, "不是有效JSON.*HTTP状态码502.*bad gateway"):
TMDb.request.__wrapped__(tmdb, "GET", "https://example.com", None, None)
def test_request_rejects_unicode_decode_error_response(self):
"""
错误编码的响应体也应转换为TMDbException避免UnicodeDecodeError直接冒泡。
"""
tmdb = TMDb()
tmdb._req.get_res = lambda *args, **kwargs: _UnicodeDecodeErrorResponse(
text="乱码内容不应进入日志"
)
with self.assertRaisesRegex(
TMDbException,
"不是有效JSON.*Content-Encodinggzip.*响应内容编码异常,已省略原始内容",
) as cm:
TMDb.request.__wrapped__(tmdb, "GET", "https://example.com", None, None)
self.assertNotIn("乱码内容", str(cm.exception))
def test_get_response_json_rejects_invalid_live_response(self):
"""
未缓存的实时响应解析失败时也应输出统一诊断信息。
"""
class _InvalidJsonResponse:
headers = {}
status_code = 200
text = ""
def json(self):
"""
模拟HTTP 200但响应体为空的情况。
"""
raise ValueError("empty")
with self.assertRaisesRegex(TMDbException, "不是有效JSON.*响应内容为空"):
TMDb._get_response_json(_InvalidJsonResponse())
def test_async_request_returns_pickleable_snapshot(self):
tmdb = TMDb()
response = _FakeResponse(
payload={"id": 2, "page": 3},
headers={"x-ratelimit-remaining": "38", "x-ratelimit-reset": "1234567891"},
)
async def _fake_get_res(*args, **kwargs):
return response
tmdb._async_req.get_res = _fake_get_res
result = asyncio.run(
TMDb.async_request.__wrapped__(tmdb, "GET", "https://example.com", None, None)
)
self.assertTrue(result[TMDb._RESPONSE_SNAPSHOT_MARKER])
self.assertEqual(result["json"], {"id": 2, "page": 3})
self.assertEqual(result["headers"]["x-ratelimit-remaining"], "38")
pickle.dumps(result)
def test_handle_headers_accepts_snapshot_headers(self):
tmdb = TMDb()
tmdb._handle_headers({"x-ratelimit-remaining": "7", "x-ratelimit-reset": "99"})
self.assertEqual(tmdb._remaining, 7)
self.assertEqual(tmdb._reset, 99)
def test_get_response_json_returns_snapshot_copy(self):
snapshot = {
TMDb._RESPONSE_SNAPSHOT_MARKER: True,
"headers": {},
"json": {
"results": [
{"id": 1, "media_type": "movie"},
{"id": 2, "media_type": "tv"},
]
},
}
first_json = TMDb._get_response_json(snapshot)
first_json["results"][0]["media_type"] = "电影"
second_json = TMDb._get_response_json(snapshot)
self.assertEqual(second_json["results"][0]["media_type"], "movie")
self.assertIsNot(first_json, second_json)
self.assertIsNot(first_json["results"][0], second_json["results"][0])
def test_async_request_obj_returns_copied_key_from_snapshot(self):
tmdb = TMDb()
snapshot = {
TMDb._RESPONSE_SNAPSHOT_MARKER: True,
"headers": {"x-ratelimit-remaining": "39", "x-ratelimit-reset": "1234567890"},
"json": {
"page": 1,
"results": [
{"id": 1, "media_type": "movie"},
{"id": 2, "media_type": "tv"},
],
},
}
async def _fake_async_request(*args, **kwargs):
return snapshot
tmdb.async_request = _fake_async_request
first_results = asyncio.run(tmdb._async_request_obj("/search/multi", key="results"))
first_results[0]["media_type"] = "电影"
second_results = asyncio.run(tmdb._async_request_obj("/search/multi", key="results"))
self.assertEqual(second_results[0]["media_type"], "movie")
self.assertIsNot(first_results, second_results)
self.assertIsNot(first_results[0], second_results[0])
def test_request_obj_rejects_scalar_snapshot_before_key_lookup(self):
"""
旧缓存中的标量快照不应在读取results字段时触发AttributeError。
"""
tmdb = TMDb()
snapshot = {
TMDb._RESPONSE_SNAPSHOT_MARKER: True,
"headers": {"x-ratelimit-remaining": "39", "x-ratelimit-reset": "1234567890"},
"json": "upstream error",
}
tmdb.request = lambda *args, **kwargs: snapshot
with self.assertRaisesRegex(TMDbException, "返回数据格式异常"):
tmdb._request_obj("/search/movie", key="results")
def test_async_request_obj_rejects_scalar_snapshot_before_key_lookup(self):
"""
异步对象请求读取旧标量快照时也应走统一TMDB异常路径。
"""
tmdb = TMDb()
snapshot = {
TMDb._RESPONSE_SNAPSHOT_MARKER: True,
"headers": {"x-ratelimit-remaining": "39", "x-ratelimit-reset": "1234567890"},
"json": "upstream error",
}
async def _fake_async_request(*args, **kwargs):
"""
模拟异步请求命中已缓存的异常快照。
"""
return snapshot
tmdb.async_request = _fake_async_request
with self.assertRaisesRegex(TMDbException, "返回数据格式异常"):
asyncio.run(tmdb._async_request_obj("/search/movie", key="results"))