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

509 lines
17 KiB
Python

"""
MoviePilot 中心服务适配器同步异步一致性测试。
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from app.adapters.external import server as server_module
from app.adapters.external.server import MoviePilotServerHelper
@pytest.fixture(autouse=True)
def _reset_server_identity_cache():
"""
隔离中心服务用户名与权限短时缓存,避免同步入口短路异步入口或用例间串值。
"""
previous_user = MoviePilotServerHelper._github_user
MoviePilotServerHelper._github_user = None
asyncio.run(MoviePilotServerHelper._async_fetch_user_permissions.cache_clear())
yield
asyncio.run(MoviePilotServerHelper._async_fetch_user_permissions.cache_clear())
MoviePilotServerHelper._github_user = previous_user
def _response(status_code: int, payload: object) -> SimpleNamespace:
"""
构造不会触发真实网络的最小 HTTP 响应。
"""
return SimpleNamespace(status_code=status_code, json=Mock(return_value=payload))
def _settings(monkeypatch, **values) -> None:
"""
只为当前用例注入中心服务配置。
"""
monkeypatch.setattr(
server_module,
"get_runtime_setting",
lambda key: values.get(key),
)
@pytest.mark.asyncio
async def test_github_user_sync_async_use_same_request_plan(monkeypatch) -> None:
"""
GitHub 用户同步异步入口必须使用同一凭据、代理和响应映射。
"""
response = _response(200, {"login": "moviepilot"})
sync_client = Mock()
sync_client.get_res.return_value = response
async_client = Mock()
async_client.get_res = AsyncMock(return_value=response)
sync_factory = Mock(return_value=sync_client)
async_factory = Mock(return_value=async_client)
_settings(
monkeypatch,
GITHUB_HEADERS={"Authorization": "token"},
PROXY={"https": "http://proxy"},
)
monkeypatch.setattr(server_module, "RequestUtils", sync_factory)
monkeypatch.setattr(server_module, "AsyncRequestUtils", async_factory)
sync_result = MoviePilotServerHelper.get_github_user()
MoviePilotServerHelper._github_user = None
async_result = await MoviePilotServerHelper.async_get_github_user()
assert sync_result == async_result == "moviepilot"
assert sync_factory.call_args.kwargs == async_factory.call_args.kwargs == {
"headers": {"Authorization": "token"},
"proxies": {"https": "http://proxy"},
"timeout": 15,
}
sync_client.get_res.assert_called_once_with("https://api.github.com/user")
async_client.get_res.assert_awaited_once_with("https://api.github.com/user")
@pytest.mark.asyncio
@pytest.mark.parametrize(
("status_code", "payload", "expected"),
[
(200, {"is_admin": True}, {"is_admin": True}),
(403, {"message": "forbidden"}, {}),
],
)
async def test_user_permissions_sync_async_classify_response_identically(
monkeypatch,
status_code: int,
payload: dict,
expected: dict,
) -> None:
"""
用户权限同步异步入口必须共享 HTTP 状态和对象响应分类。
"""
response = _response(status_code, payload)
monkeypatch.setattr(
MoviePilotServerHelper,
"get_github_user",
Mock(return_value="moviepilot"),
)
monkeypatch.setattr(
MoviePilotServerHelper,
"async_get_github_user",
AsyncMock(return_value="moviepilot"),
)
sync_request = Mock(return_value=response)
async_request = AsyncMock(return_value=response)
monkeypatch.setattr(MoviePilotServerHelper, "user_permissions", sync_request)
monkeypatch.setattr(
MoviePilotServerHelper,
"async_user_permissions",
async_request,
)
sync_result = MoviePilotServerHelper.get_user_permissions()
async_result = await MoviePilotServerHelper.async_get_user_permissions()
assert sync_result == async_result == expected
sync_request.assert_called_once_with("moviepilot")
async_request.assert_awaited_once_with("moviepilot")
@pytest.mark.asyncio
async def test_user_permissions_sync_async_map_transport_failure_identically(
monkeypatch,
) -> None:
"""
用户权限同步异步传输异常都必须回退为空权限。
"""
monkeypatch.setattr(
MoviePilotServerHelper,
"get_github_user",
Mock(return_value="moviepilot"),
)
monkeypatch.setattr(
MoviePilotServerHelper,
"async_get_github_user",
AsyncMock(return_value="moviepilot"),
)
monkeypatch.setattr(
MoviePilotServerHelper,
"user_permissions",
Mock(side_effect=RuntimeError("offline")),
)
monkeypatch.setattr(
MoviePilotServerHelper,
"async_user_permissions",
AsyncMock(side_effect=RuntimeError("offline")),
)
assert MoviePilotServerHelper.get_user_permissions() == {}
assert await MoviePilotServerHelper.async_get_user_permissions() == {}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("enabled", "user_uid", "status_code", "expected"),
[
(False, "uid-1", 200, False),
(True, "", 200, False),
(True, "uid-1", 500, False),
(True, "uid-1", 200, True),
],
)
async def test_usage_report_sync_async_share_preflight_and_result_mapping(
monkeypatch,
enabled: bool,
user_uid: str,
status_code: int,
expected: bool,
) -> None:
"""
版本统计开关、实例身份和成功状态必须由同一计划解释。
"""
payload = {"user_uid": user_uid, "backend_version": "3.0.0"}
response = _response(status_code, {})
_settings(monkeypatch, USAGE_STATISTIC_SHARE=enabled)
monkeypatch.setattr(
MoviePilotServerHelper,
"build_usage_payload",
Mock(return_value=payload),
)
sync_request = Mock(return_value=response)
async_request = AsyncMock(return_value=response)
monkeypatch.setattr(MoviePilotServerHelper, "usage_report", sync_request)
monkeypatch.setattr(MoviePilotServerHelper, "async_usage_report", async_request)
sync_result = MoviePilotServerHelper.report_usage()
async_result = await MoviePilotServerHelper.async_report_usage()
assert sync_result == async_result == expected
if enabled and user_uid:
sync_request.assert_called_once_with(payload)
async_request.assert_awaited_once_with(payload)
else:
sync_request.assert_not_called()
async_request.assert_not_awaited()
@pytest.mark.asyncio
async def test_usage_report_sync_async_map_transport_failure_identically(
monkeypatch,
) -> None:
"""
版本统计同步异步传输异常都必须映射为上报失败。
"""
_settings(monkeypatch, USAGE_STATISTIC_SHARE=True)
monkeypatch.setattr(
MoviePilotServerHelper,
"build_usage_payload",
Mock(return_value={"user_uid": "uid-1"}),
)
monkeypatch.setattr(
MoviePilotServerHelper,
"usage_report",
Mock(side_effect=RuntimeError("offline")),
)
monkeypatch.setattr(
MoviePilotServerHelper,
"async_usage_report",
AsyncMock(side_effect=RuntimeError("offline")),
)
assert MoviePilotServerHelper.report_usage() is False
assert await MoviePilotServerHelper.async_report_usage() is False
@pytest.mark.asyncio
async def test_plugin_install_sync_async_share_payload_and_result(monkeypatch) -> None:
"""
插件安装统计同步异步入口必须使用同一脱敏载荷和成功分类。
"""
response = _response(200, {})
_settings(monkeypatch, PLUGIN_STATISTIC_SHARE=True)
sync_request = Mock(return_value=response)
async_request = AsyncMock(return_value=response)
monkeypatch.setattr(MoviePilotServerHelper, "plugin_install", sync_request)
monkeypatch.setattr(
MoviePilotServerHelper,
"async_plugin_install",
async_request,
)
sync_result = MoviePilotServerHelper.install_plugin_reg(
"DemoPlugin",
"local://DemoPlugin?version=v3",
)
async_result = await MoviePilotServerHelper.async_install_plugin_reg(
"DemoPlugin",
"local://DemoPlugin?version=v3",
)
expected_payload = {
"plugin_id": "DemoPlugin",
"repo_url": "local://DemoPlugin?version=v3",
}
assert sync_result == async_result is True
sync_request.assert_called_once_with("DemoPlugin", expected_payload)
async_request.assert_awaited_once_with("DemoPlugin", expected_payload)
@pytest.mark.asyncio
async def test_plugin_statistic_sync_async_share_response_classification(
monkeypatch,
) -> None:
"""
插件统计同步异步入口必须共享功能开关和对象响应分类。
"""
response = _response(200, {"DemoPlugin": 3})
_settings(monkeypatch, PLUGIN_STATISTIC_SHARE=True)
sync_request = Mock(return_value=response)
async_request = AsyncMock(return_value=response)
monkeypatch.setattr(MoviePilotServerHelper, "plugin_statistic", sync_request)
monkeypatch.setattr(
MoviePilotServerHelper,
"async_plugin_statistic",
async_request,
)
MoviePilotServerHelper.get_plugin_statistic.cache_clear()
sync_result = MoviePilotServerHelper.get_plugin_statistic()
async_result = await MoviePilotServerHelper.async_get_plugin_statistic()
assert sync_result == async_result == {"DemoPlugin": 3}
sync_request.assert_called_once_with()
async_request.assert_awaited_once_with()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("sync_name", "async_name", "sync_transport", "async_transport"),
[
("sub_reg", "async_sub_reg", "subscribe_add", "async_subscribe_add"),
("sub_done", "async_sub_done", "subscribe_done", "async_subscribe_done"),
],
)
async def test_subscribe_statistic_sync_async_share_payload_and_result(
monkeypatch,
sync_name: str,
async_name: str,
sync_transport: str,
async_transport: str,
) -> None:
"""
订阅新增与完成统计必须共享功能开关、载荷生成和成功分类。
"""
response = _response(200, {})
payload = {"media_source": "tmdb", "media_id": "1"}
_settings(monkeypatch, SUBSCRIBE_STATISTIC_SHARE=True)
payload_builder = Mock(return_value=payload)
sync_request = Mock(return_value=response)
async_request = AsyncMock(return_value=response)
monkeypatch.setattr(
MoviePilotServerHelper,
"_build_subscribe_statistic_payload",
payload_builder,
)
monkeypatch.setattr(MoviePilotServerHelper, sync_transport, sync_request)
monkeypatch.setattr(MoviePilotServerHelper, async_transport, async_request)
sync_result = getattr(MoviePilotServerHelper, sync_name)({"id": 1})
async_result = await getattr(MoviePilotServerHelper, async_name)({"id": 1})
assert sync_result == async_result is True
assert payload_builder.call_count == 2
sync_request.assert_called_once_with(payload)
async_request.assert_awaited_once_with(payload)
@pytest.mark.asyncio
async def test_subscribe_list_sync_async_share_query_and_result(monkeypatch) -> None:
"""
订阅分享列表同步异步入口必须共享筛选参数和列表响应分类。
"""
response = _response(200, [{"id": 1}])
_settings(monkeypatch, SUBSCRIBE_STATISTIC_SHARE=True)
sync_request = Mock(return_value=response)
async_request = AsyncMock(return_value=response)
monkeypatch.setattr(MoviePilotServerHelper, "subscribe_shares", sync_request)
monkeypatch.setattr(
MoviePilotServerHelper,
"async_subscribe_shares",
async_request,
)
MoviePilotServerHelper.get_subscribe_shares.cache_clear()
await MoviePilotServerHelper.async_get_subscribe_shares.cache_clear()
sync_result = MoviePilotServerHelper.get_subscribe_shares(
name="Demo",
page=2,
count=10,
genre_id=16,
min_rating=7.0,
sort_type="rating",
)
await MoviePilotServerHelper.async_get_subscribe_shares.cache_clear()
async_result = await MoviePilotServerHelper.async_get_subscribe_shares(
name="Demo",
page=2,
count=10,
genre_id=16,
min_rating=7.0,
sort_type="rating",
)
expected_params = {
"page": 2,
"count": 10,
"name": "Demo",
"genre_id": 16,
"min_rating": 7.0,
"sort_type": "rating",
}
assert sync_result == async_result == [{"id": 1}]
sync_request.assert_called_once_with(expected_params)
async_request.assert_awaited_once_with(expected_params)
@pytest.mark.asyncio
async def test_workflow_list_sync_async_share_query_and_result(monkeypatch) -> None:
"""
工作流分享列表同步异步入口必须共享开关、分页计划和结果分类。
"""
response = _response(200, [{"id": 2}])
_settings(monkeypatch, WORKFLOW_STATISTIC_SHARE=True)
sync_request = Mock(return_value=response)
async_request = AsyncMock(return_value=response)
monkeypatch.setattr(MoviePilotServerHelper, "workflow_shares", sync_request)
monkeypatch.setattr(
MoviePilotServerHelper,
"async_workflow_shares",
async_request,
)
MoviePilotServerHelper.get_workflow_shares.cache_clear()
await MoviePilotServerHelper.async_get_workflow_shares.cache_clear()
sync_result = MoviePilotServerHelper.get_workflow_shares("Demo", 3, 20)
await MoviePilotServerHelper.async_get_workflow_shares.cache_clear()
async_result = await MoviePilotServerHelper.async_get_workflow_shares(
"Demo", 3, 20
)
expected_params = {"name": "Demo", "page": 3, "count": 20}
assert sync_result == async_result == [{"id": 2}]
sync_request.assert_called_once_with(expected_params)
async_request.assert_awaited_once_with(expected_params)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("setting_key", "sync_name", "async_name", "message"),
[
(
"SUBSCRIBE_STATISTIC_SHARE",
"share_delete",
"async_share_delete",
"当前没有开启订阅数据共享功能",
),
(
"WORKFLOW_STATISTIC_SHARE",
"workflow_share_delete_by_id",
"async_workflow_share_delete_by_id",
"当前没有开启工作流数据共享功能",
),
],
)
async def test_share_delete_sync_async_use_same_disabled_result(
monkeypatch,
setting_key: str,
sync_name: str,
async_name: str,
message: str,
) -> None:
"""
订阅与工作流删除入口必须共享禁用状态和失败文案。
"""
_settings(monkeypatch, **{setting_key: False})
sync_result = getattr(MoviePilotServerHelper, sync_name)(9)
async_result = await getattr(MoviePilotServerHelper, async_name)(9)
assert sync_result == async_result == (False, message)
@pytest.mark.asyncio
async def test_recognize_query_sync_async_share_plan_and_result(monkeypatch) -> None:
"""
共享识别查询必须共用启用判断、查询计划和业务响应解析。
"""
response = _response(200, {"code": 0, "data": {"item": {"media_id": "1"}}})
params = {"keyword": "Demo", "type": "movie"}
_settings(monkeypatch, MEDIA_RECOGNIZE_SHARE=True)
builder = Mock(return_value=params)
sync_request = Mock(return_value=response)
async_request = AsyncMock(return_value=response)
monkeypatch.setattr(
MoviePilotServerHelper,
"_build_recognize_query_params",
builder,
)
monkeypatch.setattr(MoviePilotServerHelper, "recognize_query", sync_request)
monkeypatch.setattr(
MoviePilotServerHelper,
"async_recognize_query",
async_request,
)
sync_result = MoviePilotServerHelper.query_recognize_share(None)
async_result = await MoviePilotServerHelper.async_query_recognize_share(None)
assert sync_result == async_result == {"media_id": "1"}
assert builder.call_count == 2
sync_request.assert_called_once_with(params)
async_request.assert_awaited_once_with(params)
@pytest.mark.asyncio
async def test_recognize_report_sync_async_share_plan_and_result(monkeypatch) -> None:
"""
共享识别上报必须共用启用判断、载荷计划和业务成功分类。
"""
response = _response(200, {"code": 0})
payload = {"keyword": "Demo", "media_source": "tmdb", "media_id": "1"}
_settings(monkeypatch, MEDIA_RECOGNIZE_SHARE=True)
builder = Mock(return_value=payload)
sync_request = Mock(return_value=response)
async_request = AsyncMock(return_value=response)
monkeypatch.setattr(
MoviePilotServerHelper,
"_build_recognize_report_payload",
builder,
)
monkeypatch.setattr(MoviePilotServerHelper, "recognize_report", sync_request)
monkeypatch.setattr(
MoviePilotServerHelper,
"async_recognize_report",
async_request,
)
sync_result = MoviePilotServerHelper.report_recognize_share(None, None)
async_result = await MoviePilotServerHelper.async_report_recognize_share(None, None)
assert sync_result == async_result is True
assert builder.call_count == 2
sync_request.assert_called_once_with(payload)
async_request.assert_awaited_once_with(payload)