Files
MoviePilot/tests/test_message_ingress.py
T

225 lines
7.8 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]
def test_forward_message_to_host_encodes_source_and_closes_response(monkeypatch):
"""统一入口必须安全编码查询参数并释放本地 HTTP 响应。"""
response = SimpleNamespace(status_code=200, close=MagicMock())
post_res = MagicMock(return_value=response)
request = MagicMock()
request.post_res = post_res
request_factory = MagicMock(return_value=request)
monkeypatch.setattr(
ingress,
"settings",
SimpleNamespace(PORT=3000, API_TOKEN="token value"),
)
monkeypatch.setattr(ingress, "RequestUtils", request_factory)
assert ingress.forward_message_to_host(
{"text": "hello"},
"channel & one",
timeout=9,
) is True
request_factory.assert_called_once_with(timeout=9)
url = post_res.call_args.args[0]
assert urlparse(url).path == "/api/v1/message"
assert parse_qs(urlparse(url).query) == {
"token": ["token value"],
"source": ["channel & one"],
}
assert post_res.call_args.kwargs["json"] == {"text": "hello"}
response.close.assert_called_once_with()
@pytest.mark.parametrize("status_code", [400, 500])
def test_forward_message_to_host_rejects_unconfirmed_response(
monkeypatch,
status_code,
):
"""本地入口无响应或返回错误状态时不得宣称渠道消息已接收。"""
response = SimpleNamespace(status_code=status_code, close=MagicMock())
request = MagicMock()
request.post_res.return_value = response
monkeypatch.setattr(
ingress,
"settings",
SimpleNamespace(PORT=3000, API_TOKEN="token"),
)
monkeypatch.setattr(ingress, "RequestUtils", MagicMock(return_value=request))
assert ingress.forward_message_to_host({}, "channel") is False
response.close.assert_called_once_with()
@pytest.mark.asyncio
async def test_async_forward_message_to_host_uses_same_contract(monkeypatch):
"""自有事件循环的渠道必须复用同一 URL、确认规则和异步资源释放。"""
response = SimpleNamespace(status_code=200, aclose=AsyncMock())
request = MagicMock()
request.post_res = AsyncMock(return_value=response)
request_factory = MagicMock(return_value=request)
monkeypatch.setattr(
ingress,
"settings",
SimpleNamespace(PORT=3000, API_TOKEN="token value"),
)
monkeypatch.setattr(ingress, "AsyncRequestUtils", request_factory)
assert await ingress.async_forward_message_to_host(
{"text": "hello"},
"discord & one",
timeout=10,
) is True
request_factory.assert_called_once_with(timeout=10)
url = request.post_res.await_args.args[0]
assert parse_qs(urlparse(url).query) == {
"token": ["token value"],
"source": ["discord & one"],
}
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 == []