mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
349 lines
11 KiB
Python
349 lines
11 KiB
Python
"""
|
||
多消息渠道复用统一宿主回环入口的契约测试。
|
||
"""
|
||
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
from urllib.parse import parse_qs, urlparse
|
||
|
||
import pytest
|
||
|
||
from app.application.messaging import ingress
|
||
from app.modules.discord import discord as discord_module
|
||
from app.modules.feishu import feishu as feishu_module
|
||
from app.modules.qqbot import qqbot as qqbot_module
|
||
from app.modules.slack import slack as slack_module
|
||
from app.modules.telegram import telegram as telegram_module
|
||
from app.modules.wechat import wechatbot as wechat_module
|
||
from app.modules.wechatclawbot import wechatclawbot as clawbot_module
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||
|
||
|
||
class _FakeMessageIngressPort:
|
||
"""
|
||
返回固定状态码并记录同步、异步回环投递。
|
||
"""
|
||
|
||
def __init__(self, status_code=200) -> None:
|
||
"""
|
||
保存固定状态码。
|
||
"""
|
||
self.status_code = status_code
|
||
self.sync_calls = []
|
||
self.async_calls = []
|
||
|
||
def post(self, url, payload, *, headers, timeout):
|
||
"""
|
||
记录同步投递并返回固定状态码。
|
||
"""
|
||
self.sync_calls.append((url, payload, headers, timeout))
|
||
return self.status_code
|
||
|
||
async def async_post(self, url, payload, *, headers, timeout):
|
||
"""
|
||
记录异步投递并返回固定状态码。
|
||
"""
|
||
self.async_calls.append((url, payload, headers, timeout))
|
||
return self.status_code
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def restore_message_ingress_port():
|
||
"""
|
||
隔离消息回环端口并在用例结束后恢复。
|
||
"""
|
||
previous = ingress.configure_message_ingress_port(_FakeMessageIngressPort())
|
||
yield
|
||
ingress.reset_message_ingress_port(previous)
|
||
|
||
|
||
def _patch_ingress_settings(monkeypatch, **values):
|
||
"""
|
||
通过只读运行配置端口注入回环入口测试设置。
|
||
"""
|
||
settings = SimpleNamespace(**values)
|
||
monkeypatch.setattr(
|
||
ingress,
|
||
"get_runtime_setting",
|
||
lambda key: getattr(settings, key),
|
||
)
|
||
|
||
|
||
def test_forward_message_to_host_keeps_token_out_of_url(monkeypatch):
|
||
"""
|
||
统一入口仅编码来源参数,并通过请求头传递凭据和 payload 副本。
|
||
"""
|
||
port = _FakeMessageIngressPort()
|
||
ingress.configure_message_ingress_port(port)
|
||
_patch_ingress_settings(monkeypatch, PORT=3000, API_TOKEN="token value")
|
||
payload = {"text": "hello"}
|
||
|
||
assert ingress.forward_message_to_host(
|
||
payload,
|
||
"channel & one",
|
||
timeout=9,
|
||
) is True
|
||
|
||
url, forwarded, headers, timeout = port.sync_calls[0]
|
||
assert urlparse(url).path == "/api/v1/message"
|
||
assert parse_qs(urlparse(url).query) == {
|
||
"source": ["channel & one"],
|
||
}
|
||
assert headers == {"X-API-KEY": "token value"}
|
||
assert forwarded == {"text": "hello"}
|
||
assert forwarded is not payload
|
||
assert timeout == 9
|
||
|
||
|
||
@pytest.mark.parametrize("status_code", [None, 400, 500])
|
||
def test_forward_message_to_host_rejects_unconfirmed_response(
|
||
monkeypatch,
|
||
status_code,
|
||
):
|
||
"""
|
||
本地入口无响应或返回错误状态时不得宣称渠道消息已接收。
|
||
"""
|
||
ingress.configure_message_ingress_port(_FakeMessageIngressPort(status_code))
|
||
_patch_ingress_settings(monkeypatch, PORT=3000, API_TOKEN="token")
|
||
|
||
assert ingress.forward_message_to_host({}, "channel") is False
|
||
|
||
|
||
def test_forward_message_to_host_fails_closed_when_port_is_unconfigured(monkeypatch):
|
||
"""
|
||
组合根未装配时公开入口稳定拒绝,不得懒加载具体 Adapter。
|
||
"""
|
||
ingress.reset_message_ingress_port()
|
||
_patch_ingress_settings(monkeypatch, PORT=3000, API_TOKEN="token")
|
||
|
||
assert ingress.forward_message_to_host({}, "channel") is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_forward_message_to_host_uses_same_contract(monkeypatch):
|
||
"""
|
||
自有事件循环的渠道必须复用同一 URL、payload 和确认规则。
|
||
"""
|
||
port = _FakeMessageIngressPort()
|
||
ingress.configure_message_ingress_port(port)
|
||
_patch_ingress_settings(monkeypatch, PORT=3000, API_TOKEN="token value")
|
||
|
||
assert await ingress.async_forward_message_to_host(
|
||
{"text": "hello"},
|
||
"discord & one",
|
||
timeout=10,
|
||
) is True
|
||
|
||
url, payload, headers, timeout = port.async_calls[0]
|
||
assert parse_qs(urlparse(url).query) == {
|
||
"source": ["discord & one"],
|
||
}
|
||
assert headers == {"X-API-KEY": "token value"}
|
||
assert payload == {"text": "hello"}
|
||
assert timeout == 10
|
||
|
||
|
||
def test_startup_message_ingress_adapter_closes_sync_response(monkeypatch):
|
||
"""
|
||
生产同步注入适配层必须负责关闭 HTTP 响应。
|
||
"""
|
||
from app.startup.composition import network as network_composition
|
||
|
||
response = SimpleNamespace(status_code=202, close=MagicMock())
|
||
request = MagicMock()
|
||
request.post_res.return_value = response
|
||
monkeypatch.setattr(
|
||
network_composition,
|
||
"RequestUtils",
|
||
MagicMock(return_value=request),
|
||
)
|
||
|
||
status_code = network_composition._MessageIngressAdapter().post(
|
||
"http://127.0.0.1/message",
|
||
{"text": "hello"},
|
||
headers={"X-API-KEY": "secret"},
|
||
timeout=9,
|
||
)
|
||
|
||
assert status_code == 202
|
||
network_composition.RequestUtils.assert_called_once_with(
|
||
timeout=9,
|
||
headers={"X-API-KEY": "secret"},
|
||
)
|
||
response.close.assert_called_once_with()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_startup_message_ingress_adapter_closes_async_response(monkeypatch):
|
||
"""
|
||
生产异步注入适配层必须负责关闭 HTTP 响应。
|
||
"""
|
||
from app.startup.composition import network as network_composition
|
||
|
||
response = SimpleNamespace(status_code=202, aclose=AsyncMock())
|
||
request = MagicMock()
|
||
request.post_res = AsyncMock(return_value=response)
|
||
monkeypatch.setattr(
|
||
network_composition,
|
||
"AsyncRequestUtils",
|
||
MagicMock(return_value=request),
|
||
)
|
||
|
||
status_code = await network_composition._MessageIngressAdapter().async_post(
|
||
"http://127.0.0.1/message",
|
||
{"text": "hello"},
|
||
headers={"X-API-KEY": "secret"},
|
||
timeout=10,
|
||
)
|
||
|
||
assert status_code == 202
|
||
network_composition.AsyncRequestUtils.assert_called_once_with(
|
||
timeout=10,
|
||
headers={"X-API-KEY": "secret"},
|
||
)
|
||
response.aclose.assert_awaited_once_with()
|
||
|
||
|
||
def test_submit_message_to_host_copies_payload_and_reports_admission_failure():
|
||
"""
|
||
异步渠道提交时冻结顶层 payload,执行器拒绝任务则返回 False。
|
||
"""
|
||
submitted = []
|
||
|
||
def submit(function, *args, **kwargs):
|
||
"""
|
||
记录受管执行器收到的函数和参数。
|
||
"""
|
||
submitted.append((function, args, kwargs))
|
||
|
||
payload = {"text": "before"}
|
||
assert ingress.submit_message_to_host(
|
||
payload,
|
||
"channel",
|
||
submit=submit,
|
||
) is True
|
||
payload["text"] = "after"
|
||
|
||
assert submitted[0][0] is ingress.forward_message_to_host
|
||
assert submitted[0][1] == ({"text": "before"}, "channel")
|
||
assert submitted[0][2] == {"timeout": 15}
|
||
|
||
def reject(*_args, **_kwargs):
|
||
"""
|
||
模拟生命周期关闭后的执行器拒绝新任务。
|
||
"""
|
||
raise RuntimeError("executor closed")
|
||
|
||
assert ingress.submit_message_to_host({}, "channel", submit=reject) is False
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("module", "client_type", "source_attr"),
|
||
[
|
||
(feishu_module, feishu_module.Feishu, "_name"),
|
||
(qqbot_module, qqbot_module.QQBot, "_config_name"),
|
||
(wechat_module, wechat_module.WeChatBot, "_config_name"),
|
||
],
|
||
)
|
||
def test_threaded_channels_submit_through_managed_executor(
|
||
monkeypatch,
|
||
module,
|
||
client_type,
|
||
source_attr,
|
||
):
|
||
"""
|
||
原裸线程渠道必须把回环任务交给共享 ThreadHelper。
|
||
"""
|
||
calls = []
|
||
executor = SimpleNamespace(submit=lambda *_args, **_kwargs: None)
|
||
|
||
def submit_message(payload, source, *, submit, timeout=15):
|
||
"""
|
||
记录渠道传给统一提交边界的参数。
|
||
"""
|
||
calls.append((payload, source, submit, timeout))
|
||
return True
|
||
|
||
monkeypatch.setattr(module, "ThreadHelper", lambda: executor)
|
||
monkeypatch.setattr(module, "submit_message_to_host", submit_message)
|
||
client = object.__new__(client_type)
|
||
setattr(client, source_attr, "channel-main")
|
||
|
||
assert client._forward_to_message_chain({"text": "hello"}) is True
|
||
assert calls == [({"text": "hello"}, "channel-main", executor.submit, 15)]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("module", "client_type", "source_attr"),
|
||
[
|
||
(telegram_module, telegram_module.Telegram, "_config_name"),
|
||
(clawbot_module, clawbot_module.WechatClawBot, "_config_name"),
|
||
],
|
||
)
|
||
def test_sync_channels_forward_through_shared_ingress(
|
||
monkeypatch,
|
||
module,
|
||
client_type,
|
||
source_attr,
|
||
):
|
||
"""
|
||
同步轮询渠道必须复用统一回环请求和确认语义。
|
||
"""
|
||
forward = MagicMock(return_value=True)
|
||
monkeypatch.setattr(module, "forward_message_to_host", forward)
|
||
client = object.__new__(client_type)
|
||
setattr(client, source_attr, "channel-main")
|
||
|
||
assert client._forward_to_message_chain({"text": "hello"}) is True
|
||
forward.assert_called_once_with({"text": "hello"}, "channel-main")
|
||
|
||
|
||
def test_slack_preserves_callback_timeout_through_shared_ingress(monkeypatch):
|
||
"""
|
||
Slack action 的历史长超时必须继续传给统一入口。
|
||
"""
|
||
forward = MagicMock(return_value=True)
|
||
monkeypatch.setattr(slack_module, "forward_message_to_host", forward)
|
||
client = object.__new__(slack_module.Slack)
|
||
client._config_name = "slack-main"
|
||
|
||
assert client._forward_to_message_chain({"action": "run"}, timeout=60) is True
|
||
forward.assert_called_once_with(
|
||
{"action": "run"},
|
||
"slack-main",
|
||
timeout=60,
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_discord_uses_shared_async_ingress(monkeypatch):
|
||
"""
|
||
Discord 自有事件循环不得继续维护独立 httpx 回环实现。
|
||
"""
|
||
forward = AsyncMock(return_value=True)
|
||
monkeypatch.setattr(discord_module, "async_forward_message_to_host", forward)
|
||
client = object.__new__(discord_module.Discord)
|
||
client._config_name = "discord-main"
|
||
|
||
await client._post_to_ds({"text": "hello"})
|
||
|
||
forward.assert_awaited_once_with(
|
||
{"text": "hello"},
|
||
"discord-main",
|
||
timeout=10,
|
||
)
|
||
|
||
|
||
def test_message_modules_cannot_reimplement_loopback_endpoint():
|
||
"""
|
||
消息模块不得重新拼接宿主 URL,新增渠道必须复用统一 ingress。
|
||
"""
|
||
violations = []
|
||
for path in (PROJECT_ROOT / "app" / "modules").rglob("*.py"):
|
||
if "/api/v1/message" in path.read_text(encoding="utf-8-sig"):
|
||
violations.append(path.relative_to(PROJECT_ROOT).as_posix())
|
||
|
||
assert violations == []
|